oecs-sharp/Game.Blackjack.Tests/PlayTests.cs

382 lines
13 KiB
C#
Raw Normal View History

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;
}
}
}