VanguardReMaster/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs

2017 lines
70 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Text.Json;
using Vanguard.Rules.Cards;
using Vanguard.Rules.Engine;
using Vanguard.Rules.Engine.Abilities;
using Vanguard.Rules.Replay;
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
private const string Conroe = "BT01/016EN"; // Kagero, grade 0 без триггера
private const string BlasterBlade = "BT01/002EN";
private const string Wingal = "BT01/044EN";
private const string Lien = "BT01/043EN"; // activated: rest + discard → draw
private const string Cocoa = "BT01/007EN"; // OTT, смотрит верхнюю карту
private const string Libra = "BT01/017EN"; // OTT, grade 2
private const string Deathrex = "BT01/033EN"; // Tachikaze, 10000
private const string DemonEater = "BT01/036EN"; // Dark Irregulars, 10000
private const string Morgana = "TD01/011EN"; // Royal Paladin, grade 1, discard +4000
private const string Mocha = "BT01/018EN"; // OTT, +3000 при четырёх картах в руке
private const string HellSpider = "BT01/039EN"; // Megacolony, запрет вставать
private const string BrutalJack = "BT01/029EN"; // Nova Grappler, Сдерживание
private const string ToughBoy = "BT01/060EN"; // Nova Grappler, grade 1
private const string Asura = "BT01/008EN"; // Nova Grappler, grade 3
private const string Juggernaut = "BT01/020EN"; // Spike Brothers, возврат в колоду
private const string RockTheWall = "BT01/073EN";
private const string GuidingZombie = "BT01/078EN";
private const string Blightops = "BT01/034EN";
private const string MonsterFrank = "BT01/037EN";
private const string Shieldon = "BT01/067EN";
private const string Dka = "BT01/014EN";
private const string Bahr = "BT01/048EN";
private const string Tahr = "BT01/024EN";
private const string AlephVictory = "BT01/005EN";
private const string Gancelot = "BT01/010EN";
private const string Voidmaster = "BT01/035EN";
private const string BlasterBladePrint = "BT01/002EN";
private const string Bors = "TD01/002EN";
private const string Unicorn = "TD01/010EN";
private const string Goku = "TD02/002EN";
private const string Joka = "TD02/010EN";
private const string Tejas = "BT01/023EN";
private const string Yaksha = "TD02/003EN";
private const string Hagakure = "BT01/070EN";
private const string Chigasumi = "BT01/068EN";
[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(Conroe, 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 == Conroe);
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.Modifiers.Sum(m => m.Power));
Assert.True(engine.Execute(new EndTurnCommand()).Accepted);
Assert.Empty(vanguard.Modifiers);
}
[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, 4)).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[4]); // цель выжила
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, 4)).Accepted);
Assert.True(engine.Execute(new PassGuardCommand()).Accepted);
var defender = engine.State.Players[1];
Assert.Null(defender.RearGuards[4]);
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, 4)).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, 4)).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, 4)).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);
// второй Kagero, чтобы не срабатывал штраф −2000
engine.State.Players[0].RearGuards[0] = TakeFromDeck(engine.State.Players[0], Nehalem);
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, 4)).Accepted);
Assert.False(engine.Execute(new InterceptCommand(4)).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 Grade_One_Vanguard_Performs_One_Check()
{
var engine = ReadyToAttack();
StackDeck(engine.State.Players[0], Gallatin, Gallatin);
engine.Execute(new AttackCommand(-1, -1));
engine.Execute(new PassGuardCommand());
Assert.Single(engine.Events.OfType<DriveCheckedEvent>());
}
[Fact]
public void Grade_Three_Vanguard_Performs_Two_Checks()
{
var engine = StartedEngine();
engine.Execute(new SkipRideCommand());
SetVanguard(engine, 0, Overlord);
StackDeck(engine.State.Players[0], Gallatin, Gallatin);
engine.Execute(new EnterBattleCommand());
engine.Execute(new AttackCommand(-1, -1));
engine.Execute(new PassGuardCommand());
Assert.Equal(2, engine.Events.OfType<DriveCheckedEvent>().Count());
}
[Fact]
public void Twin_Drive_Can_Be_Removed_By_Effect()
{
var engine = StartedEngine();
engine.Execute(new SkipRideCommand());
SetVanguard(engine, 0, Overlord);
engine.State.Players[0].Vanguard!.DriveChecksOverride = 1;
StackDeck(engine.State.Players[0], Gallatin, Gallatin);
engine.Execute(new EnterBattleCommand());
engine.Execute(new AttackCommand(-1, -1));
engine.Execute(new PassGuardCommand());
Assert.Single(engine.Events.OfType<DriveCheckedEvent>());
}
[Fact]
public void Rear_Guard_Performs_No_Checks_By_Default()
{
var engine = ReadyToAttackRearGuard();
engine.Execute(new AttackCommand(3, 4));
engine.Execute(new PassGuardCommand());
Assert.Empty(engine.Events.OfType<DriveCheckedEvent>());
}
[Fact]
public void Effect_Can_Give_Drive_Check_To_Rear_Guard()
{
var engine = ReadyToAttackRearGuard();
engine.State.Players[0].RearGuards[3]!.DriveChecksOverride = 1;
StackDeck(engine.State.Players[0], Gallatin);
engine.Execute(new AttackCommand(3, 4));
engine.Execute(new PassGuardCommand());
Assert.Single(engine.Events.OfType<DriveCheckedEvent>());
}
[Fact]
public void Barcgal_Offers_Optional_Call_When_Ridden_Over()
{
var engine = StartedEngine();
SetVanguard(engine, 0, Barcgal);
Give(engine, 0, Marron);
Assert.True(engine.Execute(new RideCommand(Marron)).Accepted);
Assert.NotNull(engine.State.PendingAbility);
Assert.Equal(Step.Ability, engine.State.Step);
Assert.True(engine.Execute(new ResolveAbilityCommand(true)).Accepted);
Assert.Null(engine.State.PendingAbility);
Assert.Contains(engine.State.Players[0].RearGuards, u => u?.PrintingId == Barcgal);
Assert.DoesNotContain(engine.State.Players[0].Soul, c => c.PrintingId == Barcgal);
}
[Fact]
public void Barcgal_Decline_Keeps_Card_In_Soul()
{
var engine = StartedEngine();
SetVanguard(engine, 0, Barcgal);
Give(engine, 0, Marron);
engine.Execute(new RideCommand(Marron));
Assert.True(engine.Execute(new ResolveAbilityCommand(false)).Accepted);
Assert.Contains(engine.State.Players[0].Soul, c => c.PrintingId == Barcgal);
Assert.DoesNotContain(engine.State.Players[0].RearGuards, u => u?.PrintingId == Barcgal);
}
[Fact]
public void Wingal_Gives_BlasterBlade_4000_During_Battle()
{
var engine = ReadyToAttackWithWingal(BlasterBlade);
Assert.True(engine.Execute(new AttackCommand(3, -1, 0)).Accepted);
Assert.Equal(4000, engine.State.Players[0].RearGuards[3]!.Modifiers.Where(m => m.EffectId == "ability").Sum(m => m.Power));
}
[Fact]
public void Wingal_Does_Not_Boost_Other_Units()
{
var engine = ReadyToAttackWithWingal(Gallatin);
Assert.True(engine.Execute(new AttackCommand(3, -1, 0)).Accepted);
Assert.Equal(0, engine.State.Players[0].RearGuards[3]!.Modifiers.Where(m => m.EffectId == "ability").Sum(m => m.Power));
}
[Fact]
public void Alfred_Gets_2000_Per_RoyalPaladin_RearGuard()
{
var engine = StartedEngine();
engine.Execute(new SkipRideCommand());
SetVanguard(engine, 0, Alfred);
Give(engine, 0, Marron);
engine.Execute(new CallCommand(Marron, 0));
Give(engine, 0, Marron);
engine.Execute(new CallCommand(Marron, 3));
var alfred = engine.State.Players[0].Vanguard!;
Assert.Equal(14000, engine.EffectivePower(alfred));
}
[Fact]
public void Guard_Protects_RearGuard_From_Retirement()
{
var engine = ReadyToAttackRearGuard();
Give(engine, 1, Barcgal);
Assert.True(engine.Execute(new AttackCommand(3, 4)).Accepted);
Assert.True(engine.Execute(new GuardCommand(Barcgal)).Accepted);
Assert.True(engine.Execute(new PassGuardCommand()).Accepted);
var defender = engine.State.Players[1];
Assert.NotNull(defender.RearGuards[4]);
Assert.Contains(defender.Drop, c => c.PrintingId == Barcgal);
Assert.DoesNotContain(engine.Events, e => e is UnitRetiredEvent);
}
[Fact]
public void BlasterBlade_On_Vanguard_Retires_Chosen_RearGuard()
{
var engine = StartedEngine();
SetVanguard(engine, 0, Marron);
GiveDamage(engine, 0, 2);
var opponent = engine.State.Players[1];
opponent.RearGuards[0] = TakeFromDeck(opponent, Gallatin);
Give(engine, 0, BlasterBlade);
Assert.True(engine.Execute(new RideCommand(BlasterBlade)).Accepted);
Assert.NotNull(engine.State.PendingAbility);
Assert.True(engine.Execute(new ResolveAbilityCommand(true)).Accepted);
Assert.NotNull(engine.State.PendingChoice);
Assert.Single(engine.State.PendingChoice!.Options);
Assert.True(engine.Execute(new ChooseCommand(0)).Accepted);
Assert.Null(opponent.RearGuards[0]);
Assert.Equal(2, engine.State.Players[0].Damage.Count(c => c.FaceUp));
}
[Fact]
public void BlasterBlade_Is_Not_Offered_Without_Counter_Blast()
{
var engine = StartedEngine();
SetVanguard(engine, 0, Marron);
Give(engine, 0, BlasterBlade);
Assert.True(engine.Execute(new RideCommand(BlasterBlade)).Accepted);
Assert.Null(engine.State.PendingAbility);
}
[Fact]
public void BlasterBlade_On_RearGuard_Requires_RoyalPaladin_Vanguard()
{
var kagero = StartedEngine();
kagero.Execute(new SkipRideCommand());
Give(kagero, 0, BlasterBlade);
kagero.Execute(new CallCommand(BlasterBlade, 0));
Assert.Null(kagero.State.PendingAbility);
var royal = StartedEngine();
SetVanguard(royal, 0, Marron);
royal.Execute(new SkipRideCommand());
GiveDamage(royal, 0, 2);
Give(royal, 0, BlasterBlade);
royal.Execute(new CallCommand(BlasterBlade, 0));
Assert.NotNull(royal.State.PendingAbility);
}
[Fact]
public void Activated_Ability_Rests_Self_Discards_And_Draws()
{
var engine = InMainPhase();
Give(engine, 0, Lien);
engine.Execute(new CallCommand(Lien, 0));
var lien = engine.State.Players[0].RearGuards[0]!;
lien.Rested = false;
Give(engine, 0, Marron);
var handBefore = engine.State.Players[0].Hand.Count;
Assert.True(engine.Execute(new ActivateAbilityCommand(lien.Id, Marron)).Accepted);
Assert.True(lien.Rested);
Assert.Contains(engine.State.Players[0].Drop, c => c.PrintingId == Marron);
Assert.Equal(handBefore, engine.State.Players[0].Hand.Count); // -1 сброс, +1 добор
}
[Fact]
public void Activated_Ability_Requires_Discard()
{
var engine = InMainPhase();
Give(engine, 0, Lien);
engine.Execute(new CallCommand(Lien, 0));
var lien = engine.State.Players[0].RearGuards[0]!;
lien.Rested = false;
Assert.False(engine.Execute(new ActivateAbilityCommand(lien.Id)).Accepted);
}
[Fact]
public void Activated_Ability_Rejects_Rested_Card()
{
var engine = InMainPhase();
Give(engine, 0, Lien);
engine.Execute(new CallCommand(Lien, 0));
var lien = engine.State.Players[0].RearGuards[0]!;
Give(engine, 0, Marron);
Assert.False(engine.Execute(new ActivateAbilityCommand(lien.Id, Marron)).Accepted);
}
[Fact]
public void Search_Without_Matches_Only_Shuffles()
{
var engine = StartedEngine();
var handBefore = engine.State.Players[0].Hand.Count;
engine.SearchDeckToHand(0, "Карта X", d => d.NameId == "no-such-card");
Assert.Contains(engine.Events, e => e is SearchFailedEvent);
Assert.Null(engine.State.PendingChoice);
Assert.Equal(handBefore, engine.State.Players[0].Hand.Count);
}
[Fact]
public void Search_With_Single_Match_Puts_Card_Into_Hand()
{
var engine = StartedEngine();
KeepOnly(engine, 0, "wingal", 1);
engine.SearchDeckToHand(0, "Wingal", d => d.NameId == "wingal");
Assert.Null(engine.State.PendingChoice);
Assert.Contains(engine.State.Players[0].Hand, c => c.PrintingId == Wingal);
Assert.DoesNotContain(engine.State.Players[0].Deck, c => c.PrintingId == Wingal);
}
[Fact]
public void Search_With_Two_Arts_Offers_Two_Options()
{
var engine = StartedEngine();
KeepOnly(engine, 0, "blaster-blade", 1);
var parallel = new CardInstance { Id = 9001, PrintingId = "BT01/S02EN" };
engine.State.Players[0].Deck.Add(parallel);
engine.SearchDeckToHand(0, "Blaster Blade", d => d.NameId == "blaster-blade");
var choice = engine.State.PendingChoice;
Assert.NotNull(choice);
Assert.Equal(2, choice!.Options.Count);
Assert.True(engine.Execute(new ChooseCommand(1)).Accepted);
Assert.Contains(engine.State.Players[0].Hand, c => c.PrintingId == "BT01/S02EN");
}
[Fact]
public void Conroe_Retires_Itself_Pays_Counter_Blast_And_Searches()
{
var engine = InMainPhase();
Give(engine, 0, Conroe);
engine.Execute(new CallCommand(Conroe, 0));
var conroe = engine.State.Players[0].RearGuards[0]!;
conroe.Rested = false;
GiveDamage(engine, 0, 1);
Assert.True(engine.Execute(new ActivateAbilityCommand(conroe.Id)).Accepted);
Assert.Null(engine.State.Players[0].RearGuards[0]);
Assert.Contains(engine.State.Players[0].Drop, c => c.PrintingId == Conroe);
Assert.Equal(1, engine.State.Players[0].Damage.Count(c => c.FaceUp));
if (engine.State.PendingChoice is not null)
Assert.True(engine.Execute(new ChooseCommand(0)).Accepted);
var added = engine.State.Players[0].Hand[^1];
var def = _catalog.CardOf(added.PrintingId);
Assert.Equal(Clan.Kagero, def.Clan);
Assert.True(def.Grade <= 1);
}
[Fact]
public void Cocoa_Can_Keep_The_Top_Card_On_Top()
{
var engine = InMainPhase();
SetVanguard(engine, 0, Libra);
Give(engine, 0, Cocoa);
engine.Execute(new CallCommand(Cocoa, 0));
var choice = engine.State.PendingChoice;
Assert.NotNull(choice);
Assert.Equal(2, choice!.Options.Count);
var topBefore = engine.State.Players[0].Deck[0].PrintingId;
Assert.True(engine.Execute(new ChooseCommand(0)).Accepted);
Assert.Equal(topBefore, engine.State.Players[0].Deck[0].PrintingId);
}
[Fact]
public void Cocoa_Can_Put_The_Top_Card_On_Bottom()
{
var engine = InMainPhase();
SetVanguard(engine, 0, Libra);
Give(engine, 0, Cocoa);
engine.Execute(new CallCommand(Cocoa, 0));
var topBefore = engine.State.Players[0].Deck[0].PrintingId;
Assert.True(engine.Execute(new ChooseCommand(1)).Accepted);
Assert.Equal(topBefore, engine.State.Players[0].Deck[^1].PrintingId);
}
[Fact]
public void Alfred_Searches_And_Calls_A_RoyalPaladin()
{
var engine = StartedEngine();
engine.Execute(new SkipRideCommand());
SetVanguard(engine, 0, Alfred);
GiveDamage(engine, 0, 3);
var alfred = engine.State.Players[0].Vanguard!;
Assert.True(engine.Execute(new ActivateAbilityCommand(alfred.Id)).Accepted);
Assert.Equal(3, engine.State.Players[0].Damage.Count(c => c.FaceUp));
if (engine.State.PendingChoice is not null)
Assert.True(engine.Execute(new ChooseCommand(0)).Accepted);
if (engine.State.PendingChoice is not null)
Assert.True(engine.Execute(new ChooseCommand(0)).Accepted);
var called = engine.State.Players[0].RearGuards.FirstOrDefault(u => u is not null);
Assert.NotNull(called);
var def = _catalog.CardOf(called!.PrintingId);
Assert.Equal(Clan.RoyalPaladin, def.Clan);
Assert.True(def.Grade <= 2);
}
[Fact]
public void Search_Can_Ride_The_Found_Card()
{
var engine = InMainPhase();
engine.SearchDeckToRide(0, "Blaster Blade", d => d.NameId == "blaster-blade");
if (engine.State.PendingChoice is not null)
Assert.True(engine.Execute(new ChooseCommand(0)).Accepted);
var vanguard = engine.State.Players[0].Vanguard!;
Assert.Equal("blaster-blade", _catalog.CardOf(vanguard.PrintingId).NameId);
}
[Fact]
public void Deathrex_Gains_5000_When_Attacking()
{
var engine = StartedEngine();
engine.Execute(new SkipRideCommand());
SetVanguard(engine, 0, Deathrex);
engine.Execute(new EnterBattleCommand());
Assert.True(engine.Execute(new AttackCommand(-1, -1)).Accepted);
Assert.Equal(5000, engine.State.Players[0].Vanguard!.Modifiers.Sum(m => m.Power));
}
[Fact]
public void DemonEater_Charges_Soul_At_Main_Phase_Start()
{
var engine = StartedEngine();
SetVanguard(engine, 0, DemonEater);
engine.Execute(new SkipRideCommand());
var vanguard = engine.State.Players[0].Vanguard!;
Assert.Single(engine.State.Players[0].Soul);
Assert.Equal(12000, engine.EffectivePower(vanguard));
}
[Fact]
public void MaidenOfLibra_Draws_On_Hit_When_Cost_Paid()
{
var engine = StartedEngine();
engine.Execute(new SkipRideCommand());
SetVanguard(engine, 0, Libra);
GiveDamage(engine, 0, 2);
StackDeck(engine.State.Players[0], Gallatin, Gallatin);
engine.Execute(new EnterBattleCommand());
Assert.True(engine.Execute(new AttackCommand(-1, -1)).Accepted);
Assert.True(engine.Execute(new PassGuardCommand()).Accepted);
Assert.NotNull(engine.State.PendingAbility);
var handBefore = engine.State.Players[0].Hand.Count;
Assert.True(engine.Execute(new ResolveAbilityCommand(true)).Accepted);
Assert.Equal(2, engine.State.Players[0].Damage.Count(c => c.FaceUp));
Assert.Equal(handBefore + 1, engine.State.Players[0].Hand.Count);
Assert.Single(engine.State.Players[1].Damage);
}
[Fact]
public void Attack_Hit_Effects_Run_After_Damage()
{
var engine = StartedEngine();
engine.Execute(new SkipRideCommand());
SetVanguard(engine, 0, Libra);
GiveDamage(engine, 0, 2);
StackDeck(engine.State.Players[0], Gallatin, Gallatin);
engine.Execute(new EnterBattleCommand());
Assert.True(engine.Execute(new AttackCommand(-1, -1)).Accepted);
Assert.True(engine.Execute(new PassGuardCommand()).Accepted);
// урон уже нанесён, и только теперь спрашивают про эффект попадания
Assert.Single(engine.State.Players[1].Damage);
Assert.NotNull(engine.State.PendingAbility);
}
[Fact]
public void Morgana_Discards_To_Gain_4000_When_Attacking()
{
var engine = StartedEngine();
engine.Execute(new SkipRideCommand());
SetVanguard(engine, 0, Morgana);
engine.Execute(new EnterBattleCommand());
var morgana = engine.State.Players[0].Vanguard!;
var handBefore = engine.State.Players[0].Hand.Count;
Assert.True(engine.Execute(new AttackCommand(-1, -1)).Accepted);
Assert.NotNull(engine.State.PendingAbility);
Assert.True(engine.Execute(new ResolveAbilityCommand(true)).Accepted);
Assert.NotNull(engine.State.PendingChoice);
Assert.True(engine.Execute(new ChooseCommand(0)).Accepted);
Assert.Equal(4000, morgana.Modifiers.Sum(m => m.Power));
Assert.Equal(handBefore - 1, engine.State.Players[0].Hand.Count);
Assert.Equal(Step.Guard, engine.State.Step);
}
[Fact]
public void Battle_Modifiers_Expire_But_Turn_Modifiers_Stay()
{
var engine = StartedEngine();
SetVanguard(engine, 0, DemonEater);
engine.Execute(new SkipRideCommand());
var vanguard = engine.State.Players[0].Vanguard!;
Assert.Equal(2000, vanguard.Modifiers.Sum(m => m.Power));
StackDeck(engine.State.Players[0], Gallatin, Gallatin);
engine.Execute(new EnterBattleCommand());
engine.Execute(new AttackCommand(-1, -1));
engine.Execute(new PassGuardCommand());
Assert.Equal(2000, vanguard.Modifiers.Sum(m => m.Power));
engine.Execute(new EndTurnCommand());
Assert.Empty(vanguard.Modifiers);
}
[Fact]
public void Granted_Ability_Triggers()
{
var engine = StartedEngine();
engine.Execute(new SkipRideCommand());
SetVanguard(engine, 0, Gallatin);
StackDeck(engine.State.Players[0], Gallatin, Gallatin);
engine.Execute(new EnterBattleCommand());
var vanguard = engine.State.Players[0].Vanguard!;
engine.GrantAbility(
vanguard,
new AbilityDefinition
{
Trigger = AbilityTrigger.OnAttackDeclared,
Resolve = ctx => ctx.Engine.AddBattlePower(ctx.Source, 3000),
},
vanguard,
"test",
ModifierDuration.UntilEndOfTurn);
engine.Execute(new AttackCommand(-1, -1));
Assert.Equal(3000, vanguard.Modifiers.Where(m => m.EffectId == "ability").Sum(m => m.Power));
}
[Fact]
public void Granted_Ability_Can_Be_Removed()
{
var engine = StartedEngine();
engine.Execute(new SkipRideCommand());
SetVanguard(engine, 0, Gallatin);
StackDeck(engine.State.Players[0], Gallatin, Gallatin);
engine.Execute(new EnterBattleCommand());
var vanguard = engine.State.Players[0].Vanguard!;
engine.GrantAbility(
vanguard,
new AbilityDefinition
{
Trigger = AbilityTrigger.OnAttackDeclared,
Resolve = ctx => ctx.Engine.AddBattlePower(ctx.Source, 3000),
},
vanguard,
"test",
ModifierDuration.UntilEndOfTurn);
engine.RemoveGrantedAbilities(g => g.EffectId == "test");
engine.Execute(new AttackCommand(-1, -1));
Assert.Equal(0, vanguard.Modifiers.Where(m => m.EffectId == "ability").Sum(m => m.Power));
}
[Fact]
public void MultiChoice_Stops_When_Player_Says_Done()
{
var engine = StartedEngine();
var opponent = engine.State.Players[1];
opponent.RearGuards[0] = TakeFromDeck(opponent, Marron);
opponent.RearGuards[1] = TakeFromDeck(opponent, Gallatin);
opponent.RearGuards[3] = TakeFromDeck(opponent, Gallatin);
IReadOnlyList<UnitTarget>? result = null;
engine.AskUnitMultiChoice(
0,
"Выберите до двух",
new[] { new UnitTarget(1, 0), new UnitTarget(1, 1), new UnitTarget(1, 3) },
0,
2,
chosen => result = chosen);
Assert.Equal(Step.Choice, engine.State.Step);
Assert.Equal(4, engine.State.PendingChoice!.Options.Count); // три цели и «Готово»
engine.Execute(new ChooseCommand(0));
engine.Execute(new ChooseCommand(2)); // «Готово» среди двух оставшихся
Assert.NotNull(result);
Assert.Single(result!);
Assert.Equal(0, result![0].Slot);
Assert.Equal(Step.None, engine.State.Step);
}
[Fact]
public void MultiChoice_Resolves_Automatically_When_Max_Reached()
{
var engine = StartedEngine();
var opponent = engine.State.Players[1];
opponent.RearGuards[0] = TakeFromDeck(opponent, Marron);
opponent.RearGuards[1] = TakeFromDeck(opponent, Gallatin);
IReadOnlyList<UnitTarget>? result = null;
engine.AskUnitMultiChoice(
0,
"Выберите ровно две",
new[] { new UnitTarget(1, 0), new UnitTarget(1, 1) },
2,
2,
chosen => result = chosen);
Assert.Equal(2, engine.State.PendingChoice!.Options.Count); // две цели, «Готово» ещё нет
engine.Execute(new ChooseCommand(0));
Assert.Null(result); // после первой цели выбор ещё продолжается
engine.Execute(new ChooseCommand(0));
Assert.NotNull(result);
Assert.Equal(2, result!.Count);
}
[Fact]
public void CannotStand_Skips_Stand_And_Expires_After_Stand_Phase()
{
var engine = StartedEngine();
SetVanguard(engine, 0, Gallatin);
engine.Execute(new SkipRideCommand());
var vanguard = engine.State.Players[0].Vanguard!;
engine.AddRestriction(
vanguard,
RestrictionType.CannotStand,
vanguard,
"test",
ModifierDuration.UntilOwnerNextStand);
vanguard.Rested = true;
engine.Execute(new EndTurnCommand()); // ход переходит к игроку 1
engine.Execute(new SkipRideCommand()); // игрок 1 выходит в Основную фазу
engine.Execute(new EndTurnCommand()); // и обратно
Assert.True(vanguard.Rested);
Assert.Empty(vanguard.Restrictions);
}
[Fact]
public void CannotAttack_Restriction_Blocks_Attack()
{
var engine = ReadyToAttackRearGuard();
var attacker = engine.State.Players[0].RearGuards[3]!;
engine.AddRestriction(
attacker,
RestrictionType.CannotAttack,
attacker,
"test",
ModifierDuration.UntilEndOfTurn);
Assert.False(engine.Execute(new AttackCommand(3, 0)).Accepted);
}
[Fact]
public void Attack_Hit_On_RearGuard_Triggers_OnAttackHit()
{
var engine = ReadyToAttackRearGuard();
var attacker = engine.State.Players[0].RearGuards[3]!;
engine.GrantAbility(
attacker,
new AbilityDefinition
{
Trigger = AbilityTrigger.OnAttackHit,
Resolve = ctx => ctx.Engine.AddPower(
ctx.Source,
2000,
ctx.Source,
"hit",
ModifierDuration.UntilEndOfTurn),
},
attacker,
"test",
ModifierDuration.UntilEndOfTurn);
engine.Execute(new AttackCommand(3, 4));
engine.Execute(new PassGuardCommand());
Assert.Null(engine.State.Players[1].RearGuards[4]);
Assert.Equal(2000, attacker.Modifiers.Where(m => m.EffectId == "hit").Sum(m => m.Power));
}
[Fact]
public void Mocha_Gets_Power_When_Hand_Has_Four_Cards()
{
var engine = StartedEngine();
engine.Execute(new SkipRideCommand());
var mocha = MakeCard(Mocha);
engine.State.Players[0].Vanguard = mocha;
StackDeck(engine.State.Players[0], Gallatin, Gallatin);
Assert.True(engine.State.Players[0].Hand.Count >= 4);
engine.Execute(new EnterBattleCommand());
engine.Execute(new AttackCommand(-1, -1));
Assert.Equal(3000, mocha.Modifiers.Where(m => m.EffectId == "ability").Sum(m => m.Power));
}
[Fact]
public void HellSpider_Prevents_Chosen_RearGuard_From_Standing()
{
var engine = InMainPhase();
GiveDamage(engine, 0, 2);
var opponent = engine.State.Players[1];
var target = MakeCard(Gallatin);
opponent.RearGuards[0] = target;
var spider = MakeCard(HellSpider);
engine.State.Players[0].Hand.Add(spider);
Assert.True(engine.Execute(new CallCommand(HellSpider, 0)).Accepted);
Assert.NotNull(engine.State.PendingAbility);
Assert.True(engine.Execute(new ResolveAbilityCommand(true)).Accepted);
Assert.NotNull(engine.State.PendingChoice);
Assert.True(engine.Execute(new ChooseCommand(0)).Accepted);
Assert.True(engine.State.HasRestriction(target, RestrictionType.CannotStand));
Assert.Equal(2, engine.State.Players[0].Damage.Count(c => c.FaceUp));
}
[Fact]
public void BrutalJack_Loses_Restraint_And_Gets_Power_From_Nova_Grappler_Boost()
{
var engine = InMainPhase();
GiveDamage(engine, 0, 1);
var jack = MakeCard(BrutalJack);
engine.State.Players[0].Hand.Add(jack);
engine.Execute(new CallCommand(BrutalJack, 3));
var toughBoy = MakeCard(ToughBoy);
engine.State.Players[0].Hand.Add(toughBoy);
engine.Execute(new CallCommand(ToughBoy, 0));
jack.Rested = false;
toughBoy.Rested = false;
Assert.True(engine.State.IsRestraint(jack));
Assert.True(engine.Execute(new ActivateAbilityCommand(jack.Id)).Accepted);
Assert.False(engine.State.IsRestraint(jack));
Assert.True(engine.Execute(new EnterBattleCommand()).Accepted);
Assert.True(engine.Execute(new AttackCommand(3, -1, 0)).Accepted);
Assert.Equal(5000, jack.Modifiers.Where(m => m.EffectId == "ability").Sum(m => m.Power));
}
[Fact]
public void Overlord_Activation_Adds_Power_Removes_Twin_Drive_And_Stands_On_RearGuard_Hit()
{
var engine = InMainPhase();
GiveDamage(engine, 0, 3);
var overlord = MakeCard(Overlord);
engine.State.Players[0].Vanguard = overlord;
Assert.True(engine.Execute(new ActivateAbilityCommand(overlord.Id)).Accepted);
Assert.Equal(5000, overlord.Modifiers.Where(m => m.EffectId == "dragonic-overlord").Sum(m => m.Power));
Assert.Equal(1, overlord.DriveChecksOverride);
Assert.Single(overlord.GrantedAbilities);
StackDeck(engine.State.Players[0], Gallatin);
engine.Execute(new EnterBattleCommand());
var opponent = engine.State.Players[1];
opponent.RearGuards[4] = MakeCard(Marron);
Assert.True(engine.Execute(new AttackCommand(-1, 4)).Accepted);
Assert.True(engine.Execute(new PassGuardCommand()).Accepted);
Assert.False(overlord.Rested);
Assert.Null(opponent.RearGuards[4]);
engine.Execute(new EndTurnCommand());
Assert.Null(overlord.DriveChecksOverride);
Assert.Empty(overlord.GrantedAbilities);
}
[Fact]
public void Asura_Kaiser_Stands_RearGuard_On_Grade3_Drive_Check()
{
var engine = StartedEngine();
engine.Execute(new SkipRideCommand());
var asura = MakeCard(Asura);
engine.State.Players[0].Vanguard = asura;
var rear = MakeCard(ToughBoy);
engine.State.Players[0].RearGuards[0] = rear;
rear.Rested = true;
// проверка открывает Mr. Invincible (Nova Grappler 3 ранга), затем обычную карту
engine.State.Players[0].Deck.Insert(0, MakeCard("BT01/028EN"));
engine.State.Players[0].Deck.Insert(1, MakeCard(Gallatin));
engine.State.Players[1].Deck.Insert(0, MakeCard(Gallatin)); // проверка урона без триггера
engine.Execute(new EnterBattleCommand());
Assert.True(engine.Execute(new AttackCommand(-1, -1)).Accepted);
Assert.True(engine.Execute(new PassGuardCommand()).Accepted);
Assert.NotNull(engine.State.PendingChoice);
Assert.True(engine.Execute(new ChooseCommand(0)).Accepted);
Assert.False(rear.Rested);
}
[Fact]
public void Juggernaut_Returns_To_Deck_After_Battle()
{
var engine = InMainPhase();
var juggernaut = MakeCard(Juggernaut);
engine.State.Players[0].RearGuards[3] = juggernaut;
juggernaut.Rested = false;
engine.SoulCharge(0, 1);
StackDeck(engine.State.Players[1], Gallatin);
engine.Execute(new EnterBattleCommand());
Assert.True(engine.Execute(new AttackCommand(3, -1)).Accepted);
Assert.NotNull(engine.State.PendingAbility);
Assert.True(engine.Execute(new ResolveAbilityCommand(true)).Accepted);
Assert.True(engine.Execute(new PassGuardCommand()).Accepted);
Assert.Null(engine.State.Players[0].RearGuards[3]);
Assert.Contains(engine.State.Players[0].Deck, c => c.PrintingId == Juggernaut);
}
[Fact]
public void Rock_The_Wall_Moves_To_Soul_After_Leaving_Guardian()
{
var engine = ReadyToAttack();
StackDeck(engine.State.Players[0], Gallatin, Gallatin);
engine.State.Players[1].Hand.Add(MakeCard(RockTheWall));
Assert.True(engine.Execute(new AttackCommand(-1, -1)).Accepted);
Assert.True(engine.Execute(new GuardCommand(RockTheWall)).Accepted);
Assert.True(engine.Execute(new PassGuardCommand()).Accepted);
var defender = engine.State.Players[1];
Assert.Contains(defender.Soul, c => c.PrintingId == RockTheWall);
Assert.DoesNotContain(defender.Drop, c => c.PrintingId == RockTheWall);
}
[Fact]
public void Guiding_Zombie_Puts_Itself_Into_Soul_And_Mills_Three()
{
var engine = InMainPhase();
var zombie = MakeCard(GuidingZombie);
engine.State.Players[0].Hand.Add(zombie);
Assert.True(engine.Execute(new CallCommand(GuidingZombie, 0)).Accepted);
zombie.Rested = false;
var deckBefore = engine.State.Players[0].Deck.Count;
Assert.True(engine.Execute(new ActivateAbilityCommand(zombie.Id)).Accepted);
Assert.Null(engine.State.Players[0].RearGuards[0]);
Assert.Contains(engine.State.Players[0].Soul, c => c.PrintingId == GuidingZombie);
Assert.Equal(3, deckBefore - engine.State.Players[0].Deck.Count);
}
[Fact]
public void Monster_Frank_Rides_From_Drop_Zone()
{
var engine = InMainPhase();
engine.State.Players[0].Vanguard = MakeCard(Gallatin);
GiveDamage(engine, 0, 3);
var frank = MakeCard(MonsterFrank);
engine.State.Players[0].Drop.Add(frank);
Assert.True(engine.Execute(new ActivateAbilityCommand(frank.Id)).Accepted);
Assert.Same(frank, engine.State.Players[0].Vanguard);
Assert.DoesNotContain(engine.State.Players[0].Drop, c => c.Id == frank.Id);
Assert.Equal(3, engine.State.Players[0].Damage.Count(c => c.FaceUp));
}
[Fact]
public void Gancelot_Hand_Ability_Puts_Itself_On_Top_And_Searches_Blaster_Blade()
{
var engine = InMainPhase();
KeepOnly(engine, 0, "blaster-blade", 0);
var gancelot = MakeCard(Gancelot);
engine.State.Players[0].Hand.Add(gancelot);
var blade = MakeCard(BlasterBladePrint);
engine.State.Players[0].Deck.Insert(0, blade);
Assert.True(engine.Execute(new ActivateAbilityCommand(gancelot.Id)).Accepted);
Assert.Contains(engine.State.Players[0].Hand, c => c.PrintingId == BlasterBladePrint);
Assert.DoesNotContain(engine.State.Players[0].Hand, c => c.Id == gancelot.Id);
}
[Fact]
public void Blightops_Searches_Shieldon_When_Retired()
{
var engine = InMainPhase();
KeepOnly(engine, 0, "ironclad-dragon-shieldon", 0);
var blightops = MakeCard(Blightops);
engine.State.Players[0].RearGuards[0] = blightops;
GiveDamage(engine, 0, 1);
engine.State.Players[0].Deck.Insert(0, MakeCard(Shieldon));
engine.RetireRearGuard(0, 0);
Assert.NotNull(engine.State.PendingAbility);
Assert.True(engine.Execute(new ResolveAbilityCommand(true)).Accepted);
Assert.Contains(engine.State.Players[0].Hand, c => c.PrintingId == Shieldon);
Assert.Equal(1, engine.State.Players[0].Damage.Count(c => c.FaceUp));
}
[Fact]
public void Dragon_Knight_Aleph_Rides_Aleph_After_Putting_Cost_Into_Soul()
{
var engine = InMainPhase();
KeepOnly(engine, 0, "embodiment-of-victory-aleph", 0);
var dka = MakeCard(Dka);
engine.State.Players[0].Vanguard = dka;
var bahr = MakeCard(Bahr);
var tahr = MakeCard(Tahr);
engine.State.Players[0].RearGuards[0] = bahr;
engine.State.Players[0].RearGuards[1] = tahr;
GiveDamage(engine, 0, 1);
var aleph = MakeCard(AlephVictory);
engine.State.Players[0].Deck.Insert(0, aleph);
Assert.True(engine.Execute(new ActivateAbilityCommand(dka.Id)).Accepted);
Assert.Same(aleph, engine.State.Players[0].Vanguard);
Assert.Contains(engine.State.Players[0].Soul, c => c.Id == bahr.Id);
Assert.Contains(engine.State.Players[0].Soul, c => c.Id == tahr.Id);
Assert.Null(engine.State.Players[0].RearGuards[0]);
Assert.Null(engine.State.Players[0].RearGuards[1]);
}
[Fact]
public void Voidmaster_Makes_Opponent_Discard_When_Hand_Is_Smaller()
{
var engine = StartedEngine();
engine.Execute(new SkipRideCommand());
var voidmaster = MakeCard(Voidmaster);
engine.State.Players[0].Vanguard = voidmaster;
var p0 = engine.State.Players[0];
var p1 = engine.State.Players[1];
while (p0.Hand.Count > 0)
{
p0.Drop.Add(p0.Hand[0]);
p0.Hand.RemoveAt(0);
}
p1.Hand.Add(MakeCard(Gallatin));
p1.Hand.Add(MakeCard(Gallatin));
GiveDamage(engine, 0, 1);
p0.Deck.Insert(0, MakeCard(Gallatin));
p0.Deck.Insert(1, MakeCard(Gallatin));
p1.Deck.Insert(0, MakeCard(Gallatin));
engine.Execute(new EnterBattleCommand());
Assert.True(engine.Execute(new AttackCommand(-1, -1)).Accepted);
Assert.True(engine.Execute(new PassGuardCommand()).Accepted);
Assert.NotNull(engine.State.PendingAbility);
Assert.True(engine.Execute(new ResolveAbilityCommand(true)).Accepted);
Assert.NotNull(engine.State.PendingChoice);
var handBefore = p1.Hand.Count;
Assert.True(engine.Execute(new ChooseCommand(0)).Accepted);
Assert.Equal(handBefore - 1, p1.Hand.Count);
Assert.Equal(1, p0.Damage.Count(c => c.FaceUp));
}
[Fact]
public void Bors_Gets_Power_For_Counter_Blast()
{
var engine = StartedEngine();
engine.Execute(new SkipRideCommand());
var bors = MakeCard(Bors);
engine.State.Players[0].Vanguard = bors;
GiveDamage(engine, 0, 1);
engine.Execute(new EnterBattleCommand());
Assert.True(engine.Execute(new AttackCommand(-1, -1)).Accepted);
Assert.NotNull(engine.State.PendingAbility);
Assert.True(engine.Execute(new ResolveAbilityCommand(true)).Accepted);
Assert.Equal(3000, bors.Modifiers.Where(m => m.EffectId == "ability").Sum(m => m.Power));
}
[Fact]
public void Goku_Retires_Small_RearGuard_After_Grade3_Drive_Check()
{
var engine = StartedEngine();
engine.Execute(new SkipRideCommand());
var goku = MakeCard(Goku);
engine.State.Players[0].Vanguard = goku;
var opponent = engine.State.Players[1];
opponent.RearGuards[0] = MakeCard(Marron);
engine.State.Players[0].Deck.Insert(0, MakeCard(Overlord)); // Kagero 3 ранга
engine.State.Players[0].Deck.Insert(1, MakeCard(Gallatin));
opponent.Deck.Insert(0, MakeCard(Gallatin)); // проверка урона без триггера
engine.Execute(new EnterBattleCommand());
Assert.True(engine.Execute(new AttackCommand(-1, -1)).Accepted);
Assert.True(engine.Execute(new PassGuardCommand()).Accepted);
Assert.NotNull(engine.State.PendingChoice);
Assert.True(engine.Execute(new ChooseCommand(0)).Accepted);
Assert.Null(opponent.RearGuards[0]);
}
[Fact]
public void Starlight_Unicorn_Gives_Another_Royal_Paladin_2000()
{
var engine = InMainPhase();
engine.State.Players[0].Vanguard = MakeCard(Marron);
var target = MakeCard(Gallatin);
engine.State.Players[0].RearGuards[0] = target;
engine.State.Players[0].Hand.Add(MakeCard(Unicorn));
Assert.True(engine.Execute(new CallCommand(Unicorn, 1)).Accepted);
Assert.NotNull(engine.State.PendingChoice);
Assert.True(engine.Execute(new ChooseCommand(1)).Accepted); // Авангард, затем Gallatin
Assert.Equal(
2000,
target.Modifiers.Where(m => m.EffectId == "starlight-unicorn").Sum(m => m.Power));
}
[Fact]
public void Joka_Gets_Power_When_Opponent_RearGuard_Is_Retired()
{
var engine = InMainPhase();
var joka = MakeCard(Joka);
engine.State.Players[0].RearGuards[0] = joka;
engine.State.Players[1].RearGuards[1] = MakeCard(Gallatin);
engine.RetireRearGuard(1, 1);
Assert.Equal(
3000,
joka.Modifiers.Where(m => m.EffectId == "opponent-retire").Sum(m => m.Power));
}
[Fact]
public void Rear_Guard_In_Back_Row_Cannot_Be_Attacked_Normally()
{
var engine = ReadyToAttackRearGuard();
engine.State.Players[1].RearGuards[0] = MakeCard(Marron);
Assert.False(engine.Execute(new AttackCommand(3, 0)).Accepted);
}
[Fact]
public void Tejas_Attacks_Back_Row_In_Its_Column()
{
var engine = ReadyToAttackRearGuard();
var tejas = MakeCard(Tejas);
engine.State.Players[0].RearGuards[3] = tejas;
tejas.Rested = false;
var defender = engine.State.Players[1];
defender.RearGuards[0] = MakeCard(Barcgal);
Assert.True(engine.Execute(new AttackCommand(3, 0)).Accepted);
Assert.True(engine.Execute(new PassGuardCommand()).Accepted);
Assert.Null(defender.RearGuards[0]);
}
[Fact]
public void Yaksha_Rides_From_Hand_When_Opponent_RearGuard_Is_Retired()
{
var engine = InMainPhase();
var oldVanguard = MakeCard(Gallatin);
engine.State.Players[0].Vanguard = oldVanguard;
var yaksha = MakeCard(Yaksha);
engine.State.Players[0].Hand.Add(yaksha);
engine.State.Players[1].RearGuards[0] = MakeCard(Marron);
engine.RetireRearGuard(1, 0);
Assert.NotNull(engine.State.PendingAbility);
Assert.True(engine.Execute(new ResolveAbilityCommand(true)).Accepted);
Assert.Same(yaksha, engine.State.Players[0].Vanguard);
Assert.DoesNotContain(engine.State.Players[0].Hand, c => c.Id == yaksha.Id);
Assert.Contains(engine.State.Players[0].Soul, c => c.Id == oldVanguard.Id);
}
[Fact]
public void Replay_Restores_The_Match_And_Event_Hash()
{
var engine = NewEngine(777);
var recorder = new MatchRecorder(engine, BuildDeck(), BuildDeck());
engine.Start();
var random = new Random(777);
for (var step = 0; step < 3000 && !engine.State.IsFinished; step++)
{
var legal = engine.LegalCommands();
recorder.Execute(legal[random.Next(legal.Count)]);
}
var record = recorder.ToRecord();
var json = JsonSerializer.Serialize(record);
var restored = JsonSerializer.Deserialize<MatchRecord>(json);
Assert.NotNull(restored);
ReplayRunner.Verify(_catalog, restored);
Assert.Equal(record.EventHash, restored.EventHash);
}
[Fact]
public void Guard_Trigger_Keeps_Guard_Step_After_Decline()
{
var engine = ReadyToAttack();
engine.State.Players[1].Vanguard = MakeCard(Chigasumi); // Nubatama
GiveDamage(engine, 1, 1);
engine.State.Players[1].Hand.Add(MakeCard(Hagakure));
engine.State.Players[0].Hand.Add(MakeCard(Gallatin)); // у атакующего больше карт
Assert.True(engine.Execute(new AttackCommand(-1, -1)).Accepted);
Assert.True(engine.Execute(new GuardCommand(Hagakure)).Accepted);
Assert.NotNull(engine.State.PendingAbility);
Assert.True(engine.Execute(new ResolveAbilityCommand(false)).Accepted);
Assert.Equal(Step.Guard, engine.State.Step);
Assert.True(engine.Execute(new PassGuardCommand()).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 Random_Bot_Games_Keep_Invariants_And_Terminate()
{
for (var seed = 1; seed <= 50; seed++)
{
var engine = NewEngine((ulong)seed);
engine.Start();
var random = new Random(seed);
GameCommand? lastCommand = null;
for (var step = 0; step < 3000 && !engine.State.IsFinished; step++)
{
var legal = engine.LegalCommands();
Assert.True(
legal.Count > 0,
$"{seed}/{step}: нет команд; фаза {engine.State.Phase}, шаг {engine.State.Step}, "
+ $"ожидание триггера {engine.State.PendingTrigger is not null}, "
+ $"способности {engine.State.PendingAbility is not null}, "
+ $"выбора {engine.State.PendingChoice is not null}");
var command = legal[random.Next(legal.Count)];
lastCommand = command;
var result = engine.Execute(command);
Assert.True(result.Accepted, $"{command} -> {result.Reason}");
AssertInvariants(engine.State, $"{seed}/{step}: {lastCommand}");
}
Assert.True(engine.State.IsFinished, $"Партия {seed} не завершилась за 3000 шагов");
}
}
private static void AssertInvariants(GameState state, string context)
{
var ids = new HashSet<int>();
for (var player = 0; player < 2; player++)
{
var p = state.Players[player];
var zones = new List<IEnumerable<CardInstance>>
{
p.Deck,
p.Hand,
p.Soul,
p.Damage,
p.Drop,
p.Trigger,
p.Guardian,
p.Vanguard is null ? Enumerable.Empty<CardInstance>() : new[] { p.Vanguard },
p.RearGuards.OfType<CardInstance>(),
state.InTransit.Where(t => t.Player == player).Select(t => t.Card),
};
var total = 0;
foreach (var card in zones.SelectMany(zone => zone))
{
Assert.True(ids.Add(card.Id), $"Повтор instanceId {card.Id}");
total++;
}
Assert.True(
total == 50,
$"Игрок {player}: {total} карт (deck {p.Deck.Count}, hand {p.Hand.Count}, "
+ $"soul {p.Soul.Count}, damage {p.Damage.Count}, drop {p.Drop.Count}, "
+ $"trigger {p.Trigger.Count}, guardian {p.Guardian.Count}, "
+ $"vg {(p.Vanguard is null ? 0 : 1)}, rg {p.RearGuards.Count(u => u is not null)}) [{context}]");
Assert.True(p.Damage.Count <= GameEngine.MaxDamage);
}
if (state.IsFinished)
Assert.NotNull(state.Winner);
}
[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<GameEvent> 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());
// фиксируем стартовый Авангард, чтобы Barcgal не срабатывал в обычных тестах
SetVanguard(engine, 0, Conroe);
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 ReadyToAttackWithWingal(string attackerPrintingId)
{
var engine = StartedEngine();
engine.Execute(new SkipRideCommand());
Give(engine, 0, attackerPrintingId);
engine.Execute(new CallCommand(attackerPrintingId, 3));
Give(engine, 0, Wingal);
engine.Execute(new CallCommand(Wingal, 0));
engine.State.Players[0].RearGuards[0]!.Rested = false;
engine.State.Players[0].RearGuards[3]!.Rested = false;
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[4] = TakeFromDeck(defender, Marron); // цель в переднем ряду
defender.RearGuards[3] = TakeFromDeck(defender, Gallatin); // перехватчик
engine.Execute(new EnterBattleCommand());
return engine;
}
private static readonly (string PrintingId, int Count)[] Seeds =
{
(Barcgal, 4), (Marron, 4), (Gallatin, 4), (Alfred, 4),
(Nike, 2), (Flogal, 2), (Govannon, 2),
(Iseult, 2), (Nehalem, 2), (Overlord, 2), (Conroe, 4),
(BlasterBlade, 2), (Wingal, 2),
(Lien, 2),
(Cocoa, 2), (Libra, 2),
(Deathrex, 2), (DemonEater, 2),
(Morgana, 2),
};
private List<string> BuildDeck()
{
var names = Seeds
.Select(s => _catalog.CardOf(s.PrintingId).NameId)
.ToHashSet(StringComparer.Ordinal);
var deck = new List<string>();
foreach (var (printingId, count) in Seeds)
deck.AddRange(Enumerable.Repeat(printingId, count));
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<CardInstance>();
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 KeepOnly(GameEngine engine, int player, string nameId, int keep)
{
var p = engine.State.Players[player];
var matches = p.Deck
.Where(c => engine.State.Catalog.CardOf(c.PrintingId).NameId == nameId)
.ToList();
foreach (var card in matches.Skip(keep))
p.Deck.Remove(card);
}
private static void GiveDamage(GameEngine engine, int player, int amount)
{
var p = engine.State.Players[player];
for (var i = 0; i < amount; i++)
{
Assert.NotEmpty(p.Deck);
var card = p.Deck[0];
p.Deck.RemoveAt(0);
card.FaceUp = false;
p.Damage.Add(card);
}
}
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;
}
private static int _nextInstanceId = 20_000;
/// <summary>Карта, которой нет в тестовой колоде: кладём в руку или на поле вручную.</summary>
private static CardInstance MakeCard(string printingId) =>
new() { Id = Interlocked.Increment(ref _nextInstanceId), PrintingId = printingId };
}