feat: implement CardWars game engine

Implement the core CardWars game logic using an ECS-based architecture.
This includes:
- Card effect registry and various card effect implementations.
- Game phase management (Setup, Play, Flip, Scoring, Cleanup).
- Command system for player actions (PlayCard, FlipCard, etc.).
- Component-based game state and entity relationships.
- Automated game setup and scoring systems.
- Unit tests for game setup, card effects, and play logs.
This commit is contained in:
hypercross 2026-07-20 22:52:28 +08:00
parent 3b08138580
commit 2de735498c
40 changed files with 2440 additions and 25 deletions

View File

@ -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<GameState>();
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<CardDef>(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<Horn>(warrior1).Should().BeFalse(because: "no other warrior on field");
// Play second warrior.
var def2 = world.ReadComponent<CardDef>(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<Horn>(warrior2).Should().BeTrue(because: "another warrior exists on field");
}
[Fact]
public void MercenaryEffect_SetsPendingChoice()
{
var (world, group) = Setup();
var state = world.ReadSingleton<GameState>();
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<CardDef>(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<PublicDeck>(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<CardDef>(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<PendingMercenary>().Should().BeTrue();
var pending = world.ReadSingleton<PendingMercenary>();
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<PendingMercenary>().Should().BeFalse();
}
private static Entity FindCardWithEffect(World world, List<Entity> cards, CardEffect effect)
{
foreach (var c in cards)
{
if (world.HasComponent<CardDef>(c) && world.ReadComponent<CardDef>(c).Effect == effect)
return c;
}
return Entity.Null;
}
private static Entity FindSecondCardWithEffect(World world, List<Entity> cards, CardEffect effect)
{
Entity first = Entity.Null;
foreach (var c in cards)
{
if (world.HasComponent<CardDef>(c) && world.ReadComponent<CardDef>(c).Effect == effect)
{
if (first == Entity.Null) first = c;
else return c;
}
}
return Entity.Null;
}
}

View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>Game.CardWars.Tests</RootNamespace>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Game.CardWars\CardWars.csproj" />
</ItemGroup>
</Project>

View File

@ -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<GameState>();
state.Phase.Should().Be(GamePhase.PlayPhase);
state.PlayerCount.Should().Be(2);
state.RoundNumber.Should().Be(1);
GameUtil.FindEntity<PublicDeck>(world).Should().NotBe(Entity.Null);
GameUtil.FindAllEntities<Player>(world).Should().HaveCount(2);
GameUtil.FindAllEntities<Leader>(world).Should().HaveCount(2);
GameUtil.FindAllEntities<Banner>(world).Should().HaveCount(2);
GameUtil.FindAllEntities<Castle>(world).Should().NotBeEmpty();
}
[Fact]
public void StartingHand_HasThreeCards()
{
var (world, _) = Setup();
foreach (var player in GameUtil.FindAllEntities<Player>(world))
{
var hand = GameUtil.GetHandCards(world, player);
hand.Should().HaveCount(3);
}
}
[Fact]
public void PlayCard_MovesCardFromHandToField()
{
var (world, group) = Setup();
var state = world.ReadSingleton<GameState>();
var player = PlayCardCommand.FindPlayerByIndex(world, state.CurrentPlayerIndex);
var hand = GameUtil.GetHandCards(world, player);
var cardInHand = hand[0];
var def = world.ReadComponent<CardDef>(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<Card>(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<GameState>();
var player = PlayCardCommand.FindPlayerByIndex(world, state.CurrentPlayerIndex);
var hand = GameUtil.GetHandCards(world, player);
var cardInHand = hand[0];
var def = world.ReadComponent<CardDef>(cardInHand);
world.Commands.Enqueue(new PlayCardCommand
{
CardEntity = cardInHand,
Rank = def.Ranks[0],
FaceDown = true,
TargetPlayer = null
});
group.RunLogical();
world.ReadComponent<Card>(cardInHand).FaceDown.Should().BeTrue();
}
[Fact]
public void FlipCard_RevealsFaceDownCard()
{
var (world, group) = Setup();
var state = world.ReadSingleton<GameState>();
var player = PlayCardCommand.FindPlayerByIndex(world, state.CurrentPlayerIndex);
var hand = GameUtil.GetHandCards(world, player);
var cardInHand = hand[0];
var def = world.ReadComponent<CardDef>(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<GameState>().Phase.Should().Be(GamePhase.FlipPhase);
// Flip it.
world.Commands.Enqueue(new FlipCardCommand { CardEntity = cardInHand });
group.RunLogical();
world.ReadComponent<Card>(cardInHand).FaceDown.Should().BeFalse();
}
[Fact]
public void SkipPlay_AdvancesTurn()
{
var (world, group) = Setup();
var state = world.ReadSingleton<GameState>();
int originalPlayer = state.CurrentPlayerIndex;
world.Commands.Enqueue(new SkipPlayCommand());
group.RunLogical();
state = world.ReadSingleton<GameState>();
state.CurrentPlayerIndex.Should().Be((originalPlayer + 1) % 2);
}
}

View File

@ -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<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);
}

View File

@ -0,0 +1,113 @@
using OECS;
namespace Game.CardWars;
/// <summary>
/// Loads card definitions from a CSV and creates card entities in the world.
/// </summary>
public static class CardDataLoader
{
/// <summary>
/// Creates all card definition entities in the world from CSV data.
/// Returns a list of (entity, CardDef) pairs.
/// </summary>
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<string>();
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
};
}
}

View File

@ -0,0 +1,96 @@
using OECS;
namespace Game.CardWars;
/// <summary>
/// A card effect that can be registered with the effect dispatcher.
/// </summary>
public interface ICardEffect
{
CardEffect Effect { get; }
/// <summary>
/// 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.
/// </summary>
bool Resolve(World world, Entity card, Entity player, Entity owner);
/// <summary>
/// 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.
/// </summary>
bool ResolveChoice(World world, Entity target);
}
/// <summary>
/// Registry of card effects, ordered by registration.
/// </summary>
public static class CardEffectRegistry
{
private static readonly List<ICardEffect> _effects = new();
private static bool _frozen;
public static void Add<TEffect>() 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;
/// <summary>
/// Resolve a card effect. Returns true if fully resolved.
/// </summary>
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;
}
/// <summary>
/// Look up the handler for the currently pending effect and resolve it.
/// </summary>
public static bool DispatchChoice(World world, Entity target)
{
// Find which pending component is active and dispatch to the matching handler.
if (world.HasSingleton<PendingMercenary>())
return DispatchByEffect(world, CardEffect.Mercenary, target);
if (world.HasSingleton<PendingDancer>())
return DispatchByEffect(world, CardEffect.Dancer, target);
if (world.HasSingleton<PendingPaladin>())
return DispatchByEffect(world, CardEffect.Paladin, target);
if (world.HasSingleton<PendingNun>())
return DispatchByEffect(world, CardEffect.Nun, target);
if (world.HasSingleton<PendingScout>())
return DispatchByEffect(world, CardEffect.Scout, target);
if (world.HasSingleton<PendingPegasus>())
return DispatchByEffect(world, CardEffect.Pegasus, target);
if (world.HasSingleton<PendingCurseMaster>())
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;
}
}

View File

@ -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<CardDef>(c)
&& world.ReadComponent<CardDef>(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<CardDef>(c)
&& world.ReadComponent<CardDef>(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<PendingMercenary>();
if (target != Entity.Null)
CardEffectHelpers.AddSkull(world, target);
world.RemoveSingleton<PendingMercenary>();
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<CardDef>(c)
&& world.ReadComponent<CardDef>(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<Card>(c) && world.ReadComponent<Card>(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<PendingDancer>();
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<PendingDancer>();
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<PendingPaladin>();
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<PendingNun>();
if (target != Entity.Null)
{
var discardDeck = CardEffectHelpers.GetDiscardDeck(world, pending.Player);
world.RemoveComponent<OnField>(target);
if (world.HasComponent<Card>(target))
world.RemoveComponent<Card>(target);
world.AddComponent(target, new InDeck { Source = target, Target = discardDeck });
}
world.Commands.Enqueue(new DrawCardCommand { Player = pending.Player, Kind = null });
world.RemoveSingleton<PendingNun>();
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<PendingScout>();
if (target != Entity.Null && world.HasComponent<Player>(target))
{
foreach (var c in GameUtil.GetFieldCards(world, target))
{
if (world.TryGetComponent<Card>(c, out var cd) && cd.FaceDown)
{
ref var cardRef = ref world.GetComponent<Card>(c);
cardRef.FaceDown = false;
world.MarkModified<Card>(c);
}
}
}
world.RemoveSingleton<PendingScout>();
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<PendingPegasus>();
if (target != Entity.Null && target != pending.CardEntity)
{
world.RemoveComponent<OnField>(target);
if (world.HasComponent<Card>(target))
world.RemoveComponent<Card>(target);
world.AddComponent(target, new HeldBy { Source = target, Target = pending.Player });
}
world.RemoveSingleton<PendingPegasus>();
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<PendingCurseMaster>();
if (target != Entity.Null && world.HasComponent<Horn>(target))
{
var placedTargets = world.GetSources<PlacedOn>(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<PendingCurseMaster>();
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<Player>(world))
{
foreach (var c in GameUtil.GetFieldCards(world, p))
{
if (world.TryGetComponent<Card>(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<Banner>();
while (iter.MoveNext())
{
if (iter.CurrentEntity == World.SingletonEntity) continue;
var onField = world.GetSources<OnField>(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<FactionDeck>();
while (iter.MoveNext())
{
if (iter.CurrentEntity != World.SingletonEntity)
return iter.CurrentEntity;
}
return Entity.Null;
}
}

View File

@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Library</OutputType>
<RootNamespace>Game.CardWars</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\OECS\OECS.csproj" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\OECS.SourceGen\OECS.SourceGen.csproj"
OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
</ItemGroup>
</Project>

View File

@ -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<GameState>();
if (state.Phase != GamePhase.PlayPhase) return;
ref var mutable = ref world.GetSingleton<GameState>();
mutable.PlayPhaseEnded = true;
world.MarkModified<GameState>(World.SingletonEntity);
}
}
[MessagePackObject]
public struct FlipCardCommand : ICommand
{
[Key(0)] public Entity CardEntity;
public void Execute(World world)
{
var state = world.ReadSingleton<GameState>();
if (state.Phase != GamePhase.FlipPhase) return;
if (!world.IsAlive(CardEntity)) return;
if (!world.HasComponent<Card>(CardEntity)) return;
ref var card = ref world.GetComponent<Card>(CardEntity);
if (!card.FaceDown) return;
card.FaceDown = false;
world.MarkModified<Card>(CardEntity);
var def = world.ReadComponent<CardDef>(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<GameState>();
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<Card>(c) && world.ReadComponent<Card>(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<InDeck>(deck);
if (cards.Count == 0)
{
// Recycle: for now, faction discards recycle to the same deck.
GameUtil.RecycleDiscard(world, deck, deck);
cards = world.GetSources<InDeck>(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<PublicDeck>();
while (iter.MoveNext())
{
if (iter.CurrentEntity != World.SingletonEntity)
return iter.CurrentEntity;
}
}
else
{
using var iter = world.Select<FactionDeck>();
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<GameState>();
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<GameState>();
var castles = GameUtil.FindAllEntities<Castle>(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] });
}
}
}
/// <summary>
/// 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
/// </summary>
[MessagePackObject]
public struct ResolveChoiceCommand : ICommand
{
[Key(0)] public Entity Target;
public void Execute(World world)
{
if (!PendingChoice.Any(world)) return;
CardEffectRegistry.DispatchChoice(world, Target);
}
}

View File

@ -0,0 +1,28 @@
using MessagePack;
using OECS;
namespace Game.CardWars;
/// <summary>
/// Initializes a fresh game with the given player count and seed.
/// </summary>
[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
});
}
}

View File

@ -0,0 +1,62 @@
using MessagePack;
using OECS;
namespace Game.CardWars;
/// <summary>
/// Play a card from hand to the field, choosing a rank.
/// Resolves on-play effects via the CardEffectRegistry.
/// </summary>
[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<GameState>();
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<HeldBy>(CardEntity)) return;
var heldBy = world.ReadComponent<HeldBy>(CardEntity);
if (heldBy.Target != player) return;
var def = world.ReadComponent<CardDef>(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<HeldBy>(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<GameState>(World.SingletonEntity);
}
public static Entity FindPlayerByIndex(World world, int index)
{
using var iter = world.Select<Player>();
while (iter.MoveNext())
{
if (iter.CurrentEntity == World.SingletonEntity) continue;
if (iter.Current1.Index == index)
return iter.CurrentEntity;
}
return Entity.Null;
}
}

View File

@ -0,0 +1,9 @@
using MessagePack;
namespace Game.CardWars;
/// <summary>
/// Tag component for a player's banner entity.
/// </summary>
[MessagePackObject]
public struct Banner { }

View File

@ -0,0 +1,17 @@
using MessagePack;
namespace Game.CardWars;
/// <summary>
/// Instance data for a card that has been played to the field.
/// Cards in hand/deck only have CardDef; this is added when played.
/// </summary>
[MessagePackObject]
public record struct Card
{
/// <summary>The rank chosen for this play (one of CardDef.Ranks).</summary>
[Key(0)] public int Rank;
/// <summary>Whether the card is face-down on the field.</summary>
[Key(1)] public bool FaceDown;
}

View File

@ -0,0 +1,17 @@
using MessagePack;
namespace Game.CardWars;
/// <summary>
/// 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.
/// </summary>
[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;
}

View File

@ -0,0 +1,12 @@
using MessagePack;
namespace Game.CardWars;
/// <summary>
/// A castle that can be won each round.
/// </summary>
[MessagePackObject]
public record struct Castle
{
[Key(0)] public CastleColor Color;
}

View File

@ -0,0 +1,9 @@
using MessagePack;
namespace Game.CardWars;
/// <summary>
/// Tag component for a faction deck entity.
/// </summary>
[MessagePackObject]
public struct FactionDeck { }

View File

@ -0,0 +1,15 @@
using MessagePack;
using OECS;
namespace Game.CardWars;
/// <summary>
/// Relationship from a card entity to the player who holds it in hand.
/// Added to the card: Source=card, Target=player.
/// </summary>
[MessagePackObject]
public record struct HeldBy : IRelationship
{
[Key(0)] public Entity Source { get; set; }
[Key(1)] public Entity Target { get; set; }
}

View File

@ -0,0 +1,9 @@
using MessagePack;
namespace Game.CardWars;
/// <summary>
/// Tag component for a horn token. Can be placed on cards, banners, or leaders.
/// </summary>
[MessagePackObject]
public struct Horn { }

View File

@ -0,0 +1,14 @@
using MessagePack;
using OECS;
namespace Game.CardWars;
/// <summary>
/// Relationship from a card entity to a deck entity.
/// </summary>
[MessagePackObject]
public record struct InDeck : IRelationship
{
[Key(0)] public Entity Source { get; set; }
[Key(1)] public Entity Target { get; set; }
}

View File

@ -0,0 +1,10 @@
using MessagePack;
namespace Game.CardWars;
/// <summary>
/// Tag component marking a card entity as the leader card.
/// The leader is always on the field and contributes its rank to power.
/// </summary>
[MessagePackObject]
public struct Leader { }

View File

@ -0,0 +1,14 @@
using MessagePack;
using OECS;
namespace Game.CardWars;
/// <summary>
/// Relationship from a card entity to the player entity whose field it's on.
/// </summary>
[MessagePackObject]
public record struct OnField : IRelationship
{
[Key(0)] public Entity Source { get; set; }
[Key(1)] public Entity Target { get; set; }
}

View File

@ -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.
// ────────────────────────────────────────────────────────────
/// <summary>Mercenary: choose a combatant to give a skull.</summary>
[MessagePackObject]
public record struct PendingMercenary
{
[Key(0)] public Entity CardEntity;
[Key(1)] public Entity Player;
}
/// <summary>Dancer: choose up to 2 combatants for horn. Step tracks progress.</summary>
[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
}
/// <summary>Paladin: choose a combatant or banner for horn.</summary>
[MessagePackObject]
public record struct PendingPaladin
{
[Key(0)] public Entity CardEntity;
[Key(1)] public Entity Player;
}
/// <summary>Nun: choose a field card to discard.</summary>
[MessagePackObject]
public record struct PendingNun
{
[Key(0)] public Entity CardEntity;
[Key(1)] public Entity Player;
}
/// <summary>Scout: choose an opponent player to scout.</summary>
[MessagePackObject]
public record struct PendingScout
{
[Key(0)] public Entity CardEntity;
[Key(1)] public Entity Player;
}
/// <summary>Pegasus: choose a field card to return to hand (or null to pass).</summary>
[MessagePackObject]
public record struct PendingPegasus
{
[Key(0)] public Entity CardEntity;
[Key(1)] public Entity Player;
}
/// <summary>CurseMaster: choose a horn token to turn into skull.</summary>
[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
{
/// <summary>Returns true if any pending choice component exists on the singleton.</summary>
public static bool Any(World world)
{
return world.HasSingleton<PendingMercenary>()
|| world.HasSingleton<PendingDancer>()
|| world.HasSingleton<PendingPaladin>()
|| world.HasSingleton<PendingNun>()
|| world.HasSingleton<PendingScout>()
|| world.HasSingleton<PendingPegasus>()
|| world.HasSingleton<PendingCurseMaster>();
}
}

View File

@ -0,0 +1,14 @@
using MessagePack;
namespace Game.CardWars;
/// <summary>
/// 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.
/// </summary>
[MessagePackObject]
public struct PendingDuelist
{
[Key(0)] public int PlayerIndex;
}

View File

@ -0,0 +1,14 @@
using MessagePack;
using OECS;
namespace Game.CardWars;
/// <summary>
/// Relationship from a horn/skull token entity to the target entity it's placed on.
/// </summary>
[MessagePackObject]
public record struct PlacedOn : IRelationship
{
[Key(0)] public Entity Source { get; set; }
[Key(1)] public Entity Target { get; set; }
}

View File

@ -0,0 +1,12 @@
using MessagePack;
namespace Game.CardWars;
/// <summary>
/// Tag + index for a player entity.
/// </summary>
[MessagePackObject]
public record struct Player
{
[Key(0)] public int Index;
}

View File

@ -0,0 +1,9 @@
using MessagePack;
namespace Game.CardWars;
/// <summary>
/// Tag component for the public deck entity.
/// </summary>
[MessagePackObject]
public struct PublicDeck { }

View File

@ -0,0 +1,9 @@
using MessagePack;
namespace Game.CardWars;
/// <summary>
/// Tag component for a skull token. Can be placed on cards, banners, or leaders.
/// </summary>
[MessagePackObject]
public struct Skull { }

52
Game.CardWars/Enums.cs Normal file
View File

@ -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
}

View File

@ -0,0 +1,51 @@
using OECS;
namespace Game.CardWars;
/// <summary>
/// Factory for creating a fully configured game world and system group.
/// </summary>
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);
}
}

162
Game.CardWars/GameUtil.cs Normal file
View File

@ -0,0 +1,162 @@
using OECS;
namespace Game.CardWars;
/// <summary>
/// Shared utility methods for querying and manipulating the game world.
/// </summary>
public static class GameUtil
{
public static Entity FindEntity<T>(World world) where T : struct
{
using var iter = world.Select<T>();
while (iter.MoveNext())
{
if (iter.CurrentEntity != World.SingletonEntity)
return iter.CurrentEntity;
}
return Entity.Null;
}
public static List<Entity> FindAllEntities<T>(World world) where T : struct
{
var list = new List<Entity>();
using var iter = world.Select<T>();
while (iter.MoveNext())
{
if (iter.CurrentEntity != World.SingletonEntity)
list.Add(iter.CurrentEntity);
}
return list;
}
/// <summary>Draw the top card from a deck and add it to the player's hand.</summary>
public static bool DrawCard(World world, Entity deck, Entity player)
{
var cards = world.GetSources<InDeck>(deck);
if (cards.Count == 0) return false;
var cardEntity = cards.First();
world.RemoveComponent<InDeck>(cardEntity);
world.AddComponent(cardEntity, new HeldBy { Source = cardEntity, Target = player });
return true;
}
/// <summary>Shuffle cards in a deck using Fisher-Yates with the given seed.</summary>
public static void ShuffleDeck(World world, Entity deck, ref uint seed)
{
var cards = world.GetSources<InDeck>(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<InDeck>(cards[i]);
for (int i = 0; i < cards.Length; i++)
world.AddComponent(cards[i], new InDeck { Source = cards[i], Target = deck });
}
/// <summary>Get all field cards for a player.</summary>
public static List<Entity> GetFieldCards(World world, Entity player)
{
return world.GetSources<OnField>(player).ToList();
}
/// <summary>Get all cards in a player's hand.</summary>
public static List<Entity> GetHandCards(World world, Entity player)
{
return world.GetSources<HeldBy>(player).ToList();
}
/// <summary>Calculate a player's total battle power.</summary>
public static int CalculatePower(World world, Entity player)
{
int total = 0;
// Leader power.
using var leaderIter = world.Select<Leader>();
while (leaderIter.MoveNext())
{
if (leaderIter.CurrentEntity == World.SingletonEntity) continue;
var onFieldSources = world.GetSources<OnField>(player);
if (onFieldSources.Contains(leaderIter.CurrentEntity))
{
if (world.TryGetComponent<Card>(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<Horn>(banner);
bool bannerHasSkull = banner != Entity.Null && world.HasComponent<Skull>(banner);
// Field cards.
var fieldCards = GetFieldCards(world, player);
int poweredCount = 0;
foreach (var card in fieldCards)
{
if (!world.TryGetComponent<Card>(card, out var cardData)) continue;
if (cardData.FaceDown) continue;
if (cardData.Rank <= 0) continue;
poweredCount++;
int rank = cardData.Rank;
if (world.HasComponent<Horn>(card))
rank *= 2;
if (world.HasComponent<Skull>(card))
rank = 0;
total += rank;
}
if (bannerHasHorn) total += poweredCount;
if (bannerHasSkull) total -= poweredCount;
return total;
}
/// <summary>Move all field cards (except leaders and banners) to the discard pile.</summary>
public static void DiscardField(World world, Entity player, Entity discardDeck)
{
var fieldCards = GetFieldCards(world, player);
var toDiscard = new List<Entity>();
foreach (var card in fieldCards)
{
// Keep leaders and banners on the field.
if (world.HasComponent<Leader>(card)) continue;
if (world.HasComponent<Banner>(card)) continue;
toDiscard.Add(card);
}
foreach (var card in toDiscard)
{
world.RemoveComponent<OnField>(card);
world.RemoveComponent<Card>(card);
world.AddComponent(card, new InDeck { Source = card, Target = discardDeck });
}
// Destroy horns and skulls placed on discarded cards.
var tokenTargets = new HashSet<Entity>();
foreach (var card in toDiscard)
{
foreach (var token in world.GetSources<PlacedOn>(card))
tokenTargets.Add(token);
}
foreach (var token in tokenTargets)
world.DestroyEntity(token);
}
/// <summary>Recycle discard pile into draw pile.</summary>
public static void RecycleDiscard(World world, Entity drawDeck, Entity discardDeck)
{
var cards = world.GetSources<InDeck>(discardDeck).ToList();
foreach (var card in cards)
{
world.RemoveComponent<InDeck>(card);
world.AddComponent(card, new InDeck { Source = card, Target = drawDeck });
}
}
}

View File

@ -0,0 +1,21 @@
namespace Game.CardWars;
/// <summary>
/// Mulberry32 PRNG — a fast, high-quality 32-bit random number generator.
/// </summary>
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;
}
}

View File

@ -0,0 +1,18 @@
using MessagePack;
namespace Game.CardWars;
/// <summary>
/// Global game state on the singleton entity.
/// </summary>
[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;
}

View File

@ -0,0 +1,14 @@
using MessagePack;
namespace Game.CardWars;
/// <summary>
/// Per-player score tracking.
/// </summary>
[MessagePackObject]
public record struct PlayerScores
{
[Key(0)] public int[] Scores;
[Key(1)] public CastleColor[] WonCastles;
[Key(2)] public int Count;
}

View File

@ -0,0 +1,66 @@
using OECS;
namespace Game.CardWars;
/// <summary>
/// Discards field cards, clears tokens, draws new hands, checks game over.
/// </summary>
public class CleanupSystem : ISystem
{
public void Run(World world)
{
var state = world.ReadSingleton<GameState>();
if (state.Phase != GamePhase.Cleanup) return;
ref var mutable = ref world.GetSingleton<GameState>();
var players = GameUtil.FindAllEntities<Player>(world);
var publicDeck = GameUtil.FindEntity<PublicDeck>(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<PlacedOn>(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<HeldBy>(player)
.Where(c => world.HasComponent<Castle>(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<GameState>(World.SingletonEntity);
}
}

View File

@ -0,0 +1,20 @@
using OECS;
namespace Game.CardWars;
/// <summary>
/// Manages the flip phase: players take turns flipping cards.
/// Blocks if a PendingChoice exists.
/// </summary>
public class FlipPhaseSystem : ISystem
{
public void Run(World world)
{
var state = world.ReadSingleton<GameState>();
if (state.Phase != GamePhase.FlipPhase) return;
// Block if a pending choice exists.
if (PendingChoice.Any(world))
return;
}
}

View File

@ -0,0 +1,104 @@
using OECS;
namespace Game.CardWars;
/// <summary>
/// One-time setup: creates players, leaders, banners, decks, castles, and deals starting hands.
/// </summary>
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<GameState>();
ref var mutable = ref world.GetSingleton<GameState>();
mutable.PlayerCount = _playerCount;
mutable.Phase = GamePhase.PlayPhase;
mutable.CurrentPlayerIndex = 0;
mutable.StartingPlayerIndex = 0;
mutable.RoundNumber = 1;
world.MarkModified<GameState>(World.SingletonEntity);
// Register card effects.
CardEffectRegistry.Add<WarriorEffect>();
CardEffectRegistry.Add<ArcherEffect>();
CardEffectRegistry.Add<MercenaryEffect>();
CardEffectRegistry.Add<MerchantEffect>();
CardEffectRegistry.Add<DancerEffect>();
CardEffectRegistry.Add<PaladinEffect>();
CardEffectRegistry.Add<PrincessEffect>();
CardEffectRegistry.Add<DuelistEffect>();
CardEffectRegistry.Add<NunEffect>();
CardEffectRegistry.Add<ScoutEffect>();
CardEffectRegistry.Add<WitchEffect>();
CardEffectRegistry.Add<ThiefEffect>();
CardEffectRegistry.Add<PegasusEffect>();
CardEffectRegistry.Add<CurseMasterEffect>();
CardEffectRegistry.Add<MageEffect>();
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<Player>(world))
{
for (int i = 0; i < 3; i++)
GameUtil.DrawCard(world, publicDeck, player);
}
mutable.Seed = seed;
world.MarkModified<GameState>(World.SingletonEntity);
}
}

View File

@ -0,0 +1,58 @@
using OECS;
namespace Game.CardWars;
/// <summary>
/// Manages the play phase: players take turns. If a PendingChoice exists,
/// blocks advancement until the player resolves it.
/// </summary>
public class PlayPhaseSystem : ISystem
{
public void Run(World world)
{
ref var state = ref world.GetSingleton<GameState>();
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<PendingDuelist>())
{
var duelist = world.ReadSingleton<PendingDuelist>();
if (next == duelist.PlayerIndex)
{
world.RemoveSingleton<PendingDuelist>();
state.Phase = GamePhase.FlipPhase;
state.CurrentPlayerIndex = state.StartingPlayerIndex;
world.MarkModified<GameState>(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<PendingDuelist>(); // Clean up in case duelist was never triggered.
}
world.MarkModified<GameState>(World.SingletonEntity);
}
}

View File

@ -0,0 +1,59 @@
using OECS;
namespace Game.CardWars;
/// <summary>
/// Calculates each player's battle power, awards the castle to the winner,
/// and checks for game end.
/// </summary>
public class ScoringSystem : ISystem
{
public void Run(World world)
{
var state = world.ReadSingleton<GameState>();
if (state.Phase != GamePhase.Scoring) return;
ref var mutable = ref world.GetSingleton<GameState>();
var players = GameUtil.FindAllEntities<Player>(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<Castle>(world);
if (castles.Count > 0)
{
var castle = castles[0];
world.AddComponent(castle, new HeldBy { Source = castle, Target = winner });
world.RemoveComponent<Castle>(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<GameState>(World.SingletonEntity);
}
}

3
Game.CardWars/design.md Normal file
View File

@ -0,0 +1,3 @@
# CardWars — Design Document
<!-- Fill in the rules, mechanics, and design notes here. -->

View File

@ -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,7 +90,6 @@ 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
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
@ -111,6 +114,30 @@ Global
{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
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE