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.
This commit is contained in:
MpakobecEx 2026-10-21 00:53:36 +05:00
parent bf2ea337f8
commit b7473f73d7
9 changed files with 485 additions and 18 deletions

View File

@ -114,8 +114,9 @@
- [ ] Discard, Shuffle как отдельные интенты. - [ ] Discard, Shuffle как отдельные интенты.
- [x] Число проверок принадлежит атакующей карте: 0–2 ранг — одна, 3+ — две; Арьергард по умолчанию без проверок; `DriveChecksOverride` меняет число (в том числе снимает двойную). - [x] Число проверок принадлежит атакующей карте: 0–2 ранг — одна, 3+ — две; Арьергард по умолчанию без проверок; `DriveChecksOverride` меняет число (в том числе снимает двойную).
- [x] Результат боя: `Hit` / `Guarded` как событие. - [x] Результат боя: `Hit` / `Guarded` как событие.
- [ ] Событийная шина и три обработчика: Continuous / Activated / Triggered. - [x] Событийная шина: `Triggered` (OnRideOver, OnBoost), `Continuous` с пересчётом при запросе; `Activated` ещё нет.
- [ ] Check Timing: сбор → сортировка → разрешение по одному → повтор. - [x] Check Timing: сбор сработавших, порядок (активный игрок первым, выбор игрока), пауза на опциональные способности.
- [ ] `Activated`-способности и выбор целей игроком.
- [ ] Стоимости: CB, SB, discard, rest self, retire self, put to soul, return to deck. - [ ] Стоимости: CB, SB, discard, rest self, retire self, put to soul, return to deck.
- [ ] Цели: авто по фильтру и выбор игроком, «до N» / «ровно N». - [ ] Цели: авто по фильтру и выбор игроком, «до N» / «ровно N».
- [~] Слои модификаторов силы/крита: усиления от триггеров живут до конца хода, постоянных слоёв нет. - [~] Слои модификаторов силы/крита: усиления от триггеров живут до конца хода, постоянных слоёв нет.

View File

@ -0,0 +1,61 @@
using Vanguard.Rules.Cards;
namespace Vanguard.Rules.Engine.Abilities;
public enum AbilityTrigger
{
OnRideOver,
OnBoost,
OnPhaseStart,
OnAttackDeclared,
OnAttackHit,
OnDamageTaken,
}
/// <summary>Контекст сработавшей способности.</summary>
public sealed class AbilityContext
{
public required GameEngine Engine { get; init; }
public required CardInstance Source { get; init; }
public required int Owner { get; init; }
/// <summary>Второй участник события: новая карта при смене, усиленная карта при поддержке.</summary>
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<AbilityContext, bool>? Condition { get; init; }
public required Action<AbilityContext> Resolve { get; init; }
/// <summary>«Вы можете» — движок спрашивает игрока перед разрешением.</summary>
public bool Optional { get; init; }
}
/// <summary>Непрерывный эффект: пересчитывается при каждом запросе.</summary>
public sealed class ContinuousAbility
{
public required Func<AbilityContext, bool> Applies { get; init; }
public Func<AbilityContext, int>? PowerBonus { get; init; }
public Func<AbilityContext, int>? CriticalBonus { get; init; }
}
public abstract class CardScript
{
public abstract string NameId { get; }
public virtual IEnumerable<AbilityDefinition> Abilities => Array.Empty<AbilityDefinition>();
public virtual IEnumerable<ContinuousAbility> Continuous => Array.Empty<ContinuousAbility>();
}
/// <summary>Сработавшая способность, ожидающая выбора порядка или решения игрока.</summary>
public sealed class TriggeredAbility
{
public required AbilityDefinition Definition { get; init; }
public required AbilityContext Context { get; init; }
public int Owner => Context.Owner;
}

View File

@ -0,0 +1,87 @@
using Vanguard.Rules.Cards;
namespace Vanguard.Rules.Engine.Abilities;
/// <summary>Скрипты карт первого сета, подключённые к движку.</summary>
public static class CardScripts
{
public static IReadOnlyDictionary<string, CardScript> Default { get; } =
new Dictionary<string, CardScript>(StringComparer.Ordinal)
{
["barcgal"] = new BarcgalScript(),
["wingal"] = new WingalScript(),
["king-of-knights-alfred"] = new AlfredScript(),
};
}
/// <summary>
/// Barcgal: когда другой Royal Paladin сменяет эту карту, вы можете призвать её в круг Арьергарда.
/// </summary>
public sealed class BarcgalScript : CardScript
{
public override string NameId => "barcgal";
public override IEnumerable<AbilityDefinition> 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),
};
}
}
}
/// <summary>
/// Wingal: когда эта карта поддерживает Blaster Blade, усиленная карта получает +4000 до конца боя.
/// </summary>
public sealed class WingalScript : CardScript
{
private const string BlasterBlade = "blaster-blade";
public override string NameId => "wingal";
public override IEnumerable<AbilityDefinition> 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),
};
}
}
}
/// <summary>
/// King of Knights, Alfred: в свой ход на круге Авангарда получает +2000
/// за каждый свой Арьергард Royal Paladin.
/// </summary>
public sealed class AlfredScript : CardScript
{
public override string NameId => "king-of-knights-alfred";
public override IEnumerable<ContinuousAbility> 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),
};
}
}
}

View File

@ -14,6 +14,8 @@ public sealed record GuardCommand(string PrintingId, string? DiscardPrintingId =
public sealed record InterceptCommand(int Slot) : GameCommand; public sealed record InterceptCommand(int Slot) : GameCommand;
public sealed record PassGuardCommand : GameCommand; public sealed record PassGuardCommand : GameCommand;
public sealed record ResolveTriggerCommand(int PowerTarget, int EffectTarget) : 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 EndTurnCommand : GameCommand;
public sealed record CommandResult(bool Accepted, string? Reason) public sealed record CommandResult(bool Accepted, string? Reason)

View File

@ -1,12 +1,13 @@
using Vanguard.Rules.Cards; using Vanguard.Rules.Cards;
using Vanguard.Rules.Decks; using Vanguard.Rules.Decks;
using Vanguard.Rules.Engine.Abilities;
namespace Vanguard.Rules.Engine; namespace Vanguard.Rules.Engine;
/// <summary> /// <summary>
/// Ядро партии. Не знает ни о сети, ни о БД: на вход команды, на выход события. /// Ядро партии. Не знает ни о сети, ни о БД: на вход команды, на выход события.
/// ///
/// Упрощения первого среза (карты со способностями подключаются следующим шагом): /// Что реализовано и какие есть упрощения:
/// * идеальная защита (Sentinel): щит 0, но при оплате стоимости (сброс карты того же клана) /// * идеальная защита (Sentinel): щит 0, но при оплате стоимости (сброс карты того же клана)
/// атака полностью отменяется; без оплаты не даёт ничего; /// атака полностью отменяется; без оплаты не даёт ничего;
/// * поддержка работает как отдельная способность: карты 0 и 1 ранга могут передать /// * поддержка работает как отдельная способность: карты 0 и 1 ранга могут передать
@ -17,6 +18,10 @@ namespace Vanguard.Rules.Engine;
/// из заднего ряда — только если эффект разрешил (InterceptFromBackRow); /// из заднего ряда — только если эффект разрешил (InterceptFromBackRow);
/// * число проверок принадлежит атакующей карте: 0–2 ранг — одна, 3+ — две; /// * число проверок принадлежит атакующей карте: 0–2 ранг — одна, 3+ — две;
/// Арьергард по умолчанию без проверок, но эффект может их выдать; /// Арьергард по умолчанию без проверок, но эффект может их выдать;
/// * способности карт подключаются скриптами (CardScript): Triggered и Continuous;
/// Check Timing собирает сработавшие, активный игрок выбирает порядок,
/// опциональные способности ждут ResolveAbilityCommand;
/// * непрерывные эффекты пересчитываются при каждом запросе (EffectivePower);
/// * триггер требует выбора целей через ResolveTriggerCommand: /// * триггер требует выбора целей через ResolveTriggerCommand:
/// +5000 любой карте в кругах Авангарда или Арьергарда, затем второй эффект; /// +5000 любой карте в кругах Авангарда или Арьергарда, затем второй эффект;
/// правило одинаково для drive check и damage check; /// правило одинаково для drive check и damage check;
@ -30,8 +35,14 @@ public sealed class GameEngine
public const int TriggerPowerBonus = 5000; public const int TriggerPowerBonus = 5000;
private readonly List<GameEvent> _events = new(); private readonly List<GameEvent> _events = new();
private readonly IReadOnlyDictionary<string, CardScript> _scripts;
public GameEngine(CardCatalog catalog, ulong seed, IReadOnlyList<string> deck0, IReadOnlyList<string> deck1) public GameEngine(
CardCatalog catalog,
ulong seed,
IReadOnlyList<string> deck0,
IReadOnlyList<string> deck1,
IReadOnlyDictionary<string, CardScript>? scripts = null)
{ {
var violations = DeckValidator.Validate(catalog, deck0.ToList()) var violations = DeckValidator.Validate(catalog, deck0.ToList())
.Concat(DeckValidator.Validate(catalog, deck1.ToList())) .Concat(DeckValidator.Validate(catalog, deck1.ToList()))
@ -41,6 +52,7 @@ public sealed class GameEngine
throw new InvalidOperationException( throw new InvalidOperationException(
"Некорректная колода: " + string.Join("; ", violations.Select(v => v.Message))); "Некорректная колода: " + string.Join("; ", violations.Select(v => v.Message)));
_scripts = scripts ?? CardScripts.Default;
State = new GameState(catalog, seed, "Player 1", "Player 2"); State = new GameState(catalog, seed, "Player 1", "Player 2");
FillDeck(0, deck0); FillDeck(0, deck0);
FillDeck(1, deck1); FillDeck(1, deck1);
@ -48,6 +60,7 @@ public sealed class GameEngine
public GameState State { get; } public GameState State { get; }
public IReadOnlyList<GameEvent> Events => _events; public IReadOnlyList<GameEvent> Events => _events;
public IReadOnlyDictionary<string, CardScript> Scripts => _scripts;
/// <summary> /// <summary>
/// Сколько проверок делает атакующая карта. Эффект может изменить число; /// Сколько проверок делает атакующая карта. Эффект может изменить число;
@ -61,6 +74,193 @@ public sealed class GameEngine
return slot < 0 ? State.Catalog.CardOf(attacker.PrintingId).DefaultDriveChecks : 0; 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();
}
/// <summary>Сила карты с учётом непрерывных эффектов. Пересчитывается при каждом запросе.</summary>
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);
/// <summary>Призыв карты из души — используется способностями вроде Barcgal.</summary>
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;
}
/// <summary>Бонус к силе текущего боя от способности карты.</summary>
public void AddBattlePower(int amount)
{
if (State.Battle is { } battle)
battle.AbilityPower += amount;
}
// ---------- старт партии ---------- // ---------- старт партии ----------
public void Start() public void Start()
@ -130,6 +330,8 @@ public sealed class GameEngine
InterceptCommand c => Intercept(c.Slot), InterceptCommand c => Intercept(c.Slot),
PassGuardCommand => PassGuard(), PassGuardCommand => PassGuard(),
ResolveTriggerCommand c => ResolveTrigger(c), ResolveTriggerCommand c => ResolveTrigger(c),
SelectTriggerCommand c => SelectTrigger(c.Index),
ResolveAbilityCommand c => ResolvePendingAbility(c.Accept),
EndTurnCommand => EndTurn(), EndTurnCommand => EndTurn(),
_ => CommandResult.Reject("Неизвестная команда."), _ => CommandResult.Reject("Неизвестная команда."),
}; };
@ -187,10 +389,11 @@ public sealed class GameEngine
if (def.Grade != current && def.Grade != current + 1) if (def.Grade != current && def.Grade != current + 1)
return CommandResult.Reject($"Нельзя совершить смену с {current}-го ранга на {def.Grade}-й."); 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; riddenOver.Rested = false;
player.Soul.Add(player.Vanguard); player.Soul.Add(riddenOver);
} }
player.Hand.RemoveAt(index); player.Hand.RemoveAt(index);
@ -201,6 +404,8 @@ public sealed class GameEngine
State.Phase = Phase.Main; State.Phase = Phase.Main;
_events.Add(new PhaseChangedEvent(State.ActivePlayer, Phase.Main)); _events.Add(new PhaseChangedEvent(State.ActivePlayer, Phase.Main));
TriggerFromCard(State.ActivePlayer, riddenOver, AbilityTrigger.OnRideOver, card);
return CommandResult.Ok(); return CommandResult.Ok();
} }
@ -342,6 +547,8 @@ public sealed class GameEngine
booster.Rested = true; booster.Rested = true;
State.Battle.BoostPower += State.Catalog.CardOf(booster.PrintingId).Power; State.Battle.BoostPower += State.Catalog.CardOf(booster.PrintingId).Power;
_events.Add(new BoostedEvent(attackerPlayer, boosterSlot, attackerSlot)); _events.Add(new BoostedEvent(attackerPlayer, boosterSlot, attackerSlot));
TriggerFromCard(attackerPlayer, booster, AbilityTrigger.OnBoost, attacker);
} }
State.Step = Step.Guard; State.Step = Step.Guard;
@ -688,10 +895,9 @@ public sealed class GameEngine
var attackerDef = State.Catalog.CardOf(attacker.PrintingId); var attackerDef = State.Catalog.CardOf(attacker.PrintingId);
var defenseUnit = battle.Interceptor ?? target; var defenseUnit = battle.Interceptor ?? target;
var defenseDef = State.Catalog.CardOf(defenseUnit.PrintingId);
var attackPower = attackerDef.Power + attacker.TemporaryPower + battle.BoostPower; var attackPower = EffectivePower(attacker) + battle.BoostPower + battle.AbilityPower;
var defensePower = defenseDef.Power + defenseUnit.TemporaryPower + battle.GuardShield; var defensePower = EffectivePower(defenseUnit) + battle.GuardShield;
var hit = !battle.Nullified && attackPower > defensePower; var hit = !battle.Nullified && attackPower > defensePower;
_events.Add(new BattleResolvedEvent(hit)); _events.Add(new BattleResolvedEvent(hit));

View File

@ -1,4 +1,5 @@
using Vanguard.Rules.Cards; using Vanguard.Rules.Cards;
using Vanguard.Rules.Engine.Abilities;
namespace Vanguard.Rules.Engine; 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 HealedEvent(int Player) : GameEvent;
public sealed record UnitRetiredEvent(int Player, int Slot) : GameEvent; public sealed record UnitRetiredEvent(int Player, int Slot) : GameEvent;
public sealed record GameFinishedEvent(int Winner) : 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;

View File

@ -19,6 +19,7 @@ public enum Step
Guard, Guard,
Drive, Drive,
Trigger, Trigger,
Ability,
} }
public enum TriggerContext public enum TriggerContext

View File

@ -1,4 +1,5 @@
using Vanguard.Rules.Cards; using Vanguard.Rules.Cards;
using Vanguard.Rules.Engine.Abilities;
namespace Vanguard.Rules.Engine; namespace Vanguard.Rules.Engine;
@ -23,6 +24,9 @@ public sealed class BattleState
/// <summary>Сила карт поддержки, действует только в этом бою.</summary> /// <summary>Сила карт поддержки, действует только в этом бою.</summary>
public int BoostPower { get; set; } public int BoostPower { get; set; }
/// <summary>Сила от способностей карт, действует только в этом бою.</summary>
public int AbilityPower { get; set; }
public List<CardInstance> GuardCards { get; } = new(); public List<CardInstance> GuardCards { get; } = new();
} }
@ -50,6 +54,12 @@ public sealed class GameState
public BattleState? Battle { get; internal set; } public BattleState? Battle { get; internal set; }
public PendingTrigger? PendingTrigger { get; internal set; } public PendingTrigger? PendingTrigger { get; internal set; }
/// <summary>Способность ждёт решения «да / нет».</summary>
public TriggeredAbility? PendingAbility { get; internal set; }
/// <summary>Сработавшие способности ждут выбора порядка.</summary>
public List<TriggeredAbility>? PendingOrder { get; internal set; }
public bool IsFinished { get; internal set; } public bool IsFinished { get; internal set; }
public int? Winner { get; internal set; } public int? Winner { get; internal set; }

View File

@ -18,6 +18,9 @@ public class GameEngineTests
private const string Iseult = "BT01/011EN"; // Sentinel, Royal Paladin private const string Iseult = "BT01/011EN"; // Sentinel, Royal Paladin
private const string Nehalem = "BT01/022EN"; // Kagero private const string Nehalem = "BT01/022EN"; // Kagero
private const string Overlord = "BT01/004EN"; // Kagero, 11000 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] [Fact]
public void Start_Places_Starting_Vanguard_And_Draws_Five() public void Start_Places_Starting_Vanguard_And_Draws_Five()
@ -43,7 +46,7 @@ public class GameEngineTests
var result = engine.Execute(new RideCommand(Alfred)); var result = engine.Execute(new RideCommand(Alfred));
Assert.False(result.Accepted); Assert.False(result.Accepted);
Assert.Equal(Barcgal, engine.State.Players[0].Vanguard!.PrintingId); Assert.Equal(Conroe, engine.State.Players[0].Vanguard!.PrintingId);
} }
[Fact] [Fact]
@ -56,7 +59,7 @@ public class GameEngineTests
Assert.True(result.Accepted); Assert.True(result.Accepted);
Assert.Equal(Marron, engine.State.Players[0].Vanguard!.PrintingId); 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); Assert.Equal(Phase.Main, engine.State.Phase);
} }
@ -539,6 +542,72 @@ public class GameEngineTests
Assert.Single(engine.Events.OfType<DriveCheckedEvent>()); 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.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] [Fact]
public void ResolveTrigger_Rejects_When_Nothing_Is_Pending() public void ResolveTrigger_Rejects_When_Nothing_Is_Pending()
{ {
@ -578,6 +647,9 @@ public class GameEngineTests
var engine = NewEngine(); var engine = NewEngine();
engine.Start(); engine.Start();
engine.Execute(new FinishMulliganCommand()); engine.Execute(new FinishMulliganCommand());
// фиксируем стартовый Авангард, чтобы Barcgal не срабатывал в обычных тестах
SetVanguard(engine, 0, Conroe);
return engine; return engine;
} }
@ -597,6 +669,24 @@ public class GameEngineTests
return engine; 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() private GameEngine ReadyToAttackWithRearGuard()
{ {
var engine = StartedEngine(); var engine = StartedEngine();
@ -648,17 +738,23 @@ public class GameEngineTests
return engine; 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<string> BuildDeck() private List<string> BuildDeck()
{ {
var mustInclude = new[] { Barcgal, Marron, Gallatin, Alfred, Nike, Flogal, Govannon, Iseult, Nehalem, Overlord }; var names = Seeds
var names = mustInclude .Select(s => _catalog.CardOf(s.PrintingId).NameId)
.Select(_catalog.CardOf)
.Select(c => c.NameId)
.ToHashSet(StringComparer.Ordinal); .ToHashSet(StringComparer.Ordinal);
var deck = new List<string>(); var deck = new List<string>();
foreach (var printing in mustInclude) foreach (var (printingId, count) in Seeds)
deck.AddRange(Enumerable.Repeat(printing, 4)); deck.AddRange(Enumerable.Repeat(printingId, count));
foreach (var group in _catalog.Printings foreach (var group in _catalog.Printings
.Where(p => !p.IsParallel) .Where(p => !p.IsParallel)