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> <ItemGroup>
<ProjectReference Include="..\Game.Blackjack\Blackjack.csproj" /> <ProjectReference Include="..\Game.Blackjack\Blackjack.csproj" />
<ProjectReference Include="..\OECS.PlayTest\OECS.PlayTest.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@ -10,7 +10,7 @@ public class GameFlowTests
[Fact] [Fact]
public void NewGame_StartsInBettingPhase() public void NewGame_StartsInBettingPhase()
{ {
var (world, _) = SetupGame(); var (world, _) = TestHelpers.SetupGame();
var state = world.ReadSingleton<GameState>(); var state = world.ReadSingleton<GameState>();
state.Phase.Should().Be(GamePhase.Betting); state.Phase.Should().Be(GamePhase.Betting);
@ -21,13 +21,13 @@ public class GameFlowTests
[Fact] [Fact]
public void PlaceBet_AdvancesToDealing() public void PlaceBet_AdvancesToDealing()
{ {
var (world, group) = SetupGame(); var (world, group) = TestHelpers.SetupGame();
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 }); world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
group.RunLogical(); group.RunLogical();
var state = world.ReadSingleton<GameState>(); 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.CurrentBet.Should().Be(10);
state.Chips.Should().Be(90); state.Chips.Should().Be(90);
} }
@ -35,20 +35,20 @@ public class GameFlowTests
[Fact] [Fact]
public void PlaceBet_RejectsInsufficientChips() public void PlaceBet_RejectsInsufficientChips()
{ {
var (world, group) = SetupGame(); var (world, group) = TestHelpers.SetupGame();
world.Commands.Enqueue(new PlaceBetCommand { Amount = 200 }); world.Commands.Enqueue(new PlaceBetCommand { Amount = 200 });
group.RunLogical(); group.RunLogical();
var state = world.ReadSingleton<GameState>(); 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); state.Chips.Should().Be(100);
} }
[Fact] [Fact]
public void PlaceBet_RejectsZeroOrNegative() public void PlaceBet_RejectsZeroOrNegative()
{ {
var (world, group) = SetupGame(); var (world, group) = TestHelpers.SetupGame();
world.Commands.Enqueue(new PlaceBetCommand { Amount = 0 }); world.Commands.Enqueue(new PlaceBetCommand { Amount = 0 });
group.RunLogical(); group.RunLogical();
@ -62,60 +62,53 @@ public class GameFlowTests
[Fact] [Fact]
public void Dealing_CreatesDeckAndHands() public void Dealing_CreatesDeckAndHands()
{ {
var (world, group) = SetupGame(); var (world, group) = TestHelpers.SetupGame();
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 }); world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
group.RunLogical(); group.RunLogical();
// Should have a deck entity. TestHelpers.FindEntity<Deck>(world).Should().NotBe(Entity.Null);
var deckEntity = FindEntity<Deck>(world); TestHelpers.FindEntity<PlayerHand>(world).Should().NotBe(Entity.Null);
deckEntity.Should().NotBe(Entity.Null); TestHelpers.FindEntity<DealerHand>(world).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] [Fact]
public void Dealing_DealsTwoCardsToEach() public void Dealing_DealsTwoCardsToEach()
{ {
var (world, group) = SetupGame(); var (world, group) = TestHelpers.SetupGame();
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 }); world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
group.RunLogical(); group.RunLogical();
var playerHand = FindEntity<PlayerHand>(world); var playerHand = TestHelpers.FindEntity<PlayerHand>(world);
var dealerHand = FindEntity<DealerHand>(world); var dealerHand = TestHelpers.FindEntity<DealerHand>(world);
CountCardsInHand(world, playerHand).Should().Be(2); TestHelpers.CountCardsInHand(world, playerHand).Should().Be(2);
CountCardsInHand(world, dealerHand).Should().Be(2); TestHelpers.CountCardsInHand(world, dealerHand).Should().Be(2);
} }
[Fact] [Fact]
public void Hit_DrawsOneCard() public void Hit_DrawsOneCard()
{ {
var (world, group) = SetupGame(); var (world, group) = TestHelpers.SetupGame();
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 }); world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
group.RunLogical(); group.RunLogical();
var playerHand = FindEntity<PlayerHand>(world); var playerHand = TestHelpers.FindEntity<PlayerHand>(world);
var before = CountCardsInHand(world, playerHand); var before = TestHelpers.CountCardsInHand(world, playerHand);
world.Commands.Enqueue(new HitCommand()); world.Commands.Enqueue(new HitCommand());
group.RunLogical(); group.RunLogical();
var after = CountCardsInHand(world, playerHand); var after = TestHelpers.CountCardsInHand(world, playerHand);
after.Should().Be(before + 1); after.Should().Be(before + 1);
} }
[Fact] [Fact]
public void Stand_AdvancesToDealerTurn() public void Stand_AdvancesToDealerTurn()
{ {
var (world, group) = SetupGame(); var (world, group) = TestHelpers.SetupGame();
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 }); world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
group.RunLogical(); group.RunLogical();
@ -130,15 +123,13 @@ public class GameFlowTests
[Fact] [Fact]
public void NewRound_ResetsPhase() public void NewRound_ResetsPhase()
{ {
var (world, group) = SetupGame(); var (world, group) = TestHelpers.SetupGame();
// Play a round.
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 }); world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
group.RunLogical(); group.RunLogical();
world.Commands.Enqueue(new StandCommand()); world.Commands.Enqueue(new StandCommand());
group.RunLogical(); group.RunLogical();
// Start new round.
world.Commands.Enqueue(new NewRoundCommand()); world.Commands.Enqueue(new NewRoundCommand());
group.RunLogical(); group.RunLogical();
@ -150,15 +141,15 @@ public class GameFlowTests
[Fact] [Fact]
public void DeterministicSeed_ProducesSameDeal() public void DeterministicSeed_ProducesSameDeal()
{ {
var (world1, group1) = SetupGame(seed: 42); var (world1, group1) = TestHelpers.SetupGame(seed: 42);
world1.Commands.Enqueue(new PlaceBetCommand { Amount = 10 }); world1.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
group1.RunLogical(); 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 }); world2.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
group2.RunLogical(); group2.RunLogical();
var cards2 = GetAllCards(world2); var cards2 = TestHelpers.GetAllCards(world2);
cards1.Should().Equal(cards2); cards1.Should().Equal(cards2);
} }
@ -166,20 +157,17 @@ public class GameFlowTests
[Fact] [Fact]
public void PlayerBust_LosesBet() public void PlayerBust_LosesBet()
{ {
// Use a seed that produces a bust-prone hand, then hit repeatedly. var (world, group) = TestHelpers.SetupGame(seed: 12345);
var (world, group) = SetupGame(seed: 12345);
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 }); world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
group.RunLogical(); group.RunLogical();
// Hit until bust or stand.
while (world.ReadSingleton<GameState>().Phase == GamePhase.PlayerTurn) while (world.ReadSingleton<GameState>().Phase == GamePhase.PlayerTurn)
{ {
world.Commands.Enqueue(new HitCommand()); world.Commands.Enqueue(new HitCommand());
group.RunLogical(); group.RunLogical();
} }
// Game should have resolved.
var state = world.ReadSingleton<GameState>(); var state = world.ReadSingleton<GameState>();
state.Phase.Should().Be(GamePhase.RoundOver); state.Phase.Should().Be(GamePhase.RoundOver);
} }
@ -187,7 +175,7 @@ public class GameFlowTests
[Fact] [Fact]
public void Serialization_RoundTrips() public void Serialization_RoundTrips()
{ {
var (world, group) = SetupGame(seed: 42); var (world, group) = TestHelpers.SetupGame(seed: 42);
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 }); world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
group.RunLogical(); group.RunLogical();
@ -204,60 +192,4 @@ public class GameFlowTests
state.CurrentBet.Should().Be(10); state.CurrentBet.Should().Be(10);
state.RoundNumber.Should().Be(1); 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 FluentAssertions;
using Game.Blackjack; using Game.Blackjack;
using OECS; using OECS;
using R3; using OECS.PlayTest;
using Xunit; using Xunit;
namespace Game.Blackjack.Tests; namespace Game.Blackjack.Tests;
@ -14,167 +14,73 @@ public class PlayTests
[Fact] [Fact]
public void Play_BasicStrategy() public void Play_BasicStrategy()
{ {
var log = new PlayLog(); var (world, group) = TestHelpers.SetupGame(seed: 42);
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(); 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 = log.SaveTo("blackjack_basic_strategy.playlog");
var path = SavePlayLog("blackjack_basic_strategy.playlog", log);
ReadAndVerify(path); ReadAndVerify(path);
} }
[Fact] [Fact]
public void Play_RandomAgent() public void Play_RandomAgent()
{ {
var log = new PlayLog(); var (world, group) = TestHelpers.SetupGame(seed: 123);
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(); 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 = log.SaveTo("blackjack_random_agent.playlog");
var path = SavePlayLog("blackjack_random_agent.playlog", log);
ReadAndVerify(path); ReadAndVerify(path);
} }
[Fact] [Fact]
public void Play_WeightedPool() public void Play_WeightedPool()
{ {
var log = new PlayLog(); var (world, group) = TestHelpers.SetupGame(seed: 77);
var (world, group) = SetupGame(seed: 77);
world.ObserveComponentChanges<Card>().Subscribe(change => var pool = new WeightedAgentPool<BlackjackDecision>();
{
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 BasicStrategyAgent(), 7);
pool.Add(new RandomBlackjackAgent(), 3); pool.Add(new RandomBlackjackAgent(), 3);
var agent = pool.Pick(); 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); var path = log.SaveTo("blackjack_weighted_pool.playlog");
log.FinalSnapshot = SnapshotWorld(world);
var path = SavePlayLog("blackjack_weighted_pool.playlog", log);
ReadAndVerify(path); 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 ────────────────────────────────────────────────── // ── 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> /// <summary>
/// Runs rounds until the player runs out of chips (can't afford minimum bet) /// Runs rounds until the player runs out of chips (can't afford minimum bet)
/// or doubles their starting chips. /// or doubles their starting chips.
/// </summary> /// </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 totalRounds = 0;
int wins = 0; int wins = 0;
int losses = 0; int losses = 0;
int pushes = 0; int pushes = 0;
var roundSummaries = new List<string>();
var decisions = new List<string>();
while (true) while (true)
{ {
var state = world.ReadSingleton<GameState>(); var state = world.ReadSingleton<GameState>();
int chips = state.Chips; int chips = state.Chips;
// Stop conditions.
if (chips < BetAmount) if (chips < BetAmount)
{ {
log.Header += $" — BUSTED after {totalRounds} rounds"; log.Header += $" — BUSTED after {totalRounds} rounds";
@ -185,19 +91,15 @@ public class PlayTests
log.Header += $" — DOUBLED after {totalRounds} rounds"; log.Header += $" — DOUBLED after {totalRounds} rounds";
break; break;
} }
// Safety: max 200 rounds to prevent infinite loops.
if (totalRounds >= 200) if (totalRounds >= 200)
{ {
log.Header += $" — MAX ROUNDS ({totalRounds})"; log.Header += $" — MAX ROUNDS ({totalRounds})";
break; break;
} }
// Place bet and run dealing.
world.Commands.Enqueue(new PlaceBetCommand { Amount = BetAmount }); world.Commands.Enqueue(new PlaceBetCommand { Amount = BetAmount });
group.RunLogical(); group.RunLogical();
// Player turn: agent decides hit/stand.
int roundHits = 0; int roundHits = 0;
int playerTurnSafety = 0; int playerTurnSafety = 0;
while (world.ReadSingleton<GameState>().Phase == GamePhase.PlayerTurn && playerTurnSafety < 52) while (world.ReadSingleton<GameState>().Phase == GamePhase.PlayerTurn && playerTurnSafety < 52)
@ -205,7 +107,7 @@ public class PlayTests
playerTurnSafety++; playerTurnSafety++;
var total = HandUtil.CalculateHand(world, new PlayerHand()); var total = HandUtil.CalculateHand(world, new PlayerHand());
var decision = agent.Decide(world); 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) if (decision == BlackjackDecision.Hit)
{ {
@ -220,12 +122,10 @@ public class PlayTests
group.RunLogical(); group.RunLogical();
} }
// Record round result.
state = world.ReadSingleton<GameState>(); state = world.ReadSingleton<GameState>();
int newChips = state.Chips; int newChips = state.Chips;
int delta = newChips - chips; int delta = newChips - chips;
string summary = $"{state.Result} | Bet={BetAmount} | Chips: {chips}→{newChips} ({delta:+0;-#}) | Hits: {roundHits}"; roundSummaries.Add($"Round {totalRounds + 1}: {state.Result} | Bet={BetAmount} | Chips: {chips}→{newChips} ({delta:+0;-#}) | Hits: {roundHits}");
log.RoundSummaries.Add(summary);
switch (state.Result) switch (state.Result)
{ {
@ -244,71 +144,22 @@ public class PlayTests
totalRounds++; totalRounds++;
// Start next round.
world.Commands.Enqueue(new NewRoundCommand()); world.Commands.Enqueue(new NewRoundCommand());
group.RunLogical(); group.RunLogical();
} }
log.Header += $" | W:{wins} L:{losses} P:{pushes}"; 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) return log;
{
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;
} }
// ── File I/O ────────────────────────────────────────────────────── // ── 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) private static void ReadAndVerify(string path)
{ {
var content = File.ReadAllText(path); var content = File.ReadAllText(path);
@ -324,12 +175,7 @@ public class PlayTests
private enum BlackjackDecision { Hit, Stand } private enum BlackjackDecision { Hit, Stand }
private interface IBlackjackAgent private sealed class BasicStrategyAgent : IAgent<BlackjackDecision>
{
BlackjackDecision Decide(World world);
}
private sealed class BasicStrategyAgent : IBlackjackAgent
{ {
public BlackjackDecision Decide(World world) public BlackjackDecision Decide(World world)
{ {
@ -339,7 +185,7 @@ public class PlayTests
public override string ToString() => "BasicStrategy"; public override string ToString() => "BasicStrategy";
} }
private sealed class RandomBlackjackAgent : IBlackjackAgent private sealed class RandomBlackjackAgent : IAgent<BlackjackDecision>
{ {
private static readonly Random _rng = new(); private static readonly Random _rng = new();
public BlackjackDecision Decide(World world) public BlackjackDecision Decide(World world)
@ -350,30 +196,4 @@ public class PlayTests
} }
public override string ToString() => "Random"; 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 FluentAssertions;
using Game.Blackjack; using Game.Blackjack;
using OECS; using OECS;
using R3; using OECS.PlayTest;
using Xunit; using Xunit;
using Xunit.Abstractions; using Xunit.Abstractions;
@ -24,9 +24,9 @@ public class SnapshotTests
[Fact] [Fact]
public void Snapshot_InitialState() public void Snapshot_InitialState()
{ {
var (world, _) = SetupGame(); var (world, _) = TestHelpers.SetupGame();
var snapshot = SnapshotWorld(world); var snapshot = TestHelpers.SnapshotWorld(world);
_output.WriteLine(snapshot); _output.WriteLine(snapshot);
snapshot.Should().Contain("Phase: Betting"); snapshot.Should().Contain("Phase: Betting");
@ -37,12 +37,12 @@ public class SnapshotTests
[Fact] [Fact]
public void Snapshot_AfterDeal() public void Snapshot_AfterDeal()
{ {
var (world, group) = SetupGame(seed: 42); var (world, group) = TestHelpers.SetupGame(seed: 42);
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 }); world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
group.RunLogical(); group.RunLogical();
var snapshot = SnapshotWorld(world); var snapshot = TestHelpers.SnapshotWorld(world);
_output.WriteLine(snapshot); _output.WriteLine(snapshot);
snapshot.Should().Contain("Phase: PlayerTurn"); snapshot.Should().Contain("Phase: PlayerTurn");
@ -56,7 +56,7 @@ public class SnapshotTests
[Fact] [Fact]
public void Snapshot_AfterHit() public void Snapshot_AfterHit()
{ {
var (world, group) = SetupGame(seed: 42); var (world, group) = TestHelpers.SetupGame(seed: 42);
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 }); world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
group.RunLogical(); group.RunLogical();
@ -64,17 +64,16 @@ public class SnapshotTests
world.Commands.Enqueue(new HitCommand()); world.Commands.Enqueue(new HitCommand());
group.RunLogical(); group.RunLogical();
var snapshot = SnapshotWorld(world); var snapshot = TestHelpers.SnapshotWorld(world);
_output.WriteLine(snapshot); _output.WriteLine(snapshot);
// With seed 42, one hit may bust. Just verify the game resolved.
snapshot.Should().NotContain("Phase: Dealing"); snapshot.Should().NotContain("Phase: Dealing");
} }
[Fact] [Fact]
public void Snapshot_AfterStand() public void Snapshot_AfterStand()
{ {
var (world, group) = SetupGame(seed: 42); var (world, group) = TestHelpers.SetupGame(seed: 42);
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 }); world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
group.RunLogical(); group.RunLogical();
@ -82,7 +81,7 @@ public class SnapshotTests
world.Commands.Enqueue(new StandCommand()); world.Commands.Enqueue(new StandCommand());
group.RunLogical(); group.RunLogical();
var snapshot = SnapshotWorld(world); var snapshot = TestHelpers.SnapshotWorld(world);
_output.WriteLine(snapshot); _output.WriteLine(snapshot);
snapshot.Should().Contain("Phase: RoundOver"); snapshot.Should().Contain("Phase: RoundOver");
@ -92,23 +91,11 @@ public class SnapshotTests
[Fact] [Fact]
public void Log_ReactivityDuringRound() public void Log_ReactivityDuringRound()
{ {
var (world, group) = SetupGame(seed: 42); var (world, group) = TestHelpers.SetupGame(seed: 42);
var log = new List<string>();
world.ObserveEntityChanges().Subscribe(change => using var capture = new ObservableCapture(world);
{ capture.FormatWith<Card>(c => $"{c.Rank} of {c.Suit}");
log.Add($"[entity] {change}"); capture.FormatWith<GameState>(s => $"{s.Phase} Chips={s.Chips} Bet={s.CurrentBet} Round={s.RoundNumber}");
});
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 }); world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
group.RunLogical(); group.RunLogical();
@ -116,24 +103,23 @@ public class SnapshotTests
world.Commands.Enqueue(new StandCommand()); world.Commands.Enqueue(new StandCommand());
group.RunLogical(); group.RunLogical();
var logText = string.Join("\n", log); var logText = string.Join("\n", capture.GetLogLines());
_output.WriteLine(logText); _output.WriteLine(logText);
// Should have entity creations (deck, hands, cards) and component changes. capture.GetLogLines().Should().Contain(l => l.Contains("EntityAdded"));
log.Should().Contain(l => l.Contains("EntityAdded")); capture.GetLogLines().Should().Contain(l => l.Contains("ComponentAdded") && l.Contains("Card"));
log.Should().Contain(l => l.Contains("ComponentAdded") && l.Contains("Card")); capture.GetLogLines().Should().Contain(l => l.Contains("ComponentModified") && l.Contains("GameState"));
log.Should().Contain(l => l.Contains("ComponentModified") && l.Contains("GameState"));
} }
[Fact] [Fact]
public void Serialization_RoundTrip_PreservesCoreState() 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 }); world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
group.RunLogical(); group.RunLogical();
var before = SnapshotWorld(world); var before = TestHelpers.SnapshotWorld(world);
using var stream = new MemoryStream(); using var stream = new MemoryStream();
WorldSerializer.Save(world, stream); WorldSerializer.Save(world, stream);
@ -142,112 +128,19 @@ public class SnapshotTests
var world2 = new World(); var world2 = new World();
WorldSerializer.Load(world2, stream); WorldSerializer.Load(world2, stream);
var after = SnapshotWorld(world2); var after = TestHelpers.SnapshotWorld(world2);
_output.WriteLine("=== Before ==="); _output.WriteLine("=== Before ===");
_output.WriteLine(before); _output.WriteLine(before);
_output.WriteLine("=== After ==="); _output.WriteLine("=== After ===");
_output.WriteLine(after); _output.WriteLine(after);
// Core game state must round-trip.
var state = world2.ReadSingleton<GameState>(); var state = world2.ReadSingleton<GameState>();
state.Phase.Should().Be(GamePhase.PlayerTurn); state.Phase.Should().Be(GamePhase.PlayerTurn);
state.Chips.Should().Be(90); state.Chips.Should().Be(90);
state.CurrentBet.Should().Be(10); state.CurrentBet.Should().Be(10);
state.RoundNumber.Should().Be(1); state.RoundNumber.Should().Be(1);
// Card entities must be preserved.
after.Should().Contain("Card entities: 52"); 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>();
}
} }