288 lines
11 KiB
C#
288 lines
11 KiB
C#
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<Player>(world);
|
|
var state = world.ReadSingleton<GameState>();
|
|
|
|
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<GameState>();
|
|
mutable.Phase = GamePhase.Scoring;
|
|
world.MarkModified<GameState>(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<HeldBy>(players[winnerIdx])
|
|
.FirstOrDefault(c => !world.HasComponent<Castle>(c) && !world.HasComponent<CardDef>(c));
|
|
log.AppendLine($" Castle awarded to P{winnerIdx}");
|
|
log.AppendLine();
|
|
|
|
// ── Cleanup already ran ──
|
|
LogSection(log, "── After Cleanup ──");
|
|
state = world.ReadSingleton<GameState>();
|
|
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<HeldBy>(players[i])
|
|
.Where(c => !world.HasComponent<CardDef>(c)
|
|
&& !world.HasComponent<Card>(c)
|
|
&& !world.HasComponent<Leader>(c)
|
|
&& !world.HasComponent<Banner>(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<Entity> players, StringBuilder log)
|
|
{
|
|
var state = world.ReadSingleton<GameState>();
|
|
int startIdx = state.StartingPlayerIndex;
|
|
int current = startIdx;
|
|
int safety = 0;
|
|
|
|
while (safety++ < 100)
|
|
{
|
|
state = world.ReadSingleton<GameState>();
|
|
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<CardDef>(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<GameState>();
|
|
log.AppendLine($" Phase → {state.Phase}");
|
|
log.AppendLine();
|
|
}
|
|
|
|
// ── Flip phase ──
|
|
|
|
private static void RunFlipPhase(World world, SystemGroup group, List<Entity> players, StringBuilder log)
|
|
{
|
|
var state = world.ReadSingleton<GameState>();
|
|
if (state.Phase != GamePhase.FlipPhase)
|
|
{
|
|
ref var m = ref world.GetSingleton<GameState>();
|
|
m.Phase = GamePhase.FlipPhase;
|
|
m.CurrentPlayerIndex = m.StartingPlayerIndex;
|
|
world.MarkModified<GameState>(World.SingletonEntity);
|
|
}
|
|
|
|
for (int i = 0; i < players.Count; i++)
|
|
{
|
|
state = world.ReadSingleton<GameState>();
|
|
var player = players[state.CurrentPlayerIndex];
|
|
var field = GameUtil.GetFieldCards(world, player);
|
|
bool anyFaceDown = field.Any(c =>
|
|
world.HasComponent<Card>(c) && world.ReadComponent<Card>(c).FaceDown);
|
|
|
|
if (anyFaceDown)
|
|
{
|
|
var fd = field.First(c =>
|
|
world.HasComponent<Card>(c) && world.ReadComponent<Card>(c).FaceDown);
|
|
world.Commands.Enqueue(new FlipCardCommand { CardEntity = fd });
|
|
group.RunLogical();
|
|
var def = world.ReadComponent<CardDef>(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<GameState>();
|
|
m2.CurrentPlayerIndex = (m2.CurrentPlayerIndex + 1) % m2.PlayerCount;
|
|
world.MarkModified<GameState>(World.SingletonEntity);
|
|
}
|
|
log.AppendLine();
|
|
}
|
|
|
|
// ── Pending choice auto-resolver ──
|
|
|
|
private static void ResolvePendingChoices(World world, SystemGroup group, List<Entity> 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<Entity> players)
|
|
{
|
|
if (world.HasSingleton<PendingMercenary>())
|
|
{
|
|
var p = world.ReadSingleton<PendingMercenary>();
|
|
return GameUtil.GetFieldCards(world, p.Player)
|
|
.FirstOrDefault(c => world.HasComponent<Card>(c) && world.ReadComponent<Card>(c).Rank > 0);
|
|
}
|
|
if (world.HasSingleton<PendingDancer>())
|
|
{
|
|
var p = world.ReadSingleton<PendingDancer>();
|
|
return GameUtil.GetFieldCards(world, p.Player)
|
|
.FirstOrDefault(c => world.HasComponent<Card>(c) && world.ReadComponent<Card>(c).Rank > 0);
|
|
}
|
|
if (world.HasSingleton<PendingPaladin>())
|
|
{
|
|
var p = world.ReadSingleton<PendingPaladin>();
|
|
var powered = GameUtil.GetFieldCards(world, p.Player)
|
|
.FirstOrDefault(c => world.HasComponent<Card>(c) && world.ReadComponent<Card>(c).Rank > 0);
|
|
return powered != Entity.Null ? powered : CardEffectHelpers.GetBanner(world, p.Player);
|
|
}
|
|
if (world.HasSingleton<PendingNun>())
|
|
{
|
|
var p = world.ReadSingleton<PendingNun>();
|
|
return GameUtil.GetFieldCards(world, p.Player).FirstOrDefault();
|
|
}
|
|
if (world.HasSingleton<PendingScout>())
|
|
{
|
|
var p = world.ReadSingleton<PendingScout>();
|
|
return players.FirstOrDefault(pl => pl != p.Player);
|
|
}
|
|
if (world.HasSingleton<PendingPegasus>())
|
|
{
|
|
var p = world.ReadSingleton<PendingPegasus>();
|
|
return GameUtil.GetFieldCards(world, p.Player).FirstOrDefault(c => c != p.CardEntity);
|
|
}
|
|
if (world.HasSingleton<PendingCurseMaster>())
|
|
{
|
|
return GameUtil.FindAllEntities<Horn>(world).FirstOrDefault();
|
|
}
|
|
return Entity.Null;
|
|
}
|
|
|
|
// ── Helpers ──
|
|
|
|
private static Entity PickBestCard(World world, List<Entity> hand)
|
|
{
|
|
Entity best = Entity.Null;
|
|
int bestRank = -999;
|
|
foreach (var c in hand)
|
|
{
|
|
int maxRank = world.ReadComponent<CardDef>(c).Ranks.Max();
|
|
if (maxRank > bestRank) { bestRank = maxRank; best = c; }
|
|
}
|
|
return best;
|
|
}
|
|
|
|
private static string DescribeHand(World world, List<Entity> hand)
|
|
{
|
|
var cards = hand.Where(c => world.HasComponent<CardDef>(c)).ToList();
|
|
if (cards.Count == 0) return "(empty)";
|
|
return string.Join(" ", cards.Select(c =>
|
|
{
|
|
var def = world.ReadComponent<CardDef>(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<CardDef>(e)) return $"[{world.ReadComponent<CardDef>(e).Name}]";
|
|
if (world.HasComponent<Player>(e)) return $"P{world.ReadComponent<Player>(e).Index}";
|
|
if (world.HasComponent<Horn>(e)) return "[Horn token]";
|
|
if (world.HasComponent<Skull>(e)) return "[Skull token]";
|
|
if (world.HasComponent<Banner>(e)) return "[Banner]";
|
|
return e.ToString();
|
|
}
|
|
|
|
private static void LogSection(StringBuilder log, string title) => log.AppendLine(title);
|
|
} |