refactor(test): migrate blackjack tests to use TestHelpers

Refactor the blackjack test suite to utilize the centralized
`TestHelpers`
from `OECS.PlayTest` instead of local implementations. This includes:

- Replacing local `SetupGame`, `FindEntity`, and `SnapshotWorld` with
  `TestHelpers` equivalents.
- Moving `PlayLog` logic and round running into the `OECS.PlayTest`
  framework.
- Updating project references to include `OECS.PlayTest`.
This commit is contained in:
hypercross 2026-07-21 18:09:47 +08:00
parent 9ef387f010
commit 94a9cc9519
4 changed files with 84 additions and 438 deletions

View File

@ -19,6 +19,7 @@
<ItemGroup>
<ProjectReference Include="..\Game.Blackjack\Blackjack.csproj" />
<ProjectReference Include="..\OECS.PlayTest\OECS.PlayTest.csproj" />
</ItemGroup>
</Project>

View File

@ -10,7 +10,7 @@ public class GameFlowTests
[Fact]
public void NewGame_StartsInBettingPhase()
{
var (world, _) = SetupGame();
var (world, _) = TestHelpers.SetupGame();
var state = world.ReadSingleton<GameState>();
state.Phase.Should().Be(GamePhase.Betting);
@ -21,13 +21,13 @@ public class GameFlowTests
[Fact]
public void PlaceBet_AdvancesToDealing()
{
var (world, group) = SetupGame();
var (world, group) = TestHelpers.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.Phase.Should().Be(GamePhase.PlayerTurn);
state.CurrentBet.Should().Be(10);
state.Chips.Should().Be(90);
}
@ -35,20 +35,20 @@ public class GameFlowTests
[Fact]
public void PlaceBet_RejectsInsufficientChips()
{
var (world, group) = SetupGame();
var (world, group) = TestHelpers.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.Phase.Should().Be(GamePhase.Betting);
state.Chips.Should().Be(100);
}
[Fact]
public void PlaceBet_RejectsZeroOrNegative()
{
var (world, group) = SetupGame();
var (world, group) = TestHelpers.SetupGame();
world.Commands.Enqueue(new PlaceBetCommand { Amount = 0 });
group.RunLogical();
@ -62,60 +62,53 @@ public class GameFlowTests
[Fact]
public void Dealing_CreatesDeckAndHands()
{
var (world, group) = SetupGame();
var (world, group) = TestHelpers.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);
TestHelpers.FindEntity<Deck>(world).Should().NotBe(Entity.Null);
TestHelpers.FindEntity<PlayerHand>(world).Should().NotBe(Entity.Null);
TestHelpers.FindEntity<DealerHand>(world).Should().NotBe(Entity.Null);
}
[Fact]
public void Dealing_DealsTwoCardsToEach()
{
var (world, group) = SetupGame();
var (world, group) = TestHelpers.SetupGame();
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
group.RunLogical();
var playerHand = FindEntity<PlayerHand>(world);
var dealerHand = FindEntity<DealerHand>(world);
var playerHand = TestHelpers.FindEntity<PlayerHand>(world);
var dealerHand = TestHelpers.FindEntity<DealerHand>(world);
CountCardsInHand(world, playerHand).Should().Be(2);
CountCardsInHand(world, dealerHand).Should().Be(2);
TestHelpers.CountCardsInHand(world, playerHand).Should().Be(2);
TestHelpers.CountCardsInHand(world, dealerHand).Should().Be(2);
}
[Fact]
public void Hit_DrawsOneCard()
{
var (world, group) = SetupGame();
var (world, group) = TestHelpers.SetupGame();
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
group.RunLogical();
var playerHand = FindEntity<PlayerHand>(world);
var before = CountCardsInHand(world, playerHand);
var playerHand = TestHelpers.FindEntity<PlayerHand>(world);
var before = TestHelpers.CountCardsInHand(world, playerHand);
world.Commands.Enqueue(new HitCommand());
group.RunLogical();
var after = CountCardsInHand(world, playerHand);
var after = TestHelpers.CountCardsInHand(world, playerHand);
after.Should().Be(before + 1);
}
[Fact]
public void Stand_AdvancesToDealerTurn()
{
var (world, group) = SetupGame();
var (world, group) = TestHelpers.SetupGame();
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
group.RunLogical();
@ -130,15 +123,13 @@ public class GameFlowTests
[Fact]
public void NewRound_ResetsPhase()
{
var (world, group) = SetupGame();
var (world, group) = TestHelpers.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();
@ -150,15 +141,15 @@ public class GameFlowTests
[Fact]
public void DeterministicSeed_ProducesSameDeal()
{
var (world1, group1) = SetupGame(seed: 42);
var (world1, group1) = TestHelpers.SetupGame(seed: 42);
world1.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
group1.RunLogical();
var cards1 = GetAllCards(world1);
var cards1 = TestHelpers.GetAllCards(world1);
var (world2, group2) = SetupGame(seed: 42);
var (world2, group2) = TestHelpers.SetupGame(seed: 42);
world2.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
group2.RunLogical();
var cards2 = GetAllCards(world2);
var cards2 = TestHelpers.GetAllCards(world2);
cards1.Should().Equal(cards2);
}
@ -166,20 +157,17 @@ public class GameFlowTests
[Fact]
public void PlayerBust_LosesBet()
{
// Use a seed that produces a bust-prone hand, then hit repeatedly.
var (world, group) = SetupGame(seed: 12345);
var (world, group) = TestHelpers.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);
}
@ -187,7 +175,7 @@ public class GameFlowTests
[Fact]
public void Serialization_RoundTrips()
{
var (world, group) = SetupGame(seed: 42);
var (world, group) = TestHelpers.SetupGame(seed: 42);
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
group.RunLogical();
@ -204,60 +192,4 @@ public class GameFlowTests
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
{
return world.FindEntity<T>();
}
private static int CountCardsInHand(World world, Entity handEntity)
{
// Holds relationship: the card entity (which Holds points to the hand entity).
// GetSources returns all card entities that have a Holds pointing to handEntity.
return world.GetSources<Holds>(handEntity).Count;
}
private static List<(Suit, Rank)> GetAllCards(World world)
{
var cards = new List<(Suit, Rank)>();
using (var iter = world.Select<Card>())
{
while (iter.MoveNext())
{
var card = iter.Val1;
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;
}
}
}

View File

@ -1,7 +1,7 @@
using FluentAssertions;
using Game.Blackjack;
using OECS;
using R3;
using OECS.PlayTest;
using Xunit;
namespace Game.Blackjack.Tests;
@ -14,167 +14,73 @@ public class PlayTests
[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 (world, group) = TestHelpers.SetupGame(seed: 42);
var agent = new BasicStrategyAgent();
log.Header = $"Blackjack: Basic Strategy (seed=42, agent={agent})";
RunUntilDone(world, group, agent, log);
var log = RunUntilDone(world, group, agent,
$"Blackjack: Basic Strategy (seed=42, agent={agent})");
log.FinalSnapshot = SnapshotWorld(world);
var path = SavePlayLog("blackjack_basic_strategy.playlog", log);
var path = log.SaveTo("blackjack_basic_strategy.playlog");
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 (world, group) = TestHelpers.SetupGame(seed: 123);
var agent = new RandomBlackjackAgent();
log.Header = $"Blackjack: Random Agent (seed=123, agent={agent})";
RunUntilDone(world, group, agent, log);
var log = RunUntilDone(world, group, agent,
$"Blackjack: Random Agent (seed=123, agent={agent})");
log.FinalSnapshot = SnapshotWorld(world);
var path = SavePlayLog("blackjack_random_agent.playlog", log);
var path = log.SaveTo("blackjack_random_agent.playlog");
ReadAndVerify(path);
}
[Fact]
public void Play_WeightedPool()
{
var log = new PlayLog();
var (world, group) = SetupGame(seed: 77);
var (world, group) = TestHelpers.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();
var pool = new WeightedAgentPool<BlackjackDecision>();
pool.Add(new BasicStrategyAgent(), 7);
pool.Add(new RandomBlackjackAgent(), 3);
var agent = pool.Pick();
log.Header = $"Blackjack: Weighted Pool (seed=77, agent={agent})";
var log = RunUntilDone(world, group, agent,
$"Blackjack: Weighted Pool (seed=77, agent={agent})");
RunUntilDone(world, group, agent, log);
log.FinalSnapshot = SnapshotWorld(world);
var path = SavePlayLog("blackjack_weighted_pool.playlog", log);
var path = log.SaveTo("blackjack_weighted_pool.playlog");
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)
private static PlayLog RunUntilDone(World world, SystemGroup group,
IAgent<BlackjackDecision> agent, string header)
{
var log = new PlayLog { Header = header };
using var capture = new ObservableCapture(world);
capture.FormatWith<Card>(c => $"{c.Rank} of {c.Suit}");
capture.FormatWith<GameState>(s => $"{s.Phase} Chips={s.Chips} Bet={s.CurrentBet}");
int totalRounds = 0;
int wins = 0;
int losses = 0;
int pushes = 0;
var roundSummaries = new List<string>();
var decisions = new List<string>();
while (true)
{
var state = world.ReadSingleton<GameState>();
int chips = state.Chips;
// Stop conditions.
if (chips < BetAmount)
{
log.Header += $" — BUSTED after {totalRounds} rounds";
@ -185,19 +91,15 @@ public class PlayTests
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;
int playerTurnSafety = 0;
while (world.ReadSingleton<GameState>().Phase == GamePhase.PlayerTurn && playerTurnSafety < 52)
@ -205,7 +107,7 @@ public class PlayTests
playerTurnSafety++;
var total = HandUtil.CalculateHand(world, new PlayerHand());
var decision = agent.Decide(world);
log.Decisions.Add($"R{totalRounds + 1} Hand={total}, Decision={decision}");
decisions.Add($"R{totalRounds + 1} Hand={total}, Decision={decision}");
if (decision == BlackjackDecision.Hit)
{
@ -220,12 +122,10 @@ public class PlayTests
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);
roundSummaries.Add($"Round {totalRounds + 1}: {state.Result} | Bet={BetAmount} | Chips: {chips}→{newChips} ({delta:+0;-#}) | Hits: {roundHits}");
switch (state.Result)
{
@ -244,71 +144,22 @@ public class PlayTests
totalRounds++;
// Start next round.
world.Commands.Enqueue(new NewRoundCommand());
group.RunLogical();
}
log.Header += $" | W:{wins} L:{losses} P:{pushes}";
}
// ── Snapshot ──────────────────────────────────────────────────────
log.AddSection("Round Summaries", roundSummaries);
log.AddSection("Decisions", decisions);
log.AddSection("Reactivity", capture.GetLogLines());
log.FinalSnapshot = TestHelpers.SnapshotWorld(world);
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
{
return world.FindEntity<T>();
}
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;
return log;
}
// ── 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);
@ -324,12 +175,7 @@ public class PlayTests
private enum BlackjackDecision { Hit, Stand }
private interface IBlackjackAgent
{
BlackjackDecision Decide(World world);
}
private sealed class BasicStrategyAgent : IBlackjackAgent
private sealed class BasicStrategyAgent : IAgent<BlackjackDecision>
{
public BlackjackDecision Decide(World world)
{
@ -339,7 +185,7 @@ public class PlayTests
public override string ToString() => "BasicStrategy";
}
private sealed class RandomBlackjackAgent : IBlackjackAgent
private sealed class RandomBlackjackAgent : IAgent<BlackjackDecision>
{
private static readonly Random _rng = new();
public BlackjackDecision Decide(World world)
@ -350,30 +196,4 @@ public class PlayTests
}
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;
}
}
}
}

View File

@ -1,7 +1,7 @@
using FluentAssertions;
using Game.Blackjack;
using OECS;
using R3;
using OECS.PlayTest;
using Xunit;
using Xunit.Abstractions;
@ -24,9 +24,9 @@ public class SnapshotTests
[Fact]
public void Snapshot_InitialState()
{
var (world, _) = SetupGame();
var (world, _) = TestHelpers.SetupGame();
var snapshot = SnapshotWorld(world);
var snapshot = TestHelpers.SnapshotWorld(world);
_output.WriteLine(snapshot);
snapshot.Should().Contain("Phase: Betting");
@ -37,12 +37,12 @@ public class SnapshotTests
[Fact]
public void Snapshot_AfterDeal()
{
var (world, group) = SetupGame(seed: 42);
var (world, group) = TestHelpers.SetupGame(seed: 42);
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
group.RunLogical();
var snapshot = SnapshotWorld(world);
var snapshot = TestHelpers.SnapshotWorld(world);
_output.WriteLine(snapshot);
snapshot.Should().Contain("Phase: PlayerTurn");
@ -56,7 +56,7 @@ public class SnapshotTests
[Fact]
public void Snapshot_AfterHit()
{
var (world, group) = SetupGame(seed: 42);
var (world, group) = TestHelpers.SetupGame(seed: 42);
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
group.RunLogical();
@ -64,17 +64,16 @@ public class SnapshotTests
world.Commands.Enqueue(new HitCommand());
group.RunLogical();
var snapshot = SnapshotWorld(world);
var snapshot = TestHelpers.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);
var (world, group) = TestHelpers.SetupGame(seed: 42);
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
group.RunLogical();
@ -82,7 +81,7 @@ public class SnapshotTests
world.Commands.Enqueue(new StandCommand());
group.RunLogical();
var snapshot = SnapshotWorld(world);
var snapshot = TestHelpers.SnapshotWorld(world);
_output.WriteLine(snapshot);
snapshot.Should().Contain("Phase: RoundOver");
@ -92,23 +91,11 @@ public class SnapshotTests
[Fact]
public void Log_ReactivityDuringRound()
{
var (world, group) = SetupGame(seed: 42);
var log = new List<string>();
var (world, group) = TestHelpers.SetupGame(seed: 42);
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}");
});
using var capture = new ObservableCapture(world);
capture.FormatWith<Card>(c => $"{c.Rank} of {c.Suit}");
capture.FormatWith<GameState>(s => $"{s.Phase} Chips={s.Chips} Bet={s.CurrentBet} Round={s.RoundNumber}");
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
group.RunLogical();
@ -116,24 +103,23 @@ public class SnapshotTests
world.Commands.Enqueue(new StandCommand());
group.RunLogical();
var logText = string.Join("\n", log);
var logText = string.Join("\n", capture.GetLogLines());
_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"));
capture.GetLogLines().Should().Contain(l => l.Contains("EntityAdded"));
capture.GetLogLines().Should().Contain(l => l.Contains("ComponentAdded") && l.Contains("Card"));
capture.GetLogLines().Should().Contain(l => l.Contains("ComponentModified") && l.Contains("GameState"));
}
[Fact]
public void Serialization_RoundTrip_PreservesCoreState()
{
var (world, group) = SetupGame(seed: 42);
var (world, group) = TestHelpers.SetupGame(seed: 42);
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
group.RunLogical();
var before = SnapshotWorld(world);
var before = TestHelpers.SnapshotWorld(world);
using var stream = new MemoryStream();
WorldSerializer.Save(world, stream);
@ -142,112 +128,19 @@ public class SnapshotTests
var world2 = new World();
WorldSerializer.Load(world2, stream);
var after = SnapshotWorld(world2);
var after = TestHelpers.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
{
return world.FindEntity<T>();
}
}
}