diff --git a/Game.CardWars.Tests/CardEffectTests.cs b/Game.CardWars.Tests/CardEffectTests.cs new file mode 100644 index 0000000..e225c61 --- /dev/null +++ b/Game.CardWars.Tests/CardEffectTests.cs @@ -0,0 +1,139 @@ +using FluentAssertions; +using Game.CardWars; +using OECS; +using Xunit; + +namespace Game.CardWars.Tests; + +public class CardEffectTests +{ + private static (World World, SystemGroup Group) Setup() + { + CardEffectRegistry.ClearForTests(); + return GameFactory.Create(playerCount: 2, seed: 42); + } + + [Fact] + public void WarriorEffect_AddsHornWhenAnotherWarriorExists() + { + var (world, group) = Setup(); + + var state = world.ReadSingleton(); + var player = PlayCardCommand.FindPlayerByIndex(world, state.CurrentPlayerIndex); + + // Play two warriors. + var hand = GameUtil.GetHandCards(world, player); + var warrior1 = FindCardWithEffect(world, hand, CardEffect.Warrior); + var warrior2 = FindSecondCardWithEffect(world, hand, CardEffect.Warrior); + + if (warrior1 == Entity.Null || warrior2 == Entity.Null) return; // Not enough warriors in deck. + + var def = world.ReadComponent(warrior1); + + world.Commands.Enqueue(new PlayCardCommand + { + CardEntity = warrior1, + Rank = def.Ranks[0], + FaceDown = false, + TargetPlayer = null + }); + group.RunLogical(); + + // First warrior: no other warrior, no horn. + GameUtil.GetFieldCards(world, player).Should().Contain(warrior1); + world.HasComponent(warrior1).Should().BeFalse(because: "no other warrior on field"); + + // Play second warrior. + var def2 = world.ReadComponent(warrior2); + world.Commands.Enqueue(new PlayCardCommand + { + CardEntity = warrior2, + Rank = def2.Ranks[0], + FaceDown = false, + TargetPlayer = null + }); + group.RunLogical(); + + // Second warrior has another warrior, should get horn. + // But Wait — the effect checks "other" warriors on field. The first warrior + // is on field, so the second warrior should get a horn. + world.HasComponent(warrior2).Should().BeTrue(because: "another warrior exists on field"); + } + + [Fact] + public void MercenaryEffect_SetsPendingChoice() + { + var (world, group) = Setup(); + + var state = world.ReadSingleton(); + var player = PlayCardCommand.FindPlayerByIndex(world, state.CurrentPlayerIndex); + + // Play first mercenary (no effect). + var hand = GameUtil.GetHandCards(world, player); + var merc = FindCardWithEffect(world, hand, CardEffect.Mercenary); + if (merc == Entity.Null) return; + + var def = world.ReadComponent(merc); + world.Commands.Enqueue(new PlayCardCommand + { + CardEntity = merc, + Rank = def.Ranks[0], + FaceDown = false, + TargetPlayer = null + }); + group.RunLogical(); + + // Play second mercenary. We need another one from a drawn card. + var publicDeck = GameUtil.FindEntity(world); + GameUtil.DrawCard(world, publicDeck, player); + var newHand = GameUtil.GetHandCards(world, player); + var merc2 = FindCardWithEffect(world, newHand, CardEffect.Mercenary); + if (merc2 == Entity.Null) return; + + var def2 = world.ReadComponent(merc2); + world.Commands.Enqueue(new PlayCardCommand + { + CardEntity = merc2, + Rank = def2.Ranks[0], + FaceDown = false, + TargetPlayer = null + }); + group.RunLogical(); + + // Should have a pending Mercenary choice. + world.HasSingleton().Should().BeTrue(); + var pending = world.ReadSingleton(); + pending.CardEntity.Should().Be(merc2); + + // Resolve the choice (pick a target). + world.Commands.Enqueue(new ResolveChoiceCommand { Target = merc }); + group.RunLogical(); + + // The pending should be cleared. + world.HasSingleton().Should().BeFalse(); + } + + private static Entity FindCardWithEffect(World world, List cards, CardEffect effect) + { + foreach (var c in cards) + { + if (world.HasComponent(c) && world.ReadComponent(c).Effect == effect) + return c; + } + return Entity.Null; + } + + private static Entity FindSecondCardWithEffect(World world, List cards, CardEffect effect) + { + Entity first = Entity.Null; + foreach (var c in cards) + { + if (world.HasComponent(c) && world.ReadComponent(c).Effect == effect) + { + if (first == Entity.Null) first = c; + else return c; + } + } + return Entity.Null; + } +} \ No newline at end of file diff --git a/Game.CardWars.Tests/Game.CardWars.Tests.csproj b/Game.CardWars.Tests/Game.CardWars.Tests.csproj new file mode 100644 index 0000000..dc6ef44 --- /dev/null +++ b/Game.CardWars.Tests/Game.CardWars.Tests.csproj @@ -0,0 +1,24 @@ + + + + + Game.CardWars.Tests + false + true + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/Game.CardWars.Tests/GameSetupTests.cs b/Game.CardWars.Tests/GameSetupTests.cs new file mode 100644 index 0000000..7e1bd0b --- /dev/null +++ b/Game.CardWars.Tests/GameSetupTests.cs @@ -0,0 +1,148 @@ +using FluentAssertions; +using Game.CardWars; +using OECS; +using Xunit; + +namespace Game.CardWars.Tests; + +public class GameSetupTests +{ + private static (World World, SystemGroup Group) Setup() + { + // Reset registry state between tests. + CardEffectRegistry.ClearForTests(); + return GameFactory.Create(playerCount: 2, seed: 42); + } + + [Fact] + public void CreateGame_CreatesPlayersAndDecks() + { + var (world, _) = Setup(); + + var state = world.ReadSingleton(); + state.Phase.Should().Be(GamePhase.PlayPhase); + state.PlayerCount.Should().Be(2); + state.RoundNumber.Should().Be(1); + + GameUtil.FindEntity(world).Should().NotBe(Entity.Null); + GameUtil.FindAllEntities(world).Should().HaveCount(2); + GameUtil.FindAllEntities(world).Should().HaveCount(2); + GameUtil.FindAllEntities(world).Should().HaveCount(2); + GameUtil.FindAllEntities(world).Should().NotBeEmpty(); + } + + [Fact] + public void StartingHand_HasThreeCards() + { + var (world, _) = Setup(); + + foreach (var player in GameUtil.FindAllEntities(world)) + { + var hand = GameUtil.GetHandCards(world, player); + hand.Should().HaveCount(3); + } + } + + [Fact] + public void PlayCard_MovesCardFromHandToField() + { + var (world, group) = Setup(); + + var state = world.ReadSingleton(); + var player = PlayCardCommand.FindPlayerByIndex(world, state.CurrentPlayerIndex); + + var hand = GameUtil.GetHandCards(world, player); + var cardInHand = hand[0]; + var def = world.ReadComponent(cardInHand); + + world.Commands.Enqueue(new PlayCardCommand + { + CardEntity = cardInHand, + Rank = def.Ranks[0], + FaceDown = false, + TargetPlayer = null + }); + group.RunLogical(); + + GameUtil.GetFieldCards(world, player).Should().Contain(cardInHand); + var cardData = world.ReadComponent(cardInHand); + cardData.Rank.Should().Be(def.Ranks[0]); + cardData.FaceDown.Should().BeFalse(); + } + + [Fact] + public void PlayCardFaceDown_CardIsHidden() + { + var (world, group) = Setup(); + + var state = world.ReadSingleton(); + var player = PlayCardCommand.FindPlayerByIndex(world, state.CurrentPlayerIndex); + + var hand = GameUtil.GetHandCards(world, player); + var cardInHand = hand[0]; + var def = world.ReadComponent(cardInHand); + + world.Commands.Enqueue(new PlayCardCommand + { + CardEntity = cardInHand, + Rank = def.Ranks[0], + FaceDown = true, + TargetPlayer = null + }); + group.RunLogical(); + + world.ReadComponent(cardInHand).FaceDown.Should().BeTrue(); + } + + [Fact] + public void FlipCard_RevealsFaceDownCard() + { + var (world, group) = Setup(); + + var state = world.ReadSingleton(); + var player = PlayCardCommand.FindPlayerByIndex(world, state.CurrentPlayerIndex); + + var hand = GameUtil.GetHandCards(world, player); + var cardInHand = hand[0]; + var def = world.ReadComponent(cardInHand); + + // Play face-down. + world.Commands.Enqueue(new PlayCardCommand + { + CardEntity = cardInHand, + Rank = def.Ranks[0], + FaceDown = true, + TargetPlayer = null + }); + group.RunLogical(); + + // Advance to flip phase: both players skip. + world.Commands.Enqueue(new SkipPlayCommand()); + group.RunLogical(); + world.Commands.Enqueue(new SkipPlayCommand()); + group.RunLogical(); + + world.ReadSingleton().Phase.Should().Be(GamePhase.FlipPhase); + + // Flip it. + world.Commands.Enqueue(new FlipCardCommand { CardEntity = cardInHand }); + group.RunLogical(); + + world.ReadComponent(cardInHand).FaceDown.Should().BeFalse(); + } + + [Fact] + public void SkipPlay_AdvancesTurn() + { + var (world, group) = Setup(); + + var state = world.ReadSingleton(); + int originalPlayer = state.CurrentPlayerIndex; + + world.Commands.Enqueue(new SkipPlayCommand()); + group.RunLogical(); + + state = world.ReadSingleton(); + state.CurrentPlayerIndex.Should().Be((originalPlayer + 1) % 2); + } +} \ No newline at end of file diff --git a/Game.CardWars.Tests/PlayLogTests.cs b/Game.CardWars.Tests/PlayLogTests.cs new file mode 100644 index 0000000..714faaf --- /dev/null +++ b/Game.CardWars.Tests/PlayLogTests.cs @@ -0,0 +1,288 @@ +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); +} \ No newline at end of file diff --git a/Game.CardWars/CardDataLoader.cs b/Game.CardWars/CardDataLoader.cs new file mode 100644 index 0000000..1bee634 --- /dev/null +++ b/Game.CardWars/CardDataLoader.cs @@ -0,0 +1,113 @@ +using OECS; + +namespace Game.CardWars; + +/// +/// Loads card definitions from a CSV and creates card entities in the world. +/// +public static class CardDataLoader +{ + /// + /// Creates all card definition entities in the world from CSV data. + /// Returns a list of (entity, CardDef) pairs. + /// + public static List<(Entity Entity, CardDef Def)> LoadDefinitions(World world, string csv) + { + var results = new List<(Entity, CardDef)>(); + var lines = csv.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + if (lines.Length < 2) + return results; + + // Skip header line. + for (int i = 1; i < lines.Length; i++) + { + var line = lines[i].Trim(); + if (string.IsNullOrEmpty(line)) continue; + + var def = ParseLine(line); + if (def.Name == null) continue; + + var entity = world.CreateEntity(); + world.AddComponent(entity, def); + results.Add((entity, def)); + } + + return results; + } + + private static CardDef ParseLine(string line) + { + // Simple CSV parsing: name,ranks,effect + // Ranks are semicolon-separated. + var parts = SplitCsv(line); + if (parts.Length < 3) + return default; + + var name = parts[0].Trim(); + var rankStrs = parts[1].Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var ranks = new int[rankStrs.Length]; + for (int i = 0; i < rankStrs.Length; i++) + ranks[i] = int.Parse(rankStrs[i]); + + var effect = ParseEffect(parts[2].Trim()); + + return new CardDef + { + Name = name, + Ranks = ranks, + Effect = effect, + Kind = CardKind.Public + }; + } + + private static string[] SplitCsv(string line) + { + var result = new List(); + var current = new System.Text.StringBuilder(); + bool inQuotes = false; + + for (int i = 0; i < line.Length; i++) + { + char c = line[i]; + if (c == '"') + { + inQuotes = !inQuotes; + } + else if (c == ',' && !inQuotes) + { + result.Add(current.ToString()); + current.Clear(); + } + else + { + current.Append(c); + } + } + result.Add(current.ToString()); + return result.ToArray(); + } + + private static CardEffect ParseEffect(string effect) + { + return effect switch + { + string s when s.Contains("战士") => CardEffect.Warrior, + string s when s.Contains("弓手") => CardEffect.Archer, + string s when s.Contains("佣兵") => CardEffect.Mercenary, + string s when s.Contains("商人") => CardEffect.Merchant, + string s when s.Contains("舞姬") => CardEffect.Dancer, + string s when s.Contains("圣骑士") => CardEffect.Paladin, + string s when s.Contains("飞马") => CardEffect.Pegasus, + string s when s.Contains("诅咒师") => CardEffect.CurseMaster, + string s when s.Contains("魔导士") => CardEffect.Mage, + string s when s.Contains("公主") => CardEffect.Princess, + string s when s.Contains("决斗家") => CardEffect.Duelist, + string s when s.Contains("修女") => CardEffect.Nun, + string s when s.Contains("斥候") => CardEffect.Scout, + string s when s.Contains("女巫") => CardEffect.Witch, + string s when s.Contains("盗贼") => CardEffect.Thief, + _ => CardEffect.None + }; + } +} \ No newline at end of file diff --git a/Game.CardWars/CardEffectRegistry.cs b/Game.CardWars/CardEffectRegistry.cs new file mode 100644 index 0000000..77defde --- /dev/null +++ b/Game.CardWars/CardEffectRegistry.cs @@ -0,0 +1,96 @@ +using OECS; + +namespace Game.CardWars; + +/// +/// A card effect that can be registered with the effect dispatcher. +/// +public interface ICardEffect +{ + CardEffect Effect { get; } + + /// + /// Called when the card is played face-up or flipped face-up. + /// Return true if fully resolved. Return false if a pending component + /// was set and the effect needs a player command to continue. + /// + bool Resolve(World world, Entity card, Entity player, Entity owner); + + /// + /// Called when the player provides a choice to continue a pending effect. + /// The effect handler is responsible for reading the correct pending + /// component type from the singleton and removing it when done. + /// + bool ResolveChoice(World world, Entity target); +} + +/// +/// Registry of card effects, ordered by registration. +/// +public static class CardEffectRegistry +{ + private static readonly List _effects = new(); + private static bool _frozen; + + public static void Add() where TEffect : ICardEffect, new() + { + if (_frozen) + throw new InvalidOperationException("Cannot add effects after the registry is frozen."); + _effects.Add(new TEffect()); + } + + public static void Freeze() => _frozen = true; + + /// + /// Resolve a card effect. Returns true if fully resolved. + /// + public static bool Dispatch(World world, CardEffect effect, Entity card, Entity player, Entity owner) + { + foreach (var e in _effects) + { + if (e.Effect == effect) + return e.Resolve(world, card, player, owner); + } + return true; + } + + /// + /// Look up the handler for the currently pending effect and resolve it. + /// + public static bool DispatchChoice(World world, Entity target) + { + // Find which pending component is active and dispatch to the matching handler. + if (world.HasSingleton()) + return DispatchByEffect(world, CardEffect.Mercenary, target); + if (world.HasSingleton()) + return DispatchByEffect(world, CardEffect.Dancer, target); + if (world.HasSingleton()) + return DispatchByEffect(world, CardEffect.Paladin, target); + if (world.HasSingleton()) + return DispatchByEffect(world, CardEffect.Nun, target); + if (world.HasSingleton()) + return DispatchByEffect(world, CardEffect.Scout, target); + if (world.HasSingleton()) + return DispatchByEffect(world, CardEffect.Pegasus, target); + if (world.HasSingleton()) + return DispatchByEffect(world, CardEffect.CurseMaster, target); + + return true; + } + + private static bool DispatchByEffect(World world, CardEffect effect, Entity target) + { + foreach (var e in _effects) + { + if (e.Effect == effect) + return e.ResolveChoice(world, target); + } + return true; + } + + public static void ClearForTests() + { + _effects.Clear(); + _frozen = false; + } +} \ No newline at end of file diff --git a/Game.CardWars/CardEffects.cs b/Game.CardWars/CardEffects.cs new file mode 100644 index 0000000..38f8442 --- /dev/null +++ b/Game.CardWars/CardEffects.cs @@ -0,0 +1,402 @@ +using OECS; + +namespace Game.CardWars; + +// ──────────────────────────────────────────────────────────── +// Simple effects that resolve immediately. +// ──────────────────────────────────────────────────────────── + +public class WarriorEffect : ICardEffect +{ + public CardEffect Effect => CardEffect.Warrior; + + public bool Resolve(World world, Entity card, Entity player, Entity owner) + { + if (HasOtherOnField(world, owner, card, CardEffect.Warrior)) + CardEffectHelpers.AddHorn(world, card); + return true; + } + + public bool ResolveChoice(World world, Entity target) => true; + + private static bool HasOtherOnField(World world, Entity player, Entity self, CardEffect effect) + { + return GameUtil.GetFieldCards(world, player) + .Any(c => c != self && world.HasComponent(c) + && world.ReadComponent(c).Effect == effect); + } +} + +public class ArcherEffect : ICardEffect +{ + public CardEffect Effect => CardEffect.Archer; + + public bool Resolve(World world, Entity card, Entity player, Entity owner) + { + if (HasOtherOnField(world, owner, card, CardEffect.Archer)) + { + var banner = CardEffectHelpers.GetBanner(world, owner); + if (banner != Entity.Null) CardEffectHelpers.AddHorn(world, banner); + } + return true; + } + + public bool ResolveChoice(World world, Entity target) => true; + + private static bool HasOtherOnField(World world, Entity player, Entity self, CardEffect effect) + { + return GameUtil.GetFieldCards(world, player) + .Any(c => c != self && world.HasComponent(c) + && world.ReadComponent(c).Effect == effect); + } +} + +public class MercenaryEffect : ICardEffect +{ + public CardEffect Effect => CardEffect.Mercenary; + + public bool Resolve(World world, Entity card, Entity player, Entity owner) + { + if (!HasOtherOnField(world, owner, card, CardEffect.Mercenary)) + return true; + + world.SetSingleton(new PendingMercenary { CardEntity = card, Player = player }); + return false; + } + + public bool ResolveChoice(World world, Entity target) + { + var pending = world.ReadSingleton(); + if (target != Entity.Null) + CardEffectHelpers.AddSkull(world, target); + world.RemoveSingleton(); + return true; + } + + private static bool HasOtherOnField(World world, Entity player, Entity self, CardEffect effect) + { + return GameUtil.GetFieldCards(world, player) + .Any(c => c != self && world.HasComponent(c) + && world.ReadComponent(c).Effect == effect); + } +} + +public class MerchantEffect : ICardEffect +{ + public CardEffect Effect => CardEffect.Merchant; + + public bool Resolve(World world, Entity card, Entity player, Entity owner) + { + world.Commands.Enqueue(new DrawCardCommand { Player = player, Kind = null }); + world.Commands.Enqueue(new ExtraActionCommand()); + return true; + } + + public bool ResolveChoice(World world, Entity target) => true; +} + +public class DancerEffect : ICardEffect +{ + public CardEffect Effect => CardEffect.Dancer; + + public bool Resolve(World world, Entity card, Entity player, Entity owner) + { + var powered = GameUtil.GetFieldCards(world, owner) + .Where(c => world.HasComponent(c) && world.ReadComponent(c).Rank > 0) + .ToList(); + + if (powered.Count == 0) return true; + if (powered.Count <= 2) + { + foreach (var c in powered) + CardEffectHelpers.AddHorn(world, c); + return true; + } + + world.SetSingleton(new PendingDancer { CardEntity = card, Player = player, Step = 0 }); + return false; + } + + public bool ResolveChoice(World world, Entity target) + { + var pending = world.ReadSingleton(); + + if (target != Entity.Null) + CardEffectHelpers.AddHorn(world, target); + + if (pending.Step == 0) + { + world.SetSingleton(new PendingDancer { CardEntity = pending.CardEntity, Player = pending.Player, Step = 1 }); + return false; + } + + world.RemoveSingleton(); + return true; + } +} + +public class PaladinEffect : ICardEffect +{ + public CardEffect Effect => CardEffect.Paladin; + + public bool Resolve(World world, Entity card, Entity player, Entity owner) + { + world.SetSingleton(new PendingPaladin { CardEntity = card, Player = player }); + return false; + } + + public bool ResolveChoice(World world, Entity target) + { + if (target != Entity.Null) + CardEffectHelpers.AddHorn(world, target); + world.RemoveSingleton(); + return true; + } +} + +public class PrincessEffect : ICardEffect +{ + public CardEffect Effect => CardEffect.Princess; + + public bool Resolve(World world, Entity card, Entity player, Entity owner) + { + world.Commands.Enqueue(new ReplaceCastleCommand()); + return true; + } + + public bool ResolveChoice(World world, Entity target) => true; +} + +public class DuelistEffect : ICardEffect +{ + public CardEffect Effect => CardEffect.Duelist; + + public bool Resolve(World world, Entity card, Entity player, Entity owner) + { + world.Commands.Enqueue(new EndPlayPhaseCommand()); + return true; + } + + public bool ResolveChoice(World world, Entity target) => true; +} + +public class NunEffect : ICardEffect +{ + public CardEffect Effect => CardEffect.Nun; + + public bool Resolve(World world, Entity card, Entity player, Entity owner) + { + var fieldCards = GameUtil.GetFieldCards(world, owner); + if (fieldCards.Count == 0) + { + world.Commands.Enqueue(new DrawCardCommand { Player = player, Kind = null }); + return true; + } + + world.SetSingleton(new PendingNun { CardEntity = card, Player = player }); + return false; + } + + public bool ResolveChoice(World world, Entity target) + { + var pending = world.ReadSingleton(); + if (target != Entity.Null) + { + var discardDeck = CardEffectHelpers.GetDiscardDeck(world, pending.Player); + world.RemoveComponent(target); + if (world.HasComponent(target)) + world.RemoveComponent(target); + world.AddComponent(target, new InDeck { Source = target, Target = discardDeck }); + } + world.Commands.Enqueue(new DrawCardCommand { Player = pending.Player, Kind = null }); + world.RemoveSingleton(); + return true; + } +} + +public class ScoutEffect : ICardEffect +{ + public CardEffect Effect => CardEffect.Scout; + + public bool Resolve(World world, Entity card, Entity player, Entity owner) + { + world.SetSingleton(new PendingScout { CardEntity = card, Player = player }); + return false; + } + + public bool ResolveChoice(World world, Entity target) + { + var pending = world.ReadSingleton(); + if (target != Entity.Null && world.HasComponent(target)) + { + foreach (var c in GameUtil.GetFieldCards(world, target)) + { + if (world.TryGetComponent(c, out var cd) && cd.FaceDown) + { + ref var cardRef = ref world.GetComponent(c); + cardRef.FaceDown = false; + world.MarkModified(c); + } + } + } + world.RemoveSingleton(); + return true; + } +} + +public class WitchEffect : ICardEffect +{ + public CardEffect Effect => CardEffect.Witch; + + public bool Resolve(World world, Entity card, Entity player, Entity owner) + { + var banner = CardEffectHelpers.GetBanner(world, owner); + if (banner != Entity.Null) + CardEffectHelpers.AddSkull(world, banner); + return true; + } + + public bool ResolveChoice(World world, Entity target) => true; +} + +public class ThiefEffect : ICardEffect +{ + public CardEffect Effect => CardEffect.Thief; + + public bool Resolve(World world, Entity card, Entity player, Entity owner) + { + return true; + } + + public bool ResolveChoice(World world, Entity target) => true; +} + +// ──────────────────────────────────────────────────────────── +// Face-down flip effects +// ──────────────────────────────────────────────────────────── + +public class PegasusEffect : ICardEffect +{ + public CardEffect Effect => CardEffect.Pegasus; + + public bool Resolve(World world, Entity card, Entity player, Entity owner) + { + world.SetSingleton(new PendingPegasus { CardEntity = card, Player = player }); + return false; + } + + public bool ResolveChoice(World world, Entity target) + { + var pending = world.ReadSingleton(); + if (target != Entity.Null && target != pending.CardEntity) + { + world.RemoveComponent(target); + if (world.HasComponent(target)) + world.RemoveComponent(target); + world.AddComponent(target, new HeldBy { Source = target, Target = pending.Player }); + } + world.RemoveSingleton(); + return true; + } +} + +public class CurseMasterEffect : ICardEffect +{ + public CardEffect Effect => CardEffect.CurseMaster; + + public bool Resolve(World world, Entity card, Entity player, Entity owner) + { + world.SetSingleton(new PendingCurseMaster { CardEntity = card, Player = player }); + return false; + } + + public bool ResolveChoice(World world, Entity target) + { + var pending = world.ReadSingleton(); + if (target != Entity.Null && world.HasComponent(target)) + { + var placedTargets = world.GetSources(target).ToList(); + world.DestroyEntity(target); + foreach (var pt in placedTargets) + { + var skull = world.CreateEntity(); + world.AddComponent(skull, new Skull()); + world.AddComponent(skull, new PlacedOn { Source = skull, Target = pt }); + } + } + world.RemoveSingleton(); + return true; + } +} + +public class MageEffect : ICardEffect +{ + public CardEffect Effect => CardEffect.Mage; + + public bool Resolve(World world, Entity card, Entity player, Entity owner) + { + Entity best = Entity.Null; + int bestRank = int.MinValue; + foreach (var p in GameUtil.FindAllEntities(world)) + { + foreach (var c in GameUtil.GetFieldCards(world, p)) + { + if (world.TryGetComponent(c, out var cd) && !cd.FaceDown && cd.Rank > bestRank) + { + bestRank = cd.Rank; + best = c; + } + } + } + if (best != Entity.Null) + CardEffectHelpers.AddSkull(world, best); + return true; + } + + public bool ResolveChoice(World world, Entity target) => true; +} + +// ──────────────────────────────────────────────────────────── +// Shared helpers +// ──────────────────────────────────────────────────────────── + +public static class CardEffectHelpers +{ + public static void AddHorn(World world, Entity target) + { + var horn = world.CreateEntity(); + world.AddComponent(horn, new Horn()); + world.AddComponent(horn, new PlacedOn { Source = horn, Target = target }); + } + + public static void AddSkull(World world, Entity target) + { + var skull = world.CreateEntity(); + world.AddComponent(skull, new Skull()); + world.AddComponent(skull, new PlacedOn { Source = skull, Target = target }); + } + + public static Entity GetBanner(World world, Entity player) + { + using var iter = world.Select(); + while (iter.MoveNext()) + { + if (iter.CurrentEntity == World.SingletonEntity) continue; + var onField = world.GetSources(player); + if (onField.Contains(iter.CurrentEntity)) + return iter.CurrentEntity; + } + return Entity.Null; + } + + public static Entity GetDiscardDeck(World world, Entity player) + { + using var iter = world.Select(); + while (iter.MoveNext()) + { + if (iter.CurrentEntity != World.SingletonEntity) + return iter.CurrentEntity; + } + return Entity.Null; + } +} \ No newline at end of file diff --git a/Game.CardWars/CardWars.csproj b/Game.CardWars/CardWars.csproj new file mode 100644 index 0000000..9166990 --- /dev/null +++ b/Game.CardWars/CardWars.csproj @@ -0,0 +1,17 @@ + + + + Library + Game.CardWars + + + + + + + + + + + diff --git a/Game.CardWars/Commands/GameCommands.cs b/Game.CardWars/Commands/GameCommands.cs new file mode 100644 index 0000000..5b69ee1 --- /dev/null +++ b/Game.CardWars/Commands/GameCommands.cs @@ -0,0 +1,173 @@ +using MessagePack; +using OECS; + +namespace Game.CardWars; + +[MessagePackObject] +public struct SkipPlayCommand : ICommand +{ + public void Execute(World world) + { + var state = world.ReadSingleton(); + if (state.Phase != GamePhase.PlayPhase) return; + + ref var mutable = ref world.GetSingleton(); + mutable.PlayPhaseEnded = true; + world.MarkModified(World.SingletonEntity); + } +} + +[MessagePackObject] +public struct FlipCardCommand : ICommand +{ + [Key(0)] public Entity CardEntity; + + public void Execute(World world) + { + var state = world.ReadSingleton(); + if (state.Phase != GamePhase.FlipPhase) return; + if (!world.IsAlive(CardEntity)) return; + if (!world.HasComponent(CardEntity)) return; + + ref var card = ref world.GetComponent(CardEntity); + if (!card.FaceDown) return; + + card.FaceDown = false; + world.MarkModified(CardEntity); + + var def = world.ReadComponent(CardEntity); + Entity player = PlayCardCommand.FindPlayerByIndex(world, state.CurrentPlayerIndex); + + // Resolve flip effects via registry. + CardEffectRegistry.Dispatch(world, def.Effect, CardEntity, player, player); + } +} + +[MessagePackObject] +public struct SkipFlipCommand : ICommand +{ + public void Execute(World world) + { + // Skip is only valid if no face-down cards remain. + var state = world.ReadSingleton(); + if (state.Phase != GamePhase.FlipPhase) return; + + Entity player = PlayCardCommand.FindPlayerByIndex(world, state.CurrentPlayerIndex); + var fieldCards = GameUtil.GetFieldCards(world, player); + bool hasFaceDown = fieldCards.Any(c => + world.HasComponent(c) && world.ReadComponent(c).FaceDown); + if (hasFaceDown) return; + + // Advance is handled by FlipPhaseSystem. + } +} + +[MessagePackObject] +public struct DrawCardCommand : ICommand +{ + [Key(0)] public Entity Player; + [Key(1)] public CardKind? Kind; + + public void Execute(World world) + { + if (!world.IsAlive(Player)) return; + + var kind = Kind ?? CardKind.Public; + var deck = GetDeck(world, kind); + if (deck == Entity.Null) return; + + var cards = world.GetSources(deck); + if (cards.Count == 0) + { + // Recycle: for now, faction discards recycle to the same deck. + GameUtil.RecycleDiscard(world, deck, deck); + cards = world.GetSources(deck); + if (cards.Count == 0) return; + } + + GameUtil.DrawCard(world, deck, Player); + } + + private static Entity GetDeck(World world, CardKind kind) + { + if (kind == CardKind.Public) + { + using var iter = world.Select(); + while (iter.MoveNext()) + { + if (iter.CurrentEntity != World.SingletonEntity) + return iter.CurrentEntity; + } + } + else + { + using var iter = world.Select(); + while (iter.MoveNext()) + { + if (iter.CurrentEntity != World.SingletonEntity) + return iter.CurrentEntity; + } + } + return Entity.Null; + } +} + +[MessagePackObject] +public struct ExtraActionCommand : ICommand +{ + public void Execute(World world) { } +} + +[MessagePackObject] +public struct EndPlayPhaseCommand : ICommand +{ + public void Execute(World world) + { + ref var state = ref world.GetSingleton(); + if (state.Phase != GamePhase.PlayPhase) return; + world.SetSingleton(new PendingDuelist { PlayerIndex = state.CurrentPlayerIndex }); + } +} + +[MessagePackObject] +public struct ReplaceCastleCommand : ICommand +{ + public void Execute(World world) + { + var state = world.ReadSingleton(); + var castles = GameUtil.FindAllEntities(world); + foreach (var c in castles) + world.DestroyEntity(c); + + var colors = new[] { CastleColor.Black, CastleColor.White, CastleColor.Blue, + CastleColor.Brown, CastleColor.Yellow, CastleColor.Indigo, CastleColor.Gold }; + var seed = state.Seed; + + for (int i = 0; i < 3; i++) + { + int idx = Mulberry32.NextInt(ref seed, 0, colors.Length - 1); + var castle = world.CreateEntity(); + world.AddComponent(castle, new Castle { Color = colors[idx] }); + } + } +} + +/// +/// Player makes a choice to resolve a pending card effect. +/// The target entity depends on the effect: +/// - Mercenary, Dancer, Paladin, Nun: a field card entity +/// - Scout: a player entity +/// - Pegasus: a field card to return (or null) +/// - CurseMaster: a horn token entity +/// +[MessagePackObject] +public struct ResolveChoiceCommand : ICommand +{ + [Key(0)] public Entity Target; + + public void Execute(World world) + { + if (!PendingChoice.Any(world)) return; + CardEffectRegistry.DispatchChoice(world, Target); + } +} \ No newline at end of file diff --git a/Game.CardWars/Commands/NewGameCommand.cs b/Game.CardWars/Commands/NewGameCommand.cs new file mode 100644 index 0000000..e891c16 --- /dev/null +++ b/Game.CardWars/Commands/NewGameCommand.cs @@ -0,0 +1,28 @@ +using MessagePack; +using OECS; + +namespace Game.CardWars; + +/// +/// Initializes a fresh game with the given player count and seed. +/// +[MessagePackObject] +public struct NewGameCommand : ICommand +{ + [Key(0)] public int PlayerCount; + [Key(1)] public uint Seed; + [Key(2)] public string CardDataCsv; + + public void Execute(World world) + { + world.SetSingleton(new GameState + { + Phase = GamePhase.Setup, + PlayerCount = PlayerCount, + CurrentPlayerIndex = 0, + StartingPlayerIndex = 0, + RoundNumber = 0, + Seed = Seed + }); + } +} \ No newline at end of file diff --git a/Game.CardWars/Commands/PlayCardCommand.cs b/Game.CardWars/Commands/PlayCardCommand.cs new file mode 100644 index 0000000..ac62054 --- /dev/null +++ b/Game.CardWars/Commands/PlayCardCommand.cs @@ -0,0 +1,62 @@ +using MessagePack; +using OECS; + +namespace Game.CardWars; + +/// +/// Play a card from hand to the field, choosing a rank. +/// Resolves on-play effects via the CardEffectRegistry. +/// +[MessagePackObject] +public struct PlayCardCommand : ICommand +{ + [Key(0)] public Entity CardEntity; + [Key(1)] public int Rank; + [Key(2)] public bool FaceDown; + [Key(3)] public Entity? TargetPlayer; + + public void Execute(World world) + { + var state = world.ReadSingleton(); + if (state.Phase is not GamePhase.PlayPhase) return; + if (!world.IsAlive(CardEntity)) return; + + Entity player = FindPlayerByIndex(world, state.CurrentPlayerIndex); + if (player == Entity.Null) return; + + // Verify the card is in the player's hand. + if (!world.HasComponent(CardEntity)) return; + var heldBy = world.ReadComponent(CardEntity); + if (heldBy.Target != player) return; + + var def = world.ReadComponent(CardEntity); + + // Determine target: Witch/Thief are played to another player's field. + Entity fieldOwner = def.Effect is CardEffect.Witch or CardEffect.Thief + ? (TargetPlayer ?? player) + : player; + + // Move from hand to field. + world.RemoveComponent(CardEntity); + world.AddComponent(CardEntity, new OnField { Source = CardEntity, Target = fieldOwner }); + world.AddComponent(CardEntity, new Card { Rank = Rank, FaceDown = FaceDown }); + + // Resolve on-play effects via registry. + if (!FaceDown) + CardEffectRegistry.Dispatch(world, def.Effect, CardEntity, player, fieldOwner); + + world.MarkModified(World.SingletonEntity); + } + + public static Entity FindPlayerByIndex(World world, int index) + { + using var iter = world.Select(); + while (iter.MoveNext()) + { + if (iter.CurrentEntity == World.SingletonEntity) continue; + if (iter.Current1.Index == index) + return iter.CurrentEntity; + } + return Entity.Null; + } +} \ No newline at end of file diff --git a/Game.CardWars/Components/Banner.cs b/Game.CardWars/Components/Banner.cs new file mode 100644 index 0000000..1ed4f39 --- /dev/null +++ b/Game.CardWars/Components/Banner.cs @@ -0,0 +1,9 @@ +using MessagePack; + +namespace Game.CardWars; + +/// +/// Tag component for a player's banner entity. +/// +[MessagePackObject] +public struct Banner { } diff --git a/Game.CardWars/Components/Card.cs b/Game.CardWars/Components/Card.cs new file mode 100644 index 0000000..14117be --- /dev/null +++ b/Game.CardWars/Components/Card.cs @@ -0,0 +1,17 @@ +using MessagePack; + +namespace Game.CardWars; + +/// +/// Instance data for a card that has been played to the field. +/// Cards in hand/deck only have CardDef; this is added when played. +/// +[MessagePackObject] +public record struct Card +{ + /// The rank chosen for this play (one of CardDef.Ranks). + [Key(0)] public int Rank; + + /// Whether the card is face-down on the field. + [Key(1)] public bool FaceDown; +} diff --git a/Game.CardWars/Components/CardDef.cs b/Game.CardWars/Components/CardDef.cs new file mode 100644 index 0000000..0fdbbb3 --- /dev/null +++ b/Game.CardWars/Components/CardDef.cs @@ -0,0 +1,17 @@ +using MessagePack; + +namespace Game.CardWars; + +/// +/// Static card definition — name, possible ranks, effect, and kind. +/// Cards in the deck/hand only have CardDef. When played to the field, +/// they also get a Card component with the chosen rank. +/// +[MessagePackObject] +public record struct CardDef +{ + [Key(0)] public string Name; + [Key(1)] public int[] Ranks; + [Key(2)] public CardEffect Effect; + [Key(3)] public CardKind Kind; +} diff --git a/Game.CardWars/Components/Castle.cs b/Game.CardWars/Components/Castle.cs new file mode 100644 index 0000000..4c37593 --- /dev/null +++ b/Game.CardWars/Components/Castle.cs @@ -0,0 +1,12 @@ +using MessagePack; + +namespace Game.CardWars; + +/// +/// A castle that can be won each round. +/// +[MessagePackObject] +public record struct Castle +{ + [Key(0)] public CastleColor Color; +} diff --git a/Game.CardWars/Components/FactionDeck.cs b/Game.CardWars/Components/FactionDeck.cs new file mode 100644 index 0000000..9added9 --- /dev/null +++ b/Game.CardWars/Components/FactionDeck.cs @@ -0,0 +1,9 @@ +using MessagePack; + +namespace Game.CardWars; + +/// +/// Tag component for a faction deck entity. +/// +[MessagePackObject] +public struct FactionDeck { } \ No newline at end of file diff --git a/Game.CardWars/Components/HeldBy.cs b/Game.CardWars/Components/HeldBy.cs new file mode 100644 index 0000000..a65f787 --- /dev/null +++ b/Game.CardWars/Components/HeldBy.cs @@ -0,0 +1,15 @@ +using MessagePack; +using OECS; + +namespace Game.CardWars; + +/// +/// Relationship from a card entity to the player who holds it in hand. +/// Added to the card: Source=card, Target=player. +/// +[MessagePackObject] +public record struct HeldBy : IRelationship +{ + [Key(0)] public Entity Source { get; set; } + [Key(1)] public Entity Target { get; set; } +} \ No newline at end of file diff --git a/Game.CardWars/Components/Horn.cs b/Game.CardWars/Components/Horn.cs new file mode 100644 index 0000000..d7b7905 --- /dev/null +++ b/Game.CardWars/Components/Horn.cs @@ -0,0 +1,9 @@ +using MessagePack; + +namespace Game.CardWars; + +/// +/// Tag component for a horn token. Can be placed on cards, banners, or leaders. +/// +[MessagePackObject] +public struct Horn { } diff --git a/Game.CardWars/Components/InDeck.cs b/Game.CardWars/Components/InDeck.cs new file mode 100644 index 0000000..d389485 --- /dev/null +++ b/Game.CardWars/Components/InDeck.cs @@ -0,0 +1,14 @@ +using MessagePack; +using OECS; + +namespace Game.CardWars; + +/// +/// Relationship from a card entity to a deck entity. +/// +[MessagePackObject] +public record struct InDeck : IRelationship +{ + [Key(0)] public Entity Source { get; set; } + [Key(1)] public Entity Target { get; set; } +} \ No newline at end of file diff --git a/Game.CardWars/Components/Leader.cs b/Game.CardWars/Components/Leader.cs new file mode 100644 index 0000000..cf24f38 --- /dev/null +++ b/Game.CardWars/Components/Leader.cs @@ -0,0 +1,10 @@ +using MessagePack; + +namespace Game.CardWars; + +/// +/// Tag component marking a card entity as the leader card. +/// The leader is always on the field and contributes its rank to power. +/// +[MessagePackObject] +public struct Leader { } \ No newline at end of file diff --git a/Game.CardWars/Components/OnField.cs b/Game.CardWars/Components/OnField.cs new file mode 100644 index 0000000..9f305fc --- /dev/null +++ b/Game.CardWars/Components/OnField.cs @@ -0,0 +1,14 @@ +using MessagePack; +using OECS; + +namespace Game.CardWars; + +/// +/// Relationship from a card entity to the player entity whose field it's on. +/// +[MessagePackObject] +public record struct OnField : IRelationship +{ + [Key(0)] public Entity Source { get; set; } + [Key(1)] public Entity Target { get; set; } +} \ No newline at end of file diff --git a/Game.CardWars/Components/PendingChoice.cs b/Game.CardWars/Components/PendingChoice.cs new file mode 100644 index 0000000..d037b15 --- /dev/null +++ b/Game.CardWars/Components/PendingChoice.cs @@ -0,0 +1,86 @@ +using MessagePack; +using OECS; + +namespace Game.CardWars; + +// ──────────────────────────────────────────────────────────── +// Per-effect pending choice components. +// Each is placed on the singleton entity when a card effect +// requires player input. Their presence blocks game advancement. +// ──────────────────────────────────────────────────────────── + +/// Mercenary: choose a combatant to give a skull. +[MessagePackObject] +public record struct PendingMercenary +{ + [Key(0)] public Entity CardEntity; + [Key(1)] public Entity Player; +} + +/// Dancer: choose up to 2 combatants for horn. Step tracks progress. +[MessagePackObject] +public record struct PendingDancer +{ + [Key(0)] public Entity CardEntity; + [Key(1)] public Entity Player; + [Key(2)] public int Step; // 0 = first choice, 1 = second choice +} + +/// Paladin: choose a combatant or banner for horn. +[MessagePackObject] +public record struct PendingPaladin +{ + [Key(0)] public Entity CardEntity; + [Key(1)] public Entity Player; +} + +/// Nun: choose a field card to discard. +[MessagePackObject] +public record struct PendingNun +{ + [Key(0)] public Entity CardEntity; + [Key(1)] public Entity Player; +} + +/// Scout: choose an opponent player to scout. +[MessagePackObject] +public record struct PendingScout +{ + [Key(0)] public Entity CardEntity; + [Key(1)] public Entity Player; +} + +/// Pegasus: choose a field card to return to hand (or null to pass). +[MessagePackObject] +public record struct PendingPegasus +{ + [Key(0)] public Entity CardEntity; + [Key(1)] public Entity Player; +} + +/// CurseMaster: choose a horn token to turn into skull. +[MessagePackObject] +public record struct PendingCurseMaster +{ + [Key(0)] public Entity CardEntity; + [Key(1)] public Entity Player; +} + +// ──────────────────────────────────────────────────────────── +// Helper to detect any pending component on the singleton. +// ──────────────────────────────────────────────────────────── + +public static class PendingChoice +{ + /// Returns true if any pending choice component exists on the singleton. + public static bool Any(World world) + { + return world.HasSingleton() + || world.HasSingleton() + || world.HasSingleton() + || world.HasSingleton() + || world.HasSingleton() + || world.HasSingleton() + || world.HasSingleton(); + } +} \ No newline at end of file diff --git a/Game.CardWars/Components/PendingDuelist.cs b/Game.CardWars/Components/PendingDuelist.cs new file mode 100644 index 0000000..d9679bb --- /dev/null +++ b/Game.CardWars/Components/PendingDuelist.cs @@ -0,0 +1,14 @@ +using MessagePack; + +namespace Game.CardWars; + +/// +/// Placed on the singleton when a 决斗家 (Duelist) is played. +/// The play phase ends when the turn comes back to this player. +/// Removed automatically when the phase transitions. +/// +[MessagePackObject] +public struct PendingDuelist +{ + [Key(0)] public int PlayerIndex; +} \ No newline at end of file diff --git a/Game.CardWars/Components/PlacedOn.cs b/Game.CardWars/Components/PlacedOn.cs new file mode 100644 index 0000000..b40dc4f --- /dev/null +++ b/Game.CardWars/Components/PlacedOn.cs @@ -0,0 +1,14 @@ +using MessagePack; +using OECS; + +namespace Game.CardWars; + +/// +/// Relationship from a horn/skull token entity to the target entity it's placed on. +/// +[MessagePackObject] +public record struct PlacedOn : IRelationship +{ + [Key(0)] public Entity Source { get; set; } + [Key(1)] public Entity Target { get; set; } +} \ No newline at end of file diff --git a/Game.CardWars/Components/Player.cs b/Game.CardWars/Components/Player.cs new file mode 100644 index 0000000..f42923e --- /dev/null +++ b/Game.CardWars/Components/Player.cs @@ -0,0 +1,12 @@ +using MessagePack; + +namespace Game.CardWars; + +/// +/// Tag + index for a player entity. +/// +[MessagePackObject] +public record struct Player +{ + [Key(0)] public int Index; +} diff --git a/Game.CardWars/Components/PublicDeck.cs b/Game.CardWars/Components/PublicDeck.cs new file mode 100644 index 0000000..f523e9a --- /dev/null +++ b/Game.CardWars/Components/PublicDeck.cs @@ -0,0 +1,9 @@ +using MessagePack; + +namespace Game.CardWars; + +/// +/// Tag component for the public deck entity. +/// +[MessagePackObject] +public struct PublicDeck { } \ No newline at end of file diff --git a/Game.CardWars/Components/Skull.cs b/Game.CardWars/Components/Skull.cs new file mode 100644 index 0000000..2b8f7fd --- /dev/null +++ b/Game.CardWars/Components/Skull.cs @@ -0,0 +1,9 @@ +using MessagePack; + +namespace Game.CardWars; + +/// +/// Tag component for a skull token. Can be placed on cards, banners, or leaders. +/// +[MessagePackObject] +public struct Skull { } diff --git a/Game.CardWars/Enums.cs b/Game.CardWars/Enums.cs new file mode 100644 index 0000000..b071bd5 --- /dev/null +++ b/Game.CardWars/Enums.cs @@ -0,0 +1,52 @@ +namespace Game.CardWars; + +public enum CardKind : byte +{ + Public = 0, + Faction = 1 +} + +public enum CardEffect : byte +{ + None = 0, + + // On-play effects + Warrior, + Archer, + Mercenary, + Merchant, + Dancer, + Paladin, + Princess, + Duelist, + Nun, + Scout, + Witch, + Thief, + + // On-flip effects (played face-down) + Pegasus, + CurseMaster, + Mage +} + +public enum CastleColor : byte +{ + Black = 0, + White = 1, + Blue = 2, + Brown = 3, + Yellow = 4, + Indigo = 5, + Gold = 6 +} + +public enum GamePhase : byte +{ + Setup = 0, + PlayPhase = 1, + FlipPhase = 2, + Scoring = 3, + Cleanup = 4, + GameOver = 5 +} diff --git a/Game.CardWars/GameFactory.cs b/Game.CardWars/GameFactory.cs new file mode 100644 index 0000000..c7f758a --- /dev/null +++ b/Game.CardWars/GameFactory.cs @@ -0,0 +1,51 @@ +using OECS; + +namespace Game.CardWars; + +/// +/// Factory for creating a fully configured game world and system group. +/// +public static class GameFactory +{ + public static string DefaultCardData => + "name,ranks,effect\n" + + "战士,3;4;4;5,战士\n" + + "弓手,3;4;4;5,弓手\n" + + "佣兵,4;5;5;6,佣兵\n" + + "商人,0;1,商人\n" + + "舞姬,0;1,舞姬\n" + + "圣骑士,2;3,圣骑士\n" + + "飞马,2;3,飞马\n" + + "诅咒师,1;2,诅咒师\n" + + "魔导士,0;1,魔导士\n" + + "公主,4,公主\n" + + "决斗家,4,决斗家\n" + + "修女,3,修女\n" + + "斥候,2,斥候\n" + + "女巫,-1,女巫\n" + + "盗贼,-1,盗贼\n"; + + public static (World World, SystemGroup Group) Create(int playerCount, uint seed = 42) + { + var world = new World(); + var group = new SystemGroup(world); + + world.Commands.Enqueue(new NewGameCommand + { + PlayerCount = playerCount, + Seed = seed, + CardDataCsv = DefaultCardData + }); + + group.Add(new GameSetupSystem(playerCount, DefaultCardData)); + group.Add(new PlayPhaseSystem()); + group.Add(new FlipPhaseSystem()); + group.Add(new ScoringSystem()); + group.Add(new CleanupSystem()); + + // Run setup. + group.RunLogical(); + + return (world, group); + } +} \ No newline at end of file diff --git a/Game.CardWars/GameUtil.cs b/Game.CardWars/GameUtil.cs new file mode 100644 index 0000000..83d2055 --- /dev/null +++ b/Game.CardWars/GameUtil.cs @@ -0,0 +1,162 @@ +using OECS; + +namespace Game.CardWars; + +/// +/// Shared utility methods for querying and manipulating the game world. +/// +public static class GameUtil +{ + public static Entity FindEntity(World world) where T : struct + { + using var iter = world.Select(); + while (iter.MoveNext()) + { + if (iter.CurrentEntity != World.SingletonEntity) + return iter.CurrentEntity; + } + return Entity.Null; + } + + public static List FindAllEntities(World world) where T : struct + { + var list = new List(); + using var iter = world.Select(); + while (iter.MoveNext()) + { + if (iter.CurrentEntity != World.SingletonEntity) + list.Add(iter.CurrentEntity); + } + return list; + } + + /// Draw the top card from a deck and add it to the player's hand. + public static bool DrawCard(World world, Entity deck, Entity player) + { + var cards = world.GetSources(deck); + if (cards.Count == 0) return false; + + var cardEntity = cards.First(); + world.RemoveComponent(cardEntity); + world.AddComponent(cardEntity, new HeldBy { Source = cardEntity, Target = player }); + return true; + } + + /// Shuffle cards in a deck using Fisher-Yates with the given seed. + public static void ShuffleDeck(World world, Entity deck, ref uint seed) + { + var cards = world.GetSources(deck).ToArray(); + for (int i = cards.Length - 1; i > 0; i--) + { + int j = Mulberry32.NextInt(ref seed, 0, i); + (cards[i], cards[j]) = (cards[j], cards[i]); + } + for (int i = cards.Length - 1; i >= 0; i--) + world.RemoveComponent(cards[i]); + for (int i = 0; i < cards.Length; i++) + world.AddComponent(cards[i], new InDeck { Source = cards[i], Target = deck }); + } + + /// Get all field cards for a player. + public static List GetFieldCards(World world, Entity player) + { + return world.GetSources(player).ToList(); + } + + /// Get all cards in a player's hand. + public static List GetHandCards(World world, Entity player) + { + return world.GetSources(player).ToList(); + } + + /// Calculate a player's total battle power. + public static int CalculatePower(World world, Entity player) + { + int total = 0; + + // Leader power. + using var leaderIter = world.Select(); + while (leaderIter.MoveNext()) + { + if (leaderIter.CurrentEntity == World.SingletonEntity) continue; + var onFieldSources = world.GetSources(player); + if (onFieldSources.Contains(leaderIter.CurrentEntity)) + { + if (world.TryGetComponent(leaderIter.CurrentEntity, out var leaderCard)) + total += leaderCard.Rank; + } + } + + // Find the player's banner. + var banner = CardEffectHelpers.GetBanner(world, player); + bool bannerHasHorn = banner != Entity.Null && world.HasComponent(banner); + bool bannerHasSkull = banner != Entity.Null && world.HasComponent(banner); + + // Field cards. + var fieldCards = GetFieldCards(world, player); + int poweredCount = 0; + foreach (var card in fieldCards) + { + if (!world.TryGetComponent(card, out var cardData)) continue; + if (cardData.FaceDown) continue; + if (cardData.Rank <= 0) continue; + + poweredCount++; + int rank = cardData.Rank; + if (world.HasComponent(card)) + rank *= 2; + if (world.HasComponent(card)) + rank = 0; + + total += rank; + } + + if (bannerHasHorn) total += poweredCount; + if (bannerHasSkull) total -= poweredCount; + + return total; + } + + /// Move all field cards (except leaders and banners) to the discard pile. + public static void DiscardField(World world, Entity player, Entity discardDeck) + { + var fieldCards = GetFieldCards(world, player); + + var toDiscard = new List(); + foreach (var card in fieldCards) + { + // Keep leaders and banners on the field. + if (world.HasComponent(card)) continue; + if (world.HasComponent(card)) continue; + toDiscard.Add(card); + } + + foreach (var card in toDiscard) + { + world.RemoveComponent(card); + world.RemoveComponent(card); + world.AddComponent(card, new InDeck { Source = card, Target = discardDeck }); + } + + // Destroy horns and skulls placed on discarded cards. + var tokenTargets = new HashSet(); + foreach (var card in toDiscard) + { + foreach (var token in world.GetSources(card)) + tokenTargets.Add(token); + } + foreach (var token in tokenTargets) + world.DestroyEntity(token); + } + + /// Recycle discard pile into draw pile. + public static void RecycleDiscard(World world, Entity drawDeck, Entity discardDeck) + { + var cards = world.GetSources(discardDeck).ToList(); + foreach (var card in cards) + { + world.RemoveComponent(card); + world.AddComponent(card, new InDeck { Source = card, Target = drawDeck }); + } + } +} \ No newline at end of file diff --git a/Game.CardWars/Mulberry32.cs b/Game.CardWars/Mulberry32.cs new file mode 100644 index 0000000..b562035 --- /dev/null +++ b/Game.CardWars/Mulberry32.cs @@ -0,0 +1,21 @@ +namespace Game.CardWars; + +/// +/// Mulberry32 PRNG — a fast, high-quality 32-bit random number generator. +/// +public static class Mulberry32 +{ + public static float NextFloat(ref uint state) + { + state += 0x6D2B79F5u; + uint z = state; + z = (z ^ (z >> 15)) * (z | 1u); + z ^= z + (z ^ (z >> 7)) * (z | 61u); + return (z ^ (z >> 14)) / (float)uint.MaxValue; + } + + public static int NextInt(ref uint state, int min, int max) + { + return (int)(NextFloat(ref state) * (max - min + 1)) + min; + } +} diff --git a/Game.CardWars/Singletons/GameState.cs b/Game.CardWars/Singletons/GameState.cs new file mode 100644 index 0000000..243cb49 --- /dev/null +++ b/Game.CardWars/Singletons/GameState.cs @@ -0,0 +1,18 @@ +using MessagePack; + +namespace Game.CardWars; + +/// +/// Global game state on the singleton entity. +/// +[MessagePackObject] +public record struct GameState +{ + [Key(0)] public GamePhase Phase; + [Key(1)] public int PlayerCount; + [Key(2)] public int CurrentPlayerIndex; + [Key(3)] public int StartingPlayerIndex; + [Key(4)] public int RoundNumber; + [Key(5)] public uint Seed; + [Key(6)] public bool PlayPhaseEnded; +} \ No newline at end of file diff --git a/Game.CardWars/Singletons/PlayerScores.cs b/Game.CardWars/Singletons/PlayerScores.cs new file mode 100644 index 0000000..9a42ead --- /dev/null +++ b/Game.CardWars/Singletons/PlayerScores.cs @@ -0,0 +1,14 @@ +using MessagePack; + +namespace Game.CardWars; + +/// +/// Per-player score tracking. +/// +[MessagePackObject] +public record struct PlayerScores +{ + [Key(0)] public int[] Scores; + [Key(1)] public CastleColor[] WonCastles; + [Key(2)] public int Count; +} \ No newline at end of file diff --git a/Game.CardWars/Systems/CleanupSystem.cs b/Game.CardWars/Systems/CleanupSystem.cs new file mode 100644 index 0000000..af41363 --- /dev/null +++ b/Game.CardWars/Systems/CleanupSystem.cs @@ -0,0 +1,66 @@ +using OECS; + +namespace Game.CardWars; + +/// +/// Discards field cards, clears tokens, draws new hands, checks game over. +/// +public class CleanupSystem : ISystem +{ + public void Run(World world) + { + var state = world.ReadSingleton(); + if (state.Phase != GamePhase.Cleanup) return; + + ref var mutable = ref world.GetSingleton(); + + var players = GameUtil.FindAllEntities(world); + var publicDeck = GameUtil.FindEntity(world); + + foreach (var player in players) + { + GameUtil.DiscardField(world, player, publicDeck); + + // Clear tokens on banners. + var banner = CardEffectHelpers.GetBanner(world, player); + if (banner != Entity.Null) + { + var tokens = world.GetSources(banner).ToList(); + foreach (var token in tokens) + world.DestroyEntity(token); + } + } + + // Draw 2 public cards per player. + foreach (var player in players) + { + for (int i = 0; i < 2; i++) + GameUtil.DrawCard(world, publicDeck, player); + } + + // Check game over. + int maxScore = 0; + foreach (var player in players) + { + var castles = world.GetSources(player) + .Where(c => world.HasComponent(c)) + .ToList(); + maxScore = Math.Max(maxScore, castles.Count); + } + + if (maxScore >= 4) + { + mutable.Phase = GamePhase.GameOver; + } + else + { + mutable.Phase = GamePhase.PlayPhase; + mutable.RoundNumber++; + mutable.CurrentPlayerIndex = (mutable.StartingPlayerIndex + 1) % mutable.PlayerCount; + mutable.StartingPlayerIndex = mutable.CurrentPlayerIndex; + mutable.PlayPhaseEnded = false; + } + + world.MarkModified(World.SingletonEntity); + } +} \ No newline at end of file diff --git a/Game.CardWars/Systems/FlipPhaseSystem.cs b/Game.CardWars/Systems/FlipPhaseSystem.cs new file mode 100644 index 0000000..148d310 --- /dev/null +++ b/Game.CardWars/Systems/FlipPhaseSystem.cs @@ -0,0 +1,20 @@ +using OECS; + +namespace Game.CardWars; + +/// +/// Manages the flip phase: players take turns flipping cards. +/// Blocks if a PendingChoice exists. +/// +public class FlipPhaseSystem : ISystem +{ + public void Run(World world) + { + var state = world.ReadSingleton(); + if (state.Phase != GamePhase.FlipPhase) return; + + // Block if a pending choice exists. + if (PendingChoice.Any(world)) + return; + } +} \ No newline at end of file diff --git a/Game.CardWars/Systems/GameSetupSystem.cs b/Game.CardWars/Systems/GameSetupSystem.cs new file mode 100644 index 0000000..efcacdc --- /dev/null +++ b/Game.CardWars/Systems/GameSetupSystem.cs @@ -0,0 +1,104 @@ +using OECS; + +namespace Game.CardWars; + +/// +/// One-time setup: creates players, leaders, banners, decks, castles, and deals starting hands. +/// +public class GameSetupSystem : ISystem +{ + private readonly int _playerCount; + private readonly string _cardDataCsv; + private bool _hasRun; + + public GameSetupSystem(int playerCount, string cardDataCsv) + { + _playerCount = playerCount; + _cardDataCsv = cardDataCsv; + } + + public void Run(World world) + { + if (_hasRun) return; + _hasRun = true; + + var state = world.ReadSingleton(); + ref var mutable = ref world.GetSingleton(); + mutable.PlayerCount = _playerCount; + mutable.Phase = GamePhase.PlayPhase; + mutable.CurrentPlayerIndex = 0; + mutable.StartingPlayerIndex = 0; + mutable.RoundNumber = 1; + world.MarkModified(World.SingletonEntity); + + // Register card effects. + CardEffectRegistry.Add(); + CardEffectRegistry.Add(); + CardEffectRegistry.Add(); + CardEffectRegistry.Add(); + CardEffectRegistry.Add(); + CardEffectRegistry.Add(); + CardEffectRegistry.Add(); + CardEffectRegistry.Add(); + CardEffectRegistry.Add(); + CardEffectRegistry.Add(); + CardEffectRegistry.Add(); + CardEffectRegistry.Add(); + CardEffectRegistry.Add(); + CardEffectRegistry.Add(); + CardEffectRegistry.Add(); + CardEffectRegistry.Freeze(); + + // Create public deck. + var publicDeck = world.CreateEntity(); + world.AddComponent(publicDeck, new PublicDeck()); + + // Load card definitions and create card instances for the public deck. + var defs = CardDataLoader.LoadDefinitions(world, _cardDataCsv); + foreach (var (defEntity, _) in defs) + { + world.AddComponent(defEntity, new InDeck { Source = defEntity, Target = publicDeck }); + } + + // Shuffle public deck. + var seed = state.Seed; + GameUtil.ShuffleDeck(world, publicDeck, ref seed); + + // Create the first castle. + var castle = world.CreateEntity(); + world.AddComponent(castle, new Castle { Color = CastleColor.Black }); + + // Create players. + for (int i = 0; i < _playerCount; i++) + { + var player = world.CreateEntity(); + world.AddComponent(player, new Player { Index = i }); + + // Create leader card (always on field, rank 0 placeholder). + var leader = world.CreateEntity(); + world.AddComponent(leader, new Leader()); + world.AddComponent(leader, new CardDef { Name = $"领袖{i}", Ranks = new[] { 0 }, Effect = CardEffect.None, Kind = CardKind.Faction }); + world.AddComponent(leader, new Card { Rank = 0, FaceDown = false }); + world.AddComponent(leader, new OnField { Source = leader, Target = player }); + + // Create banner. + var banner = world.CreateEntity(); + world.AddComponent(banner, new Banner()); + world.AddComponent(banner, new OnField { Source = banner, Target = player }); + + // Create faction deck. + var factionDeck = world.CreateEntity(); + world.AddComponent(factionDeck, new FactionDeck()); + } + + // Deal starting hands: 3 public cards per player. + foreach (var player in GameUtil.FindAllEntities(world)) + { + for (int i = 0; i < 3; i++) + GameUtil.DrawCard(world, publicDeck, player); + } + + mutable.Seed = seed; + world.MarkModified(World.SingletonEntity); + } +} \ No newline at end of file diff --git a/Game.CardWars/Systems/PlayPhaseSystem.cs b/Game.CardWars/Systems/PlayPhaseSystem.cs new file mode 100644 index 0000000..9b6a7c5 --- /dev/null +++ b/Game.CardWars/Systems/PlayPhaseSystem.cs @@ -0,0 +1,58 @@ +using OECS; + +namespace Game.CardWars; + +/// +/// Manages the play phase: players take turns. If a PendingChoice exists, +/// blocks advancement until the player resolves it. +/// +public class PlayPhaseSystem : ISystem +{ + public void Run(World world) + { + ref var state = ref world.GetSingleton(); + if (state.Phase != GamePhase.PlayPhase) return; + + // Block if a pending choice exists. + if (PendingChoice.Any(world)) + return; + + // If the current player has skipped, move to the next player. + if (state.PlayPhaseEnded) + { + state.PlayPhaseEnded = false; + AdvanceTurn(world, ref state); + return; + } + } + + private static void AdvanceTurn(World world, ref GameState state) + { + int next = (state.CurrentPlayerIndex + 1) % state.PlayerCount; + + // Duelist: if the next player is the one who played duelist, end the phase. + if (world.HasSingleton()) + { + var duelist = world.ReadSingleton(); + if (next == duelist.PlayerIndex) + { + world.RemoveSingleton(); + state.Phase = GamePhase.FlipPhase; + state.CurrentPlayerIndex = state.StartingPlayerIndex; + world.MarkModified(World.SingletonEntity); + return; + } + } + + state.CurrentPlayerIndex = next; + + // If we've come back to the starting player, everyone has skipped. + if (state.CurrentPlayerIndex == state.StartingPlayerIndex) + { + state.Phase = GamePhase.FlipPhase; + world.RemoveSingleton(); // Clean up in case duelist was never triggered. + } + + world.MarkModified(World.SingletonEntity); + } +} \ No newline at end of file diff --git a/Game.CardWars/Systems/ScoringSystem.cs b/Game.CardWars/Systems/ScoringSystem.cs new file mode 100644 index 0000000..c4fe2ce --- /dev/null +++ b/Game.CardWars/Systems/ScoringSystem.cs @@ -0,0 +1,59 @@ +using OECS; + +namespace Game.CardWars; + +/// +/// Calculates each player's battle power, awards the castle to the winner, +/// and checks for game end. +/// +public class ScoringSystem : ISystem +{ + public void Run(World world) + { + var state = world.ReadSingleton(); + if (state.Phase != GamePhase.Scoring) return; + + ref var mutable = ref world.GetSingleton(); + + var players = GameUtil.FindAllEntities(world); + if (players.Count == 0) return; + + int bestPower = int.MinValue; + Entity winner = Entity.Null; + int winnerIndex = -1; + + for (int i = 0; i < players.Count; i++) + { + int idx = (state.StartingPlayerIndex + i) % players.Count; + var player = players[idx]; + int power = GameUtil.CalculatePower(world, player); + + if (power > bestPower) + { + bestPower = power; + winner = player; + winnerIndex = idx; + } + } + + if (winner != Entity.Null) + { + var castles = GameUtil.FindAllEntities(world); + if (castles.Count > 0) + { + var castle = castles[0]; + world.AddComponent(castle, new HeldBy { Source = castle, Target = winner }); + world.RemoveComponent(castle); + } + + var colors = new[] { CastleColor.Black, CastleColor.White, CastleColor.Blue, + CastleColor.Brown, CastleColor.Yellow, CastleColor.Indigo, CastleColor.Gold }; + int colorIdx = Mulberry32.NextInt(ref mutable.Seed, 0, colors.Length - 1); + var newCastle = world.CreateEntity(); + world.AddComponent(newCastle, new Castle { Color = colors[colorIdx] }); + } + + mutable.Phase = GamePhase.Cleanup; + world.MarkModified(World.SingletonEntity); + } +} \ No newline at end of file diff --git a/Game.CardWars/design.md b/Game.CardWars/design.md new file mode 100644 index 0000000..27a4184 --- /dev/null +++ b/Game.CardWars/design.md @@ -0,0 +1,3 @@ +# CardWars — Design Document + + diff --git a/OECS.sln b/OECS.sln index 7542fa5..3f88df9 100644 --- a/OECS.sln +++ b/OECS.sln @@ -16,6 +16,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Game.Blackjack.Tests", "Gam EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Game.TicTacToe.Tests", "Game.TicTacToe.Tests\Game.TicTacToe.Tests.csproj", "{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Game.CardWars", "Game.CardWars\CardWars.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567891}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Game.CardWars.Tests", "Game.CardWars.Tests\Game.CardWars.Tests.csproj", "{B2C3D4E5-F6A7-8901-BCDE-F12345678902}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -86,32 +90,55 @@ Global {D4E5F6A7-B8C9-0123-DEF4-567890ABCDEF}.Release|x64.Build.0 = Release|Any CPU {D4E5F6A7-B8C9-0123-DEF4-567890ABCDEF}.Release|x86.ActiveCfg = Release|Any CPU {D4E5F6A7-B8C9-0123-DEF4-567890ABCDEF}.Release|x86.Build.0 = Release|Any CPU + {E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Debug|x64.ActiveCfg = Debug|Any CPU + {E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Debug|x64.Build.0 = Debug|Any CPU + {E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Debug|x86.ActiveCfg = Debug|Any CPU + {E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Debug|x86.Build.0 = Debug|Any CPU + {E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Release|Any CPU.Build.0 = Release|Any CPU + {E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Release|x64.ActiveCfg = Release|Any CPU + {E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Release|x64.Build.0 = Release|Any CPU + {E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Release|x86.ActiveCfg = Release|Any CPU + {E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Release|x86.Build.0 = Release|Any CPU + {F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Debug|x64.ActiveCfg = Debug|Any CPU + {F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Debug|x64.Build.0 = Debug|Any CPU + {F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Debug|x86.ActiveCfg = Debug|Any CPU + {F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Debug|x86.Build.0 = Debug|Any CPU + {F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Release|Any CPU.Build.0 = Release|Any CPU + {F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Release|x64.ActiveCfg = Release|Any CPU + {F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Release|x64.Build.0 = Release|Any CPU + {F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Release|x86.ActiveCfg = Release|Any CPU + {F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Release|x86.Build.0 = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567891}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567891}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567891}.Debug|x64.ActiveCfg = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567891}.Debug|x64.Build.0 = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567891}.Debug|x86.ActiveCfg = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567891}.Debug|x86.Build.0 = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567891}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567891}.Release|Any CPU.Build.0 = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567891}.Release|x64.ActiveCfg = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567891}.Release|x64.Build.0 = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567891}.Release|x86.ActiveCfg = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567891}.Release|x86.Build.0 = Release|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F12345678902}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F12345678902}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F12345678902}.Debug|x64.ActiveCfg = Debug|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F12345678902}.Debug|x64.Build.0 = Debug|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F12345678902}.Debug|x86.ActiveCfg = Debug|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F12345678902}.Debug|x86.Build.0 = Debug|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F12345678902}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F12345678902}.Release|Any CPU.Build.0 = Release|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F12345678902}.Release|x64.ActiveCfg = Release|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F12345678902}.Release|x64.Build.0 = Release|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F12345678902}.Release|x86.ActiveCfg = Release|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F12345678902}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection - {E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Debug|Any CPU.Build.0 = Debug|Any CPU - {E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Debug|x64.ActiveCfg = Debug|Any CPU - {E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Debug|x64.Build.0 = Debug|Any CPU - {E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Debug|x86.ActiveCfg = Debug|Any CPU - {E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Debug|x86.Build.0 = Debug|Any CPU - {E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Release|Any CPU.ActiveCfg = Release|Any CPU - {E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Release|Any CPU.Build.0 = Release|Any CPU - {E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Release|x64.ActiveCfg = Release|Any CPU - {E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Release|x64.Build.0 = Release|Any CPU - {E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Release|x86.ActiveCfg = Release|Any CPU - {E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Release|x86.Build.0 = Release|Any CPU - {F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Debug|Any CPU.Build.0 = Debug|Any CPU - {F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Debug|x64.ActiveCfg = Debug|Any CPU - {F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Debug|x64.Build.0 = Debug|Any CPU - {F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Debug|x86.ActiveCfg = Debug|Any CPU - {F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Debug|x86.Build.0 = Debug|Any CPU - {F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Release|Any CPU.ActiveCfg = Release|Any CPU - {F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Release|Any CPU.Build.0 = Release|Any CPU - {F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Release|x64.ActiveCfg = Release|Any CPU - {F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Release|x64.Build.0 = Release|Any CPU - {F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Release|x86.ActiveCfg = Release|Any CPU - {F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Release|x86.Build.0 = Release|Any CPU - EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection