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 private const string Iseult = "BT01/011EN"; // Sentinel, Royal Paladin private const string Nehalem = "BT01/022EN"; // Kagero private const string Overlord = "BT01/004EN"; // Kagero, 11000 [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 Perfect_Guard_Nullifies_Attack_When_Cost_Paid() { var engine = ReadyToAttack(); StackDeck(engine.State.Players[0], Gallatin, Gallatin); Give(engine, 1, Iseult); Give(engine, 1, Marron); Assert.True(engine.Execute(new AttackCommand(-1, -1)).Accepted); Assert.True(engine.Execute(new GuardCommand(Iseult, Marron)).Accepted); Assert.True(engine.Execute(new PassGuardCommand()).Accepted); Assert.Empty(engine.State.Players[1].Damage); Assert.Contains(engine.State.Players[1].Drop, c => c.PrintingId == Marron); Assert.Contains(engine.Events, e => e is PerfectGuardEvent); } [Fact] public void Perfect_Guard_Without_Cost_Adds_No_Shield() { var engine = ReadyToAttack(); StackDeck(engine.State.Players[0], Gallatin, Gallatin); Give(engine, 1, Iseult); Assert.True(engine.Execute(new AttackCommand(-1, -1)).Accepted); Assert.True(engine.Execute(new GuardCommand(Iseult)).Accepted); Assert.True(engine.Execute(new PassGuardCommand()).Accepted); Assert.Single(engine.State.Players[1].Damage); Assert.DoesNotContain(engine.Events, e => e is PerfectGuardEvent); } [Fact] public void Perfect_Guard_Rejects_Wrong_Clan_Cost() { var engine = ReadyToAttack(); Give(engine, 1, Iseult); Give(engine, 1, Nehalem); Assert.True(engine.Execute(new AttackCommand(-1, -1)).Accepted); Assert.False(engine.Execute(new GuardCommand(Iseult, Nehalem)).Accepted); } [Fact] public void Intercept_Saves_Target_And_Retires_Interceptor() { var engine = ReadyToAttackRearGuard(); Assert.True(engine.Execute(new AttackCommand(3, 0)).Accepted); Assert.True(engine.Execute(new InterceptCommand(3)).Accepted); Assert.True(engine.Execute(new PassGuardCommand()).Accepted); var defender = engine.State.Players[1]; Assert.NotNull(defender.RearGuards[0]); // цель выжила Assert.Null(defender.RearGuards[3]); // перехватчик покинул круг Assert.Contains(defender.Drop, c => c.PrintingId == Gallatin); Assert.Contains(engine.Events, e => e is InterceptedEvent); Assert.Contains(engine.Events, e => e is BattleResolvedEvent { Hit: false }); } [Fact] public void Without_Intercept_RearGuard_Is_Retired() { var engine = ReadyToAttackRearGuard(); Assert.True(engine.Execute(new AttackCommand(3, 0)).Accepted); Assert.True(engine.Execute(new PassGuardCommand()).Accepted); var defender = engine.State.Players[1]; Assert.Null(defender.RearGuards[0]); Assert.Contains(defender.Drop, c => c.PrintingId == Marron); Assert.Contains(engine.Events, e => e is UnitRetiredEvent); } [Fact] public void Grade_One_Cannot_Intercept() { var engine = ReadyToAttackRearGuard(); var defender = engine.State.Players[1]; defender.RearGuards[3] = TakeFromDeck(defender, Marron); Assert.True(engine.Execute(new AttackCommand(3, 0)).Accepted); Assert.False(engine.Execute(new InterceptCommand(3)).Accepted); } [Fact] public void Back_Row_Cannot_Intercept_By_Default() { var engine = ReadyToAttackRearGuard(); var defender = engine.State.Players[1]; defender.RearGuards[1] = TakeFromDeck(defender, Gallatin); Assert.True(engine.Execute(new AttackCommand(3, 0)).Accepted); Assert.False(engine.Execute(new InterceptCommand(1)).Accepted); } [Fact] public void Effect_Can_Grant_Back_Row_Intercept() { var engine = ReadyToAttackRearGuard(); var defender = engine.State.Players[1]; var unit = TakeFromDeck(defender, Gallatin); unit.InterceptOverride = true; unit.InterceptFromBackRow = true; defender.RearGuards[1] = unit; Assert.True(engine.Execute(new AttackCommand(3, 0)).Accepted); Assert.True(engine.Execute(new InterceptCommand(1)).Accepted); } [Fact] public void Intercept_Works_Against_Vanguard_Attack() { var engine = StartedEngine(); engine.Execute(new SkipRideCommand()); SetVanguard(engine, 0, Overlord); StackDeck(engine.State.Players[0], Gallatin, Gallatin); engine.Execute(new EnterBattleCommand()); var defender = engine.State.Players[1]; defender.RearGuards[3] = TakeFromDeck(defender, Gallatin); Assert.True(engine.Execute(new AttackCommand(-1, -1)).Accepted); Assert.True(engine.Execute(new InterceptCommand(3)).Accepted); Assert.True(engine.Execute(new PassGuardCommand()).Accepted); Assert.Empty(defender.Damage); Assert.NotNull(defender.Vanguard); Assert.Contains(defender.Drop, c => c.PrintingId == Gallatin); Assert.Contains(engine.Events, e => e is InterceptorRetiredEvent); } [Fact] public void Attacked_RearGuard_Cannot_Intercept_Itself() { var engine = ReadyToAttackRearGuard(); Assert.True(engine.Execute(new AttackCommand(3, 0)).Accepted); Assert.False(engine.Execute(new InterceptCommand(0)).Accepted); } [Fact] public void Move_To_Empty_Circle_In_Same_Column() { var engine = InMainPhase(); Give(engine, 0, Marron); engine.Execute(new CallCommand(Marron, 0)); Assert.True(engine.Execute(new MoveCommand(0, 3)).Accepted); Assert.Null(engine.State.Players[0].RearGuards[0]); Assert.NotNull(engine.State.Players[0].RearGuards[3]); Assert.Contains(engine.Events, e => e is UnitMovedEvent { FromSlot: 0, ToSlot: 3 }); } [Fact] public void Move_Swaps_Occupied_Circles() { var engine = InMainPhase(); Give(engine, 0, Marron); engine.Execute(new CallCommand(Marron, 0)); Give(engine, 0, Gallatin); engine.Execute(new CallCommand(Gallatin, 3)); Assert.True(engine.Execute(new MoveCommand(0, 3)).Accepted); Assert.Equal(Marron, engine.State.Players[0].RearGuards[3]!.PrintingId); Assert.Equal(Gallatin, engine.State.Players[0].RearGuards[0]!.PrintingId); } [Fact] public void Move_Rejects_Other_Column() { var engine = InMainPhase(); Give(engine, 0, Marron); engine.Execute(new CallCommand(Marron, 0)); Assert.False(engine.Execute(new MoveCommand(0, 4)).Accepted); } [Fact] public void Center_Back_Row_Has_No_Pair() { var engine = InMainPhase(); Give(engine, 0, Marron); engine.Execute(new CallCommand(Marron, 1)); Assert.False(engine.Execute(new MoveCommand(1, 3)).Accepted); Assert.False(engine.Execute(new MoveCommand(1, 0)).Accepted); } [Fact] public void Move_Is_Available_Only_In_Main_Phase() { var engine = StartedEngine(); // фаза Смены Assert.False(engine.Execute(new MoveCommand(0, 3)).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 GameEngine ReadyToAttackRearGuard() { var engine = StartedEngine(); Give(engine, 0, Marron); engine.Execute(new RideCommand(Marron)); Give(engine, 0, Gallatin); engine.Execute(new CallCommand(Gallatin, 3)); engine.State.Players[0].RearGuards[3]!.Rested = false; var defender = engine.State.Players[1]; defender.RearGuards[0] = TakeFromDeck(defender, Marron); defender.RearGuards[3] = TakeFromDeck(defender, Gallatin); engine.Execute(new EnterBattleCommand()); return engine; } private List BuildDeck() { var mustInclude = new[] { Barcgal, Marron, Gallatin, Alfred, Nike, Flogal, Govannon, Iseult, Nehalem, Overlord }; 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; } }