using Vanguard.Rules.Cards; using Vanguard.Rules.Engine; using Xunit; namespace Vanguard.Rules.Tests; public class GameEngineTests { private readonly CardCatalog _catalog = TestCatalog.Instance; private const string Barcgal = "BT01/003EN"; // grade 0, без триггера — стартовый Авангард private const string Marron = "BT01/042EN"; // grade 1, 8000 private const string Gallatin = "BT01/021EN"; // grade 2, 10000 private const string Alfred = "BT01/001EN"; // grade 3 private const string Nike = "BT01/056EN"; // Critical private const string Flogal = "BT01/046EN"; // Stand private const string Govannon = "BT01/045EN"; // Draw [Fact] public void Start_Places_Starting_Vanguard_And_Draws_Five() { var engine = NewEngine(); engine.Start(); var player = engine.State.Players[0]; Assert.NotNull(player.Vanguard); Assert.Equal(0, _catalog.CardOf(player.Vanguard!.PrintingId).Grade); Assert.Equal(TriggerType.None, _catalog.CardOf(player.Vanguard.PrintingId).Trigger); Assert.Equal(GameEngine.StartingHand, player.Hand.Count); Assert.Equal(44, player.Deck.Count); Assert.Equal(Phase.Mulligan, engine.State.Phase); } [Fact] public void Ride_Rejects_Grade_Jump() { var engine = StartedEngine(); Give(engine, 0, Alfred); var result = engine.Execute(new RideCommand(Alfred)); Assert.False(result.Accepted); Assert.Equal(Barcgal, engine.State.Players[0].Vanguard!.PrintingId); } [Fact] public void Ride_Moves_Previous_Vanguard_To_Soul() { var engine = StartedEngine(); Give(engine, 0, Marron); var result = engine.Execute(new RideCommand(Marron)); Assert.True(result.Accepted); Assert.Equal(Marron, engine.State.Players[0].Vanguard!.PrintingId); Assert.Contains(engine.State.Players[0].Soul, c => c.PrintingId == Barcgal); Assert.Equal(Phase.Main, engine.State.Phase); } [Fact] public void Called_RearGuard_Enters_Rested_And_Slot_Is_Busy() { var engine = InMainPhase(); Give(engine, 0, Marron); Assert.True(engine.Execute(new CallCommand(Marron, 0)).Accepted); Assert.True(engine.State.Players[0].RearGuards[0]!.Rested); Give(engine, 0, Marron); var second = engine.Execute(new CallCommand(Marron, 0)); Assert.False(second.Accepted); } [Fact] public void Attack_Without_Guard_Deals_Damage() { var engine = ReadyToAttack(); StackDeck(engine.State.Players[0], Gallatin, Gallatin); Assert.True(engine.Execute(new AttackCommand(-1, -1)).Accepted); Assert.True(engine.Execute(new PassGuardCommand()).Accepted); Assert.Single(engine.State.Players[1].Damage); Assert.Equal(Step.None, engine.State.Step); } [Fact] public void Guard_Prevents_Damage() { var engine = ReadyToAttack(); StackDeck(engine.State.Players[0], Gallatin, Gallatin); Give(engine, 1, Barcgal); Assert.True(engine.Execute(new AttackCommand(-1, -1)).Accepted); Assert.True(engine.Execute(new GuardCommand(Barcgal)).Accepted); Assert.True(engine.Execute(new PassGuardCommand()).Accepted); Assert.Empty(engine.State.Players[1].Damage); Assert.Single(engine.State.Players[1].Drop); } [Fact] public void Sixth_Damage_Ends_The_Game() { var engine = ReadyToAttack(); StackDeck(engine.State.Players[0], Gallatin, Gallatin); var defender = engine.State.Players[1]; for (var i = 0; i < GameEngine.MaxDamage - 1; i++) defender.Damage.Add(TakeAnyFromDeck(defender)); Assert.True(engine.Execute(new AttackCommand(-1, -1)).Accepted); Assert.True(engine.Execute(new PassGuardCommand()).Accepted); Assert.True(engine.State.IsFinished); Assert.Equal(0, engine.State.Winner); Assert.Contains(engine.Events, e => e is GameFinishedEvent); } [Fact] public void Critical_Trigger_Grants_Power_And_Crit_To_Chosen_Unit() { var engine = ReadyToAttack(); StackDeck(engine.State.Players[0], Nike, Gallatin); Assert.True(engine.Execute(new AttackCommand(-1, -1)).Accepted); Assert.True(engine.Execute(new PassGuardCommand()).Accepted); Assert.NotNull(engine.State.PendingTrigger); Assert.Equal(Step.Trigger, engine.State.Step); Assert.True(engine.Execute(new ResolveTriggerCommand(-1, -1)).Accepted); Assert.Null(engine.State.PendingTrigger); Assert.Equal(2, engine.State.Players[1].Damage.Count); } [Fact] public void Stand_Trigger_Stands_Chosen_RearGuard() { var engine = ReadyToAttackWithRearGuard(); StackDeck(engine.State.Players[0], Flogal, Gallatin); Assert.True(engine.Execute(new AttackCommand(-1, -1)).Accepted); Assert.True(engine.Execute(new PassGuardCommand()).Accepted); Assert.NotNull(engine.State.PendingTrigger); var rearGuard = engine.State.Players[0].RearGuards[0]!; Assert.True(rearGuard.Rested); Assert.True(engine.Execute(new ResolveTriggerCommand(-1, 0)).Accepted); Assert.False(rearGuard.Rested); } [Fact] public void Damage_Trigger_Also_Asks_For_Targets_And_Applies_Effect() { var engine = ReadyToAttack(); StackDeck(engine.State.Players[0], Gallatin, Gallatin); StackDeck(engine.State.Players[1], Nike); Assert.True(engine.Execute(new AttackCommand(-1, -1)).Accepted); Assert.True(engine.Execute(new PassGuardCommand()).Accepted); var pending = engine.State.PendingTrigger; Assert.NotNull(pending); Assert.Equal(TriggerContext.DamageCheck, pending!.Context); // до разрешения карта лежит в зоне триггера, а не в уроне Assert.Empty(engine.State.Players[1].Damage); Assert.Contains(engine.State.Players[1].Trigger, c => c.PrintingId == Nike); Assert.True(engine.Execute(new ResolveTriggerCommand(-1, -1)).Accepted); Assert.Null(engine.State.PendingTrigger); Assert.Null(engine.State.Battle); Assert.Single(engine.State.Players[1].Damage); Assert.Empty(engine.State.Players[1].Trigger); Assert.Contains(engine.State.Players[1].Damage, c => c.PrintingId == Nike); Assert.Contains(engine.Events, e => e is TriggerAppliedEvent { Player: 1 }); } [Fact] public void Trigger_Power_Lasts_Until_End_Of_Turn() { var engine = ReadyToAttack(); StackDeck(engine.State.Players[0], Nike, Gallatin); engine.Execute(new AttackCommand(-1, -1)); engine.Execute(new PassGuardCommand()); engine.Execute(new ResolveTriggerCommand(-1, -1)); var vanguard = engine.State.Players[0].Vanguard!; Assert.Equal(GameEngine.TriggerPowerBonus, vanguard.TemporaryPower); Assert.True(engine.Execute(new EndTurnCommand()).Accepted); Assert.Equal(0, vanguard.TemporaryPower); } [Fact] public void Boost_Adds_Back_Row_Power_To_Attacker() { var engine = ReadyToBoost(); Assert.True(engine.Execute(new AttackCommand(3, -1, 0)).Accepted); Assert.True(engine.State.Players[0].RearGuards[0]!.Rested); Assert.True(engine.Execute(new PassGuardCommand()).Accepted); Assert.Single(engine.State.Players[1].Damage); Assert.Contains(engine.Events, e => e is BoostedEvent { BoosterSlot: 0, AttackerSlot: 3 }); } [Fact] public void Without_Boost_The_Same_Attack_Misses() { var engine = ReadyToBoost(); Assert.True(engine.Execute(new AttackCommand(3, -1)).Accepted); Assert.True(engine.Execute(new PassGuardCommand()).Accepted); Assert.Empty(engine.State.Players[1].Damage); Assert.False(engine.State.Players[0].RearGuards[0]!.Rested); } [Fact] public void Boost_Rejects_Wrong_Column() { var engine = ReadyToBoost(); var result = engine.Execute(new AttackCommand(3, -1, 2)); Assert.False(result.Accepted); } [Fact] public void Grade_Two_Cannot_Boost() { var engine = ReadyToBoost(); engine.State.Players[0].RearGuards[0] = TakeFromDeck(engine.State.Players[0], Gallatin); var result = engine.Execute(new AttackCommand(3, -1, 0)); Assert.False(result.Accepted); } [Fact] public void Effect_Can_Grant_Boost_To_Grade_Two() { var engine = ReadyToBoost(); var booster = TakeFromDeck(engine.State.Players[0], Gallatin); engine.State.Players[0].RearGuards[0] = booster; Assert.False(engine.Execute(new AttackCommand(3, -1, 0)).Accepted); booster.BoostOverride = true; Assert.True(engine.Execute(new AttackCommand(3, -1, 0)).Accepted); } [Fact] public void Effect_Can_Remove_Boost_From_Grade_One() { var engine = ReadyToBoost(); var booster = engine.State.Players[0].RearGuards[0]!; booster.BoostOverride = false; Assert.False(engine.Execute(new AttackCommand(3, -1, 0)).Accepted); } [Fact] public void ResolveTrigger_Rejects_When_Nothing_Is_Pending() { var engine = StartedEngine(); Assert.False(engine.Execute(new ResolveTriggerCommand(-1, -1)).Accepted); } [Fact] public void Same_Seed_And_Commands_Produce_Same_Events() { var first = RunScriptedMatch(seed: 12345); var second = RunScriptedMatch(seed: 12345); Assert.Equal(first, second); } private List RunScriptedMatch(ulong seed) { var engine = NewEngine(seed); engine.Start(); engine.Execute(new FinishMulliganCommand()); Give(engine, 0, Marron); engine.Execute(new RideCommand(Marron)); engine.Execute(new AttackCommand(-1, -1)); engine.Execute(new PassGuardCommand()); return engine.Events.ToList(); } // ---------- helpers ---------- private GameEngine NewEngine(ulong seed = 7) => new(_catalog, seed, BuildDeck(), BuildDeck()); private GameEngine StartedEngine() { var engine = NewEngine(); engine.Start(); engine.Execute(new FinishMulliganCommand()); return engine; } private GameEngine InMainPhase() { var engine = StartedEngine(); engine.Execute(new SkipRideCommand()); return engine; } private GameEngine ReadyToAttack() { var engine = StartedEngine(); Give(engine, 0, Marron); engine.Execute(new RideCommand(Marron)); engine.Execute(new EnterBattleCommand()); return engine; } private GameEngine ReadyToAttackWithRearGuard() { var engine = StartedEngine(); Give(engine, 0, Marron); engine.Execute(new RideCommand(Marron)); Give(engine, 0, Gallatin); engine.Execute(new CallCommand(Gallatin, 0)); engine.Execute(new EnterBattleCommand()); return engine; } private GameEngine ReadyToBoost() { var engine = StartedEngine(); Give(engine, 0, Marron); engine.Execute(new RideCommand(Marron)); // сзади слева — поддержка (1 ранг), впереди слева — атакующий (2 ранг) Give(engine, 0, Marron); engine.Execute(new CallCommand(Marron, 0)); Give(engine, 0, Gallatin); engine.Execute(new CallCommand(Gallatin, 3)); // призванные карты входят повёрнутыми — для теста поддержки поднимаем обе engine.State.Players[0].RearGuards[0]!.Rested = false; engine.State.Players[0].RearGuards[3]!.Rested = false; SetVanguard(engine, 1, Gallatin); engine.Execute(new EnterBattleCommand()); return engine; } private List BuildDeck() { var mustInclude = new[] { Barcgal, Marron, Gallatin, Alfred, Nike, Flogal, Govannon }; var names = mustInclude .Select(_catalog.CardOf) .Select(c => c.NameId) .ToHashSet(StringComparer.Ordinal); var deck = new List(); foreach (var printing in mustInclude) deck.AddRange(Enumerable.Repeat(printing, 4)); foreach (var group in _catalog.Printings .Where(p => !p.IsParallel) .GroupBy(p => p.NameId) .OrderBy(g => g.Key, StringComparer.Ordinal)) { if (deck.Count >= 50) break; if (names.Contains(group.Key)) continue; var card = _catalog.Card(group.Key); if (card.IsSentinel || card.Trigger == TriggerType.Heal) continue; var printingId = group.First().PrintingId; for (var i = 0; i < 4 && deck.Count < 50; i++) deck.Add(printingId); } return deck; } private static void Give(GameEngine engine, int player, string printingId) { var p = engine.State.Players[player]; var index = p.Deck.FindIndex(c => c.PrintingId == printingId); Assert.True(index >= 0, $"В колоде нет {printingId}"); var card = p.Deck[index]; p.Deck.RemoveAt(index); p.Hand.Add(card); } private static void StackDeck(PlayerState player, params string[] printingIds) { var cards = new List(); foreach (var printingId in printingIds) { var index = player.Deck.FindIndex(c => c.PrintingId == printingId); Assert.True(index >= 0, $"В колоде нет {printingId}"); cards.Add(player.Deck[index]); player.Deck.RemoveAt(index); } for (var i = cards.Count - 1; i >= 0; i--) player.Deck.Insert(0, cards[i]); } private static void SetVanguard(GameEngine engine, int player, string printingId) { var p = engine.State.Players[player]; var index = p.Deck.FindIndex(c => c.PrintingId == printingId); Assert.True(index >= 0, $"В колоде нет {printingId}"); var card = p.Deck[index]; p.Deck.RemoveAt(index); p.Vanguard = card; } private static CardInstance TakeFromDeck(PlayerState player, string printingId) { var index = player.Deck.FindIndex(c => c.PrintingId == printingId); Assert.True(index >= 0, $"В колоде нет {printingId}"); var card = player.Deck[index]; player.Deck.RemoveAt(index); return card; } private static CardInstance TakeAnyFromDeck(PlayerState player) { Assert.NotEmpty(player.Deck); var card = player.Deck[0]; player.Deck.RemoveAt(0); return card; } }