2026-07-20 17:05:17 +08:00
|
|
|
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
|
|
|
|
|
{
|
2026-07-21 13:57:11 +08:00
|
|
|
return world.FindEntity<T>();
|
2026-07-20 17:05:17 +08:00
|
|
|
}
|
|
|
|
|
}
|