308 lines
10 KiB
C#
308 lines
10 KiB
C#
using FluentAssertions;
|
|
using Game.Blackjack;
|
|
using OECS;
|
|
using R3;
|
|
using Xunit;
|
|
|
|
namespace Game.Blackjack.Tests;
|
|
|
|
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}");
|
|
});
|
|
|
|
log.Header = "Blackjack: Basic Strategy (seed=42)";
|
|
|
|
var agent = new BasicStrategyAgent();
|
|
RunRound(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}");
|
|
});
|
|
|
|
log.Header = "Blackjack: Random Agent (seed=123)";
|
|
|
|
var agent = new RandomBlackjackAgent();
|
|
RunRound(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);
|
|
|
|
log.Header = $"Blackjack: Weighted Pool (seed=77, agent={pool.LastPicked})";
|
|
|
|
var agent = pool.Pick();
|
|
RunRound(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> 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("--- 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 = 100,
|
|
CurrentBet = 0,
|
|
RoundNumber = 1,
|
|
Seed = seed
|
|
});
|
|
world.PostChanges();
|
|
|
|
return (world, group);
|
|
}
|
|
|
|
private static void RunRound(World world, SystemGroup group, IBlackjackAgent agent, PlayLog log)
|
|
{
|
|
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
|
|
group.RunLogical();
|
|
|
|
while (world.ReadSingleton<GameState>().Phase == GamePhase.PlayerTurn)
|
|
{
|
|
var total = HandUtil.CalculateHand(world, new PlayerHand());
|
|
var decision = agent.Decide(world);
|
|
log.Decisions.Add($"Hand={total}, Decision={decision}");
|
|
|
|
if (decision == BlackjackDecision.Hit)
|
|
world.Commands.Enqueue(new HitCommand());
|
|
else
|
|
world.Commands.Enqueue(new StandCommand());
|
|
|
|
group.RunLogical();
|
|
}
|
|
|
|
var state = world.ReadSingleton<GameState>();
|
|
log.Header += $" — Result: {state.Result}";
|
|
}
|
|
|
|
// ── 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($"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("--- 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 string LastPicked { get; private set; } = "";
|
|
|
|
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)
|
|
{
|
|
LastPicked = agent.ToString()!;
|
|
return agent;
|
|
}
|
|
}
|
|
LastPicked = _agents[^1].Agent.ToString()!;
|
|
return _agents[^1].Agent;
|
|
}
|
|
}
|
|
}
|