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)