diff --git a/IMPLEMENTATION_CHECKLIST.md b/IMPLEMENTATION_CHECKLIST.md index 99b01cd..ba5cfc0 100644 --- a/IMPLEMENTATION_CHECKLIST.md +++ b/IMPLEMENTATION_CHECKLIST.md @@ -114,8 +114,9 @@ - [ ] Discard, Shuffle как отдельные интенты. - [x] Число проверок принадлежит атакующей карте: 0–2 ранг — одна, 3+ — две; Арьергард по умолчанию без проверок; `DriveChecksOverride` меняет число (в том числе снимает двойную). - [x] Результат боя: `Hit` / `Guarded` как событие. -- [ ] Событийная шина и три обработчика: Continuous / Activated / Triggered. -- [ ] Check Timing: сбор → сортировка → разрешение по одному → повтор. +- [x] Событийная шина: `Triggered` (OnRideOver, OnBoost), `Continuous` с пересчётом при запросе; `Activated` ещё нет. +- [x] Check Timing: сбор сработавших, порядок (активный игрок первым, выбор игрока), пауза на опциональные способности. +- [ ] `Activated`-способности и выбор целей игроком. - [ ] Стоимости: CB, SB, discard, rest self, retire self, put to soul, return to deck. - [ ] Цели: авто по фильтру и выбор игроком, «до N» / «ровно N». - [~] Слои модификаторов силы/крита: усиления от триггеров живут до конца хода, постоянных слоёв нет. diff --git a/server/src/Vanguard.Rules/Engine/Abilities/Abilities.cs b/server/src/Vanguard.Rules/Engine/Abilities/Abilities.cs new file mode 100644 index 0000000..307eeda --- /dev/null +++ b/server/src/Vanguard.Rules/Engine/Abilities/Abilities.cs @@ -0,0 +1,61 @@ +using Vanguard.Rules.Cards; + +namespace Vanguard.Rules.Engine.Abilities; + +public enum AbilityTrigger +{ + OnRideOver, + OnBoost, + OnPhaseStart, + OnAttackDeclared, + OnAttackHit, + OnDamageTaken, +} + +/// Контекст сработавшей способности. +public sealed class AbilityContext +{ + public required GameEngine Engine { get; init; } + public required CardInstance Source { get; init; } + public required int Owner { get; init; } + + /// Второй участник события: новая карта при смене, усиленная карта при поддержке. + public CardInstance? Other { get; init; } + + public Phase Phase { get; init; } + public GameState State => Engine.State; + public CardCatalog Catalog => Engine.State.Catalog; +} + +public sealed class AbilityDefinition +{ + public required AbilityTrigger Trigger { get; init; } + public Func? Condition { get; init; } + public required Action Resolve { get; init; } + + /// «Вы можете» — движок спрашивает игрока перед разрешением. + public bool Optional { get; init; } +} + +/// Непрерывный эффект: пересчитывается при каждом запросе. +public sealed class ContinuousAbility +{ + public required Func Applies { get; init; } + public Func? PowerBonus { get; init; } + public Func? CriticalBonus { get; init; } +} + +public abstract class CardScript +{ + public abstract string NameId { get; } + public virtual IEnumerable Abilities => Array.Empty(); + public virtual IEnumerable Continuous => Array.Empty(); +} + +/// Сработавшая способность, ожидающая выбора порядка или решения игрока. +public sealed class TriggeredAbility +{ + public required AbilityDefinition Definition { get; init; } + public required AbilityContext Context { get; init; } + public int Owner => Context.Owner; +} diff --git a/server/src/Vanguard.Rules/Engine/Abilities/CardScripts.cs b/server/src/Vanguard.Rules/Engine/Abilities/CardScripts.cs new file mode 100644 index 0000000..7c453d1 --- /dev/null +++ b/server/src/Vanguard.Rules/Engine/Abilities/CardScripts.cs @@ -0,0 +1,87 @@ +using Vanguard.Rules.Cards; + +namespace Vanguard.Rules.Engine.Abilities; + +/// Скрипты карт первого сета, подключённые к движку. +public static class CardScripts +{ + public static IReadOnlyDictionary Default { get; } = + new Dictionary(StringComparer.Ordinal) + { + ["barcgal"] = new BarcgalScript(), + ["wingal"] = new WingalScript(), + ["king-of-knights-alfred"] = new AlfredScript(), + }; +} + +/// +/// Barcgal: когда другой Royal Paladin сменяет эту карту, вы можете призвать её в круг Арьергарда. +/// +public sealed class BarcgalScript : CardScript +{ + public override string NameId => "barcgal"; + + public override IEnumerable Abilities + { + get + { + yield return new AbilityDefinition + { + Trigger = AbilityTrigger.OnRideOver, + Optional = true, + Condition = ctx => + ctx.Other is not null && + ctx.Catalog.CardOf(ctx.Other.PrintingId).Clan == Clan.RoyalPaladin, + Resolve = ctx => ctx.Engine.CallFromSoul(ctx.Source, ctx.Owner), + }; + } + } +} + +/// +/// Wingal: когда эта карта поддерживает Blaster Blade, усиленная карта получает +4000 до конца боя. +/// +public sealed class WingalScript : CardScript +{ + private const string BlasterBlade = "blaster-blade"; + + public override string NameId => "wingal"; + + public override IEnumerable Abilities + { + get + { + yield return new AbilityDefinition + { + Trigger = AbilityTrigger.OnBoost, + Condition = ctx => + ctx.Other is not null && + ctx.Catalog.CardOf(ctx.Other.PrintingId).NameId == BlasterBlade, + Resolve = ctx => ctx.Engine.AddBattlePower(4000), + }; + } + } +} + +/// +/// King of Knights, Alfred: в свой ход на круге Авангарда получает +2000 +/// за каждый свой Арьергард Royal Paladin. +/// +public sealed class AlfredScript : CardScript +{ + public override string NameId => "king-of-knights-alfred"; + + public override IEnumerable Continuous + { + get + { + yield return new ContinuousAbility + { + Applies = ctx => + ctx.Engine.IsVanguardOf(ctx.Source, ctx.Owner) && + ctx.State.ActivePlayer == ctx.Owner, + PowerBonus = ctx => 2000 * ctx.Engine.CountRearGuards(ctx.Owner, Clan.RoyalPaladin), + }; + } + } +} diff --git a/server/src/Vanguard.Rules/Engine/GameCommands.cs b/server/src/Vanguard.Rules/Engine/GameCommands.cs index 1ca2153..362b13f 100644 --- a/server/src/Vanguard.Rules/Engine/GameCommands.cs +++ b/server/src/Vanguard.Rules/Engine/GameCommands.cs @@ -14,6 +14,8 @@ public sealed record GuardCommand(string PrintingId, string? DiscardPrintingId = public sealed record InterceptCommand(int Slot) : GameCommand; public sealed record PassGuardCommand : GameCommand; public sealed record ResolveTriggerCommand(int PowerTarget, int EffectTarget) : GameCommand; +public sealed record SelectTriggerCommand(int Index) : GameCommand; +public sealed record ResolveAbilityCommand(bool Accept) : GameCommand; public sealed record EndTurnCommand : GameCommand; public sealed record CommandResult(bool Accepted, string? Reason) diff --git a/server/src/Vanguard.Rules/Engine/GameEngine.cs b/server/src/Vanguard.Rules/Engine/GameEngine.cs index b529f73..fa38751 100644 --- a/server/src/Vanguard.Rules/Engine/GameEngine.cs +++ b/server/src/Vanguard.Rules/Engine/GameEngine.cs @@ -1,12 +1,13 @@ using Vanguard.Rules.Cards; using Vanguard.Rules.Decks; +using Vanguard.Rules.Engine.Abilities; namespace Vanguard.Rules.Engine; /// /// Ядро партии. Не знает ни о сети, ни о БД: на вход команды, на выход события. /// -/// Упрощения первого среза (карты со способностями подключаются следующим шагом): +/// Что реализовано и какие есть упрощения: /// * идеальная защита (Sentinel): щит 0, но при оплате стоимости (сброс карты того же клана) /// атака полностью отменяется; без оплаты не даёт ничего; /// * поддержка работает как отдельная способность: карты 0 и 1 ранга могут передать @@ -17,6 +18,10 @@ namespace Vanguard.Rules.Engine; /// из заднего ряда — только если эффект разрешил (InterceptFromBackRow); /// * число проверок принадлежит атакующей карте: 0–2 ранг — одна, 3+ — две; /// Арьергард по умолчанию без проверок, но эффект может их выдать; +/// * способности карт подключаются скриптами (CardScript): Triggered и Continuous; +/// Check Timing собирает сработавшие, активный игрок выбирает порядок, +/// опциональные способности ждут ResolveAbilityCommand; +/// * непрерывные эффекты пересчитываются при каждом запросе (EffectivePower); /// * триггер требует выбора целей через ResolveTriggerCommand: /// +5000 любой карте в кругах Авангарда или Арьергарда, затем второй эффект; /// правило одинаково для drive check и damage check; @@ -30,8 +35,14 @@ public sealed class GameEngine public const int TriggerPowerBonus = 5000; private readonly List _events = new(); + private readonly IReadOnlyDictionary _scripts; - public GameEngine(CardCatalog catalog, ulong seed, IReadOnlyList deck0, IReadOnlyList deck1) + public GameEngine( + CardCatalog catalog, + ulong seed, + IReadOnlyList deck0, + IReadOnlyList deck1, + IReadOnlyDictionary? scripts = null) { var violations = DeckValidator.Validate(catalog, deck0.ToList()) .Concat(DeckValidator.Validate(catalog, deck1.ToList())) @@ -41,6 +52,7 @@ public sealed class GameEngine throw new InvalidOperationException( "Некорректная колода: " + string.Join("; ", violations.Select(v => v.Message))); + _scripts = scripts ?? CardScripts.Default; State = new GameState(catalog, seed, "Player 1", "Player 2"); FillDeck(0, deck0); FillDeck(1, deck1); @@ -48,6 +60,7 @@ public sealed class GameEngine public GameState State { get; } public IReadOnlyList Events => _events; + public IReadOnlyDictionary Scripts => _scripts; /// /// Сколько проверок делает атакующая карта. Эффект может изменить число; @@ -61,6 +74,193 @@ public sealed class GameEngine return slot < 0 ? State.Catalog.CardOf(attacker.PrintingId).DefaultDriveChecks : 0; } + // ---------- способности ---------- + + private void TriggerFromCard(int owner, CardInstance? source, AbilityTrigger trigger, CardInstance? other) + { + if (source is null) + return; + + var nameId = State.Catalog.CardOf(source.PrintingId).NameId; + if (!_scripts.TryGetValue(nameId, out var script)) + return; + + var context = new AbilityContext + { + Engine = this, + Source = source, + Owner = owner, + Other = other, + Phase = State.Phase, + }; + + var triggered = script.Abilities + .Where(a => a.Trigger == trigger && (a.Condition is null || a.Condition(context))) + .Select(a => new TriggeredAbility { Definition = a, Context = context }) + .ToList(); + + if (triggered.Count == 0) + return; + + if (triggered.Count == 1) + { + OfferAbility(triggered[0]); + return; + } + + State.PendingOrder = triggered; + State.Step = Step.Ability; + State.ActivePlayer = triggered[0].Owner; + } + + private void OfferAbility(TriggeredAbility ability) + { + _events.Add(new AbilityTriggeredEvent(ability.Owner, ability.Context.Source.PrintingId, ability.Definition.Trigger)); + + if (ability.Definition.Optional) + { + State.PendingAbility = ability; + State.Step = Step.Ability; + State.ActivePlayer = ability.Owner; + return; + } + + ResolveAbility(ability, accepted: true); + } + + private void ResolveAbility(TriggeredAbility ability, bool accepted) + { + if (accepted) + ability.Definition.Resolve(ability.Context); + + _events.Add(new AbilityResolvedEvent(ability.Owner, ability.Context.Source.PrintingId, accepted)); + + if (State.PendingOrder is { Count: > 0 } order) + { + var next = order[0]; + order.RemoveAt(0); + + if (order.Count == 0) + State.PendingOrder = null; + + State.ActivePlayer = next.Owner; + OfferAbility(next); + return; + } + + State.Step = Step.None; + } + + private CommandResult SelectTrigger(int index) + { + var order = State.PendingOrder; + if (order is null || State.Step != Step.Ability) + return CommandResult.Reject("Сейчас нет выбора порядка триггеров."); + + if (index < 0 || index >= order.Count) + return CommandResult.Reject("Некорректный индекс."); + + if (order[index].Owner != State.ActivePlayer) + return CommandResult.Reject("Сейчас выбирает другой игрок."); + + var chosen = order[index]; + order.RemoveAt(index); + + if (order.Count == 0) + State.PendingOrder = null; + + OfferAbility(chosen); + return CommandResult.Ok(); + } + + private CommandResult ResolvePendingAbility(bool accept) + { + var ability = State.PendingAbility; + if (ability is null || State.Step != Step.Ability) + return CommandResult.Reject("Сейчас нет способности, ожидающей решения."); + + State.PendingAbility = null; + ResolveAbility(ability, accept); + return CommandResult.Ok(); + } + + /// Сила карты с учётом непрерывных эффектов. Пересчитывается при каждом запросе. + public int EffectivePower(CardInstance unit) + { + var total = State.Catalog.CardOf(unit.PrintingId).Power + unit.TemporaryPower; + return total + ContinuousPower(unit); + } + + private int ContinuousPower(CardInstance unit) + { + var owner = OwnerOf(unit); + if (owner < 0) + return 0; + + var nameId = State.Catalog.CardOf(unit.PrintingId).NameId; + if (!_scripts.TryGetValue(nameId, out var script)) + return 0; + + var context = new AbilityContext + { + Engine = this, + Source = unit, + Owner = owner, + Phase = State.Phase, + }; + + return script.Continuous + .Where(c => c.Applies(context)) + .Sum(c => c.PowerBonus?.Invoke(context) ?? 0); + } + + public int OwnerOf(CardInstance unit) + { + for (var i = 0; i < 2; i++) + { + if (ReferenceEquals(State.Players[i].Vanguard, unit)) + return i; + + if (State.Players[i].RearGuards.Any(u => ReferenceEquals(u, unit))) + return i; + } + + return -1; + } + + public bool IsVanguardOf(CardInstance unit, int player) => + ReferenceEquals(State.Players[player].Vanguard, unit); + + public int CountRearGuards(int player, Clan clan) => + State.Players[player].RearGuards.Count(u => + u is not null && State.Catalog.CardOf(u.PrintingId).Clan == clan); + + /// Призыв карты из души — используется способностями вроде Barcgal. + public bool CallFromSoul(CardInstance card, int player) + { + var p = State.Players[player]; + if (!p.Soul.Contains(card)) + return false; + + var slot = Array.FindIndex(p.RearGuards, u => u is null); + if (slot < 0) + return false; + + p.Soul.Remove(card); + card.Rested = true; + p.RearGuards[slot] = card; + + _events.Add(new UnitCalledEvent(player, slot, card.PrintingId)); + return true; + } + + /// Бонус к силе текущего боя от способности карты. + public void AddBattlePower(int amount) + { + if (State.Battle is { } battle) + battle.AbilityPower += amount; + } + // ---------- старт партии ---------- public void Start() @@ -130,6 +330,8 @@ public sealed class GameEngine InterceptCommand c => Intercept(c.Slot), PassGuardCommand => PassGuard(), ResolveTriggerCommand c => ResolveTrigger(c), + SelectTriggerCommand c => SelectTrigger(c.Index), + ResolveAbilityCommand c => ResolvePendingAbility(c.Accept), EndTurnCommand => EndTurn(), _ => CommandResult.Reject("Неизвестная команда."), }; @@ -187,10 +389,11 @@ public sealed class GameEngine if (def.Grade != current && def.Grade != current + 1) return CommandResult.Reject($"Нельзя совершить смену с {current}-го ранга на {def.Grade}-й."); - if (player.Vanguard is not null) + var riddenOver = player.Vanguard; + if (riddenOver is not null) { - player.Vanguard.Rested = false; - player.Soul.Add(player.Vanguard); + riddenOver.Rested = false; + player.Soul.Add(riddenOver); } player.Hand.RemoveAt(index); @@ -201,6 +404,8 @@ public sealed class GameEngine State.Phase = Phase.Main; _events.Add(new PhaseChangedEvent(State.ActivePlayer, Phase.Main)); + TriggerFromCard(State.ActivePlayer, riddenOver, AbilityTrigger.OnRideOver, card); + return CommandResult.Ok(); } @@ -342,6 +547,8 @@ public sealed class GameEngine booster.Rested = true; State.Battle.BoostPower += State.Catalog.CardOf(booster.PrintingId).Power; _events.Add(new BoostedEvent(attackerPlayer, boosterSlot, attackerSlot)); + + TriggerFromCard(attackerPlayer, booster, AbilityTrigger.OnBoost, attacker); } State.Step = Step.Guard; @@ -688,10 +895,9 @@ public sealed class GameEngine var attackerDef = State.Catalog.CardOf(attacker.PrintingId); var defenseUnit = battle.Interceptor ?? target; - var defenseDef = State.Catalog.CardOf(defenseUnit.PrintingId); - var attackPower = attackerDef.Power + attacker.TemporaryPower + battle.BoostPower; - var defensePower = defenseDef.Power + defenseUnit.TemporaryPower + battle.GuardShield; + var attackPower = EffectivePower(attacker) + battle.BoostPower + battle.AbilityPower; + var defensePower = EffectivePower(defenseUnit) + battle.GuardShield; var hit = !battle.Nullified && attackPower > defensePower; _events.Add(new BattleResolvedEvent(hit)); diff --git a/server/src/Vanguard.Rules/Engine/GameEvents.cs b/server/src/Vanguard.Rules/Engine/GameEvents.cs index 55a5485..059f39e 100644 --- a/server/src/Vanguard.Rules/Engine/GameEvents.cs +++ b/server/src/Vanguard.Rules/Engine/GameEvents.cs @@ -1,4 +1,5 @@ using Vanguard.Rules.Cards; +using Vanguard.Rules.Engine.Abilities; namespace Vanguard.Rules.Engine; @@ -25,3 +26,5 @@ public sealed record DamageTakenEvent(int Player, int Amount, int Total) : GameE public sealed record HealedEvent(int Player) : GameEvent; public sealed record UnitRetiredEvent(int Player, int Slot) : GameEvent; public sealed record GameFinishedEvent(int Winner) : GameEvent; +public sealed record AbilityTriggeredEvent(int Player, string PrintingId, AbilityTrigger Trigger) : GameEvent; +public sealed record AbilityResolvedEvent(int Player, string PrintingId, bool Accepted) : GameEvent; diff --git a/server/src/Vanguard.Rules/Engine/GameModels.cs b/server/src/Vanguard.Rules/Engine/GameModels.cs index b104761..cde0617 100644 --- a/server/src/Vanguard.Rules/Engine/GameModels.cs +++ b/server/src/Vanguard.Rules/Engine/GameModels.cs @@ -19,6 +19,7 @@ public enum Step Guard, Drive, Trigger, + Ability, } public enum TriggerContext diff --git a/server/src/Vanguard.Rules/Engine/GameState.cs b/server/src/Vanguard.Rules/Engine/GameState.cs index 7050faa..686bcde 100644 --- a/server/src/Vanguard.Rules/Engine/GameState.cs +++ b/server/src/Vanguard.Rules/Engine/GameState.cs @@ -1,4 +1,5 @@ using Vanguard.Rules.Cards; +using Vanguard.Rules.Engine.Abilities; namespace Vanguard.Rules.Engine; @@ -23,6 +24,9 @@ public sealed class BattleState /// Сила карт поддержки, действует только в этом бою. public int BoostPower { get; set; } + + /// Сила от способностей карт, действует только в этом бою. + public int AbilityPower { get; set; } public List GuardCards { get; } = new(); } @@ -50,6 +54,12 @@ public sealed class GameState public BattleState? Battle { get; internal set; } public PendingTrigger? PendingTrigger { get; internal set; } + /// Способность ждёт решения «да / нет». + public TriggeredAbility? PendingAbility { get; internal set; } + + /// Сработавшие способности ждут выбора порядка. + public List? PendingOrder { get; internal set; } + public bool IsFinished { get; internal set; } public int? Winner { get; internal set; } diff --git a/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs b/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs index 1a94ad5..4f39d36 100644 --- a/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs +++ b/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs @@ -18,6 +18,9 @@ public class GameEngineTests 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"; [Fact] public void Start_Places_Starting_Vanguard_And_Draws_Five() @@ -43,7 +46,7 @@ public class GameEngineTests var result = engine.Execute(new RideCommand(Alfred)); Assert.False(result.Accepted); - Assert.Equal(Barcgal, engine.State.Players[0].Vanguard!.PrintingId); + Assert.Equal(Conroe, engine.State.Players[0].Vanguard!.PrintingId); } [Fact] @@ -56,7 +59,7 @@ public class GameEngineTests 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.Contains(engine.State.Players[0].Soul, c => c.PrintingId == Conroe); Assert.Equal(Phase.Main, engine.State.Phase); } @@ -539,6 +542,72 @@ public class GameEngineTests Assert.Single(engine.Events.OfType()); } + [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.Battle!.AbilityPower); + } + + [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.Battle!.AbilityPower); + } + + [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 ResolveTrigger_Rejects_When_Nothing_Is_Pending() { @@ -578,6 +647,9 @@ public class GameEngineTests var engine = NewEngine(); engine.Start(); engine.Execute(new FinishMulliganCommand()); + + // фиксируем стартовый Авангард, чтобы Barcgal не срабатывал в обычных тестах + SetVanguard(engine, 0, Conroe); return engine; } @@ -597,6 +669,24 @@ public class GameEngineTests 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(); @@ -648,17 +738,23 @@ public class GameEngineTests 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, 2), + (BlasterBlade, 2), (Wingal, 2), + }; + 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) + var names = Seeds + .Select(s => _catalog.CardOf(s.PrintingId).NameId) .ToHashSet(StringComparer.Ordinal); var deck = new List(); - foreach (var printing in mustInclude) - deck.AddRange(Enumerable.Repeat(printing, 4)); + foreach (var (printingId, count) in Seeds) + deck.AddRange(Enumerable.Repeat(printingId, count)); foreach (var group in _catalog.Printings .Where(p => !p.IsParallel)