From b7473f73d759dd03f176708794afdd82c8c48150 Mon Sep 17 00:00:00 2001 From: MpakobecEx Date: Wed, 21 Oct 2026 00:53:36 +0500 Subject: [PATCH] Ability bus, Check Timing and the first card scripts CardScript model with Triggered and Continuous abilities. Check Timing collects triggered abilities, orders them (turn player first, player picks the order via SelectTriggerCommand), and pauses on optional abilities (ResolveAbilityCommand). Continuous effects are recomputed on demand in EffectivePower. Scripts: Barcgal (optional call from soul), Wingal (+4000 when boosting Blaster Blade), Alfred (+2000 per Royal Paladin rear-guard). Six tests. --- IMPLEMENTATION_CHECKLIST.md | 5 +- .../Engine/Abilities/Abilities.cs | 61 +++++ .../Engine/Abilities/CardScripts.cs | 87 +++++++ .../src/Vanguard.Rules/Engine/GameCommands.cs | 2 + .../src/Vanguard.Rules/Engine/GameEngine.cs | 222 +++++++++++++++++- .../src/Vanguard.Rules/Engine/GameEvents.cs | 3 + .../src/Vanguard.Rules/Engine/GameModels.cs | 1 + server/src/Vanguard.Rules/Engine/GameState.cs | 10 + .../Vanguard.Rules.Tests/GameEngineTests.cs | 112 ++++++++- 9 files changed, 485 insertions(+), 18 deletions(-) create mode 100644 server/src/Vanguard.Rules/Engine/Abilities/Abilities.cs create mode 100644 server/src/Vanguard.Rules/Engine/Abilities/CardScripts.cs 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)