using System.Text; using FluentAssertions; using Game.CardWars; using OECS; using Xunit; using Xunit.Abstractions; namespace Game.CardWars.Tests; public class PlayLogTests { private readonly ITestOutputHelper _output; public PlayLogTests(ITestOutputHelper output) { _output = output; } [Fact] public void OneRound_ThreePlayers_PlayLog() { CardEffectRegistry.ClearForTests(); var (world, group) = GameFactory.Create(playerCount: 3, seed: 12345); var log = new StringBuilder(); var players = GameUtil.FindAllEntities(world); var state = world.ReadSingleton(); LogSection(log, "=== CardWars Play Log — Round 1 ==="); log.AppendLine($"Seed: {state.Seed} | Players: {state.PlayerCount} | Starting: P{state.StartingPlayerIndex}"); log.AppendLine(); // ── Initial hands ── LogSection(log, "── Starting Hands ──"); for (int i = 0; i < players.Count; i++) log.AppendLine($" P{i}: {DescribeHand(world, GameUtil.GetHandCards(world, players[i]))}"); log.AppendLine(); // ── Play Phase ── LogSection(log, "── Play Phase ──"); RunPlayPhase(world, group, players, log); // ── Flip Phase ── LogSection(log, "── Flip Phase ──"); RunFlipPhase(world, group, players, log); // ── Scoring (capture powers BEFORE cleanup) ── LogSection(log, "── Scoring ──"); var powers = new int[players.Count]; for (int i = 0; i < players.Count; i++) powers[i] = GameUtil.CalculatePower(world, players[i]); // Advance to scoring phase and let systems run. ref var mutable = ref world.GetSingleton(); mutable.Phase = GamePhase.Scoring; world.MarkModified(World.SingletonEntity); group.RunLogical(); // ScoringSystem → CleanupSystem for (int i = 0; i < players.Count; i++) log.AppendLine($" P{i} power: {powers[i]}"); int winnerIdx = Array.IndexOf(powers, powers.Max()); log.AppendLine($" Winner: P{winnerIdx} (power={powers[winnerIdx]})"); // Find the castle that was just awarded (it has HeldBy but no Castle component). var wonCastleEntity = world.GetSources(players[winnerIdx]) .FirstOrDefault(c => !world.HasComponent(c) && !world.HasComponent(c)); log.AppendLine($" Castle awarded to P{winnerIdx}"); log.AppendLine(); // ── Cleanup already ran ── LogSection(log, "── After Cleanup ──"); state = world.ReadSingleton(); log.AppendLine($" Phase: {state.Phase} | Round: {state.RoundNumber}"); for (int i = 0; i < players.Count; i++) { var hand = GameUtil.GetHandCards(world, players[i]); log.AppendLine($" P{i} hand: {DescribeHand(world, hand)}"); } log.AppendLine(); // ── Castle counts ── LogSection(log, "── Castle Tally ──"); for (int i = 0; i < players.Count; i++) { // A won castle: has HeldBy to this player, no CardDef, no Card, no Leader, no Banner. var castleEntities = world.GetSources(players[i]) .Where(c => !world.HasComponent(c) && !world.HasComponent(c) && !world.HasComponent(c) && !world.HasComponent(c)) .ToList(); log.AppendLine($" P{i}: {castleEntities.Count} castle(s)"); } log.AppendLine(); _output.WriteLine(log.ToString()); state.Phase.Should().BeOneOf(GamePhase.PlayPhase, GamePhase.GameOver); } // ── Play phase: greedy agent, plays best card each turn ── private static void RunPlayPhase(World world, SystemGroup group, List players, StringBuilder log) { var state = world.ReadSingleton(); int startIdx = state.StartingPlayerIndex; int current = startIdx; int safety = 0; while (safety++ < 100) { state = world.ReadSingleton(); if (state.Phase != GamePhase.PlayPhase) break; var player = players[state.CurrentPlayerIndex]; var hand = GameUtil.GetHandCards(world, player); if (hand.Count > 0) { var best = PickBestCard(world, hand); var def = world.ReadComponent(best); int rank = def.Ranks.Max(); world.Commands.Enqueue(new PlayCardCommand { CardEntity = best, Rank = rank, FaceDown = false, TargetPlayer = null }); group.RunLogical(); log.AppendLine($" P{state.CurrentPlayerIndex} plays [{def.Name}] rank={rank}"); // Resolve any pending choices automatically. ResolvePendingChoices(world, group, players, log); } else { world.Commands.Enqueue(new SkipPlayCommand()); group.RunLogical(); log.AppendLine($" P{state.CurrentPlayerIndex} skips (hand empty)"); } } state = world.ReadSingleton(); log.AppendLine($" Phase → {state.Phase}"); log.AppendLine(); } // ── Flip phase ── private static void RunFlipPhase(World world, SystemGroup group, List players, StringBuilder log) { var state = world.ReadSingleton(); if (state.Phase != GamePhase.FlipPhase) { ref var m = ref world.GetSingleton(); m.Phase = GamePhase.FlipPhase; m.CurrentPlayerIndex = m.StartingPlayerIndex; world.MarkModified(World.SingletonEntity); } for (int i = 0; i < players.Count; i++) { state = world.ReadSingleton(); var player = players[state.CurrentPlayerIndex]; var field = GameUtil.GetFieldCards(world, player); bool anyFaceDown = field.Any(c => world.HasComponent(c) && world.ReadComponent(c).FaceDown); if (anyFaceDown) { var fd = field.First(c => world.HasComponent(c) && world.ReadComponent(c).FaceDown); world.Commands.Enqueue(new FlipCardCommand { CardEntity = fd }); group.RunLogical(); var def = world.ReadComponent(fd); log.AppendLine($" P{state.CurrentPlayerIndex} flips [{def.Name}]"); ResolvePendingChoices(world, group, players, log); } else { log.AppendLine($" P{state.CurrentPlayerIndex} skips flip (no face-down)"); } ref var m2 = ref world.GetSingleton(); m2.CurrentPlayerIndex = (m2.CurrentPlayerIndex + 1) % m2.PlayerCount; world.MarkModified(World.SingletonEntity); } log.AppendLine(); } // ── Pending choice auto-resolver ── private static void ResolvePendingChoices(World world, SystemGroup group, List players, StringBuilder log) { while (PendingChoice.Any(world)) { Entity target = PickDefaultTarget(world, players); world.Commands.Enqueue(new ResolveChoiceCommand { Target = target }); group.RunLogical(); log.AppendLine($" ↳ auto-resolve → {DescribeEntity(world, target)}"); } } private static Entity PickDefaultTarget(World world, List players) { if (world.HasSingleton()) { var p = world.ReadSingleton(); return GameUtil.GetFieldCards(world, p.Player) .FirstOrDefault(c => world.HasComponent(c) && world.ReadComponent(c).Rank > 0); } if (world.HasSingleton()) { var p = world.ReadSingleton(); return GameUtil.GetFieldCards(world, p.Player) .FirstOrDefault(c => world.HasComponent(c) && world.ReadComponent(c).Rank > 0); } if (world.HasSingleton()) { var p = world.ReadSingleton(); var powered = GameUtil.GetFieldCards(world, p.Player) .FirstOrDefault(c => world.HasComponent(c) && world.ReadComponent(c).Rank > 0); return powered != Entity.Null ? powered : CardEffectHelpers.GetBanner(world, p.Player); } if (world.HasSingleton()) { var p = world.ReadSingleton(); return GameUtil.GetFieldCards(world, p.Player).FirstOrDefault(); } if (world.HasSingleton()) { var p = world.ReadSingleton(); return players.FirstOrDefault(pl => pl != p.Player); } if (world.HasSingleton()) { var p = world.ReadSingleton(); return GameUtil.GetFieldCards(world, p.Player).FirstOrDefault(c => c != p.CardEntity); } if (world.HasSingleton()) { return GameUtil.FindAllEntities(world).FirstOrDefault(); } return Entity.Null; } // ── Helpers ── private static Entity PickBestCard(World world, List hand) { Entity best = Entity.Null; int bestRank = -999; foreach (var c in hand) { int maxRank = world.ReadComponent(c).Ranks.Max(); if (maxRank > bestRank) { bestRank = maxRank; best = c; } } return best; } private static string DescribeHand(World world, List hand) { var cards = hand.Where(c => world.HasComponent(c)).ToList(); if (cards.Count == 0) return "(empty)"; return string.Join(" ", cards.Select(c => { var def = world.ReadComponent(c); return $"[{def.Name} {string.Join("/", def.Ranks)}]"; })); } private static string DescribeEntity(World world, Entity e) { if (e == Entity.Null) return "(none)"; if (world.HasComponent(e)) return $"[{world.ReadComponent(e).Name}]"; if (world.HasComponent(e)) return $"P{world.ReadComponent(e).Index}"; if (world.HasComponent(e)) return "[Horn token]"; if (world.HasComponent(e)) return "[Skull token]"; if (world.HasComponent(e)) return "[Banner]"; return e.ToString(); } private static void LogSection(StringBuilder log, string title) => log.AppendLine(title); }