diff --git a/IMPLEMENTATION_CHECKLIST.md b/IMPLEMENTATION_CHECKLIST.md index 62a6b02..d6d42c9 100644 --- a/IMPLEMENTATION_CHECKLIST.md +++ b/IMPLEMENTATION_CHECKLIST.md @@ -121,6 +121,7 @@ - [ ] Стоимости: CB, SB, discard, rest self, retire self, put to soul, return to deck. - [x] Выбор цели игроком (`PendingChoice` / `ChooseCommand`); «до N» / «ровно N» ещё нет. - [x] Выбор карты из руки как стоимости эффекта (Morgana: сброс → +4000). +- [x] Модификаторы силы/крита с источником и сроком жизни (`StatModifier`); выдача и снятие способностей эффектами (`GrantedAbility`). - [x] Поиск в колоде без просмотра: 0 совпадений — уведомление и перемешивание, 1 — сразу в руку, несколько — выбор; разные арты считаются разными картами. - [x] Поиск с призывом в круг Арьергарда (Alfred) и с райдом (API, Llew). - [x] Просмотр верхней карты с решением «оставить на верху / положить в низ» (Cocoa). diff --git a/server/src/Vanguard.Rules/Engine/Abilities/CardScripts.cs b/server/src/Vanguard.Rules/Engine/Abilities/CardScripts.cs index 7464565..4c3d4d5 100644 --- a/server/src/Vanguard.Rules/Engine/Abilities/CardScripts.cs +++ b/server/src/Vanguard.Rules/Engine/Abilities/CardScripts.cs @@ -1,8 +1,8 @@ -using Vanguard.Rules.Cards; +using Vanguard.Rules.Cards; namespace Vanguard.Rules.Engine.Abilities; -/// Скрипты карт первого сета, подключённые к движку. +/// Скрипты карт первого сета, подключённые Рє РґРІРёР¶РєСѓ. public static class CardScripts { public static IReadOnlyDictionary Default { get; } = @@ -25,7 +25,7 @@ public static class CardScripts } /// -/// Knight of Rose, Morgana: при атаке можно сбросить карту из руки и получить +4000 до конца хода. +/// Knight of Rose, Morgana: РїСЂРё атаке РјРѕР¶РЅРѕ сбросить карту РёР· СЂСѓРєРё Рё получить +4000 РґРѕ конца С…РѕРґР°. /// public sealed class MorganaScript : CardScript { @@ -42,21 +42,21 @@ public sealed class MorganaScript : CardScript CanPay = ctx => ctx.State.Players[ctx.Owner].Hand.Count > 0, Resolve = ctx => ctx.Engine.AskHandCardChoice( ctx.Owner, - "Сбросьте карту из руки", + "Сбросьте карту РёР· СЂСѓРєРё", null, card => { var p = ctx.State.Players[ctx.Owner]; p.Hand.Remove(card); p.Drop.Add(card); - ctx.Source.TemporaryPower += 4000; + ctx.Engine.AddPower(ctx.Source, 4000, ctx.Source, "morgana", ModifierDuration.UntilEndOfTurn); }), }; } } } -/// Tyrant, Deathrex: когда эта карта атакует, получает +5000 до конца боя. +/// Tyrant, Deathrex: РєРѕРіРґР° эта карта атакует, получает +5000 РґРѕ конца Р±РѕСЏ. public sealed class DeathrexScript : CardScript { public override string NameId => "tyrant-deathrex"; @@ -68,13 +68,13 @@ public sealed class DeathrexScript : CardScript yield return new AbilityDefinition { Trigger = AbilityTrigger.OnAttackDeclared, - Resolve = ctx => ctx.Engine.AddBattlePower(5000), + Resolve = ctx => ctx.Engine.AddBattlePower(ctx.Source, 5000), }; } } } -/// Demon Eater: в начале Основной фазы Soul Charge 1 и +2000 до конца хода. +/// Demon Eater: РІ начале РћСЃРЅРѕРІРЅРѕР№ фазы Soul Charge 1 Рё +2000 РґРѕ конца С…РѕРґР°. public sealed class DemonEaterScript : CardScript { public override string NameId => "demon-eater"; @@ -89,14 +89,14 @@ public sealed class DemonEaterScript : CardScript Resolve = ctx => { ctx.Engine.SoulCharge(ctx.Owner, 1); - ctx.Source.TemporaryPower += 2000; + ctx.Engine.AddPower(ctx.Source, 2000, ctx.Source, "demon-eater", ModifierDuration.UntilEndOfTurn); }, }; } } } -/// Maiden of Libra: когда атака попадает, Counter Blast 2 и взять карту. +/// Maiden of Libra: РєРѕРіРґР° атака попадает, Counter Blast 2 Рё взять карту. public sealed class MaidenOfLibraScript : CardScript { public override string NameId => "maiden-of-libra"; @@ -118,8 +118,8 @@ public sealed class MaidenOfLibraScript : CardScript } /// -/// Battle Sister, Cocoa: при размещении на VC/RC, если Авангард Oracle Think Tank, -/// посмотреть верхнюю карту и оставить её на верху или отправить в низ. +/// Battle Sister, Cocoa: РїСЂРё размещении РЅР° VC/RC, если Авангард Oracle Think Tank, +/// посмотреть верхнюю карту Рё оставить её РЅР° верху или отправить РІ РЅРёР·. /// public sealed class CocoaScript : CardScript { @@ -133,15 +133,15 @@ public sealed class CocoaScript : CardScript { Trigger = AbilityTrigger.OnPlace, Condition = ctx => ctx.Engine.VanguardClan(ctx.Owner) == Clan.OracleThinkTank, - Resolve = ctx => ctx.Engine.LookTopAndReorder(ctx.Owner, "Верхняя карта колоды"), + Resolve = ctx => ctx.Engine.LookTopAndReorder(ctx.Owner, "Верхняя карта колоды"), }; } } } /// -/// Lizard Soldier, Conroe: [ACT](RC): Counter Blast 1 и уничтожить эту карту → -/// найти в колоде Kagero 1 ранга или ниже и добавить в руку. +/// Lizard Soldier, Conroe: [ACT](RC): Counter Blast 1 Рё уничтожить эту карту в†’ +/// найти РІ колоде Kagero 1 ранга или РЅРёР¶Рµ Рё добавить РІ СЂСѓРєСѓ. /// public sealed class ConroeScript : CardScript { @@ -160,7 +160,7 @@ public sealed class ConroeScript : CardScript ctx.Engine.CounterBlast(ctx.Owner, 1); ctx.Engine.SearchDeckToHand( ctx.Owner, - "Найдите Kagero 1 ранга или ниже", + "Найдите Kagero 1 ранга или РЅРёР¶Рµ", d => d.Clan == Clan.Kagero && d.Grade <= 1); }, }; @@ -169,8 +169,8 @@ public sealed class ConroeScript : CardScript } /// -/// «[ACT]: повернуть эту карту и сбросить карту из руки → взять карту». -/// Общий скрипт для Lake Maiden, Lien, Dragon Monk, Gojo и Shout. +/// В«[ACT]: повернуть эту карту Рё сбросить карту РёР· СЂСѓРєРё в†’ взять карту». +/// Общий СЃРєСЂРёРїС‚ для Lake Maiden, Lien, Dragon Monk, Gojo Рё Shout. /// public sealed class DrawOneScript : CardScript { @@ -194,8 +194,8 @@ public sealed class DrawOneScript : CardScript } /// -/// Blaster Blade: при размещении оплатить Counter Blast 2 и уничтожить Арьергард противника. -/// На Авангарде — любой, на Арьергарде — только 2 ранга или выше и при Авангарде Royal Paladin. +/// Blaster Blade: РїСЂРё размещении оплатить Counter Blast 2 Рё уничтожить Арьергард противника. +/// РќР° Авангарде — любой, РЅР° Арьергарде — только 2 ранга или выше Рё РїСЂРё Авангарде Royal Paladin. /// public sealed class BlasterBladeScript : CardScript { @@ -222,7 +222,7 @@ public sealed class BlasterBladeScript : CardScript ctx.Engine.AskUnitChoice( ctx.Owner, opponent, - "Выберите Арьергард противника", + "Выберите Арьергард противника", slots, slot => ctx.Engine.RetireRearGuard(opponent, slot)); }, @@ -247,7 +247,7 @@ public sealed class BlasterBladeScript : CardScript ctx.Engine.AskUnitChoice( ctx.Owner, opponent, - "Выберите Арьергард противника 2 ранга или выше", + "Выберите Арьергард противника 2 ранга или выше", slots, slot => ctx.Engine.RetireRearGuard(opponent, slot)); }, @@ -257,7 +257,7 @@ public sealed class BlasterBladeScript : CardScript } /// -/// Barcgal: когда другой Royal Paladin сменяет эту карту, вы можете призвать её в круг Арьергарда. +/// Barcgal: РєРѕРіРґР° РґСЂСѓРіРѕР№ Royal Paladin сменяет эту карту, РІС‹ можете призвать её РІ РєСЂСѓРі Арьергарда. /// public sealed class BarcgalScript : CardScript { @@ -281,7 +281,7 @@ public sealed class BarcgalScript : CardScript } /// -/// Wingal: когда эта карта поддерживает Blaster Blade, усиленная карта получает +4000 до конца боя. +/// Wingal: РєРѕРіРґР° эта карта поддерживает Blaster Blade, усиленная карта получает +4000 РґРѕ конца Р±РѕСЏ. /// public sealed class WingalScript : CardScript { @@ -299,15 +299,15 @@ public sealed class WingalScript : CardScript Condition = ctx => ctx.Other is not null && ctx.Catalog.CardOf(ctx.Other.PrintingId).NameId == BlasterBlade, - Resolve = ctx => ctx.Engine.AddBattlePower(4000), + Resolve = ctx => ctx.Engine.AddBattlePower(ctx.Other!, 4000, ctx.Source), }; } } } /// -/// King of Knights, Alfred: в свой ход на круге Авангарда получает +2000 -/// за каждый свой Арьергард Royal Paladin. +/// King of Knights, Alfred: РІ СЃРІРѕР№ С…РѕРґ РЅР° РєСЂСѓРіРµ Авангарда получает +2000 +/// Р·Р° каждый СЃРІРѕР№ Арьергард Royal Paladin. /// public sealed class AlfredScript : CardScript { @@ -341,10 +341,11 @@ public sealed class AlfredScript : CardScript ctx.Engine.CounterBlast(ctx.Owner, 3); ctx.Engine.SearchDeckToCall( ctx.Owner, - "Найдите Royal Paladin 2 ранга или ниже", + "Найдите Royal Paladin 2 ранга или РЅРёР¶Рµ", d => d.Clan == Clan.RoyalPaladin && d.Grade <= 2); }, }; } } } + diff --git a/server/src/Vanguard.Rules/Engine/GameEngine.cs b/server/src/Vanguard.Rules/Engine/GameEngine.cs index 5b7b375..4777129 100644 --- a/server/src/Vanguard.Rules/Engine/GameEngine.cs +++ b/server/src/Vanguard.Rules/Engine/GameEngine.cs @@ -18,6 +18,8 @@ namespace Vanguard.Rules.Engine; /// из заднего ряда — только если эффект разрешил (InterceptFromBackRow); /// * число проверок принадлежит атакующей карте: 0–2 ранг — одна, 3+ — две; /// Арьергард по умолчанию без проверок, но эффект может их выдать; +/// * модификаторы силы и крита хранят источник и срок жизни (бой / ход / постоянно); +/// способности можно выдавать эффектом и снимать по источнику; /// * эффекты попадания срабатывают после нанесения урона и помещения карты в зону урона; /// * эффекты не вкладываются друг в друга: триггеры, поднятые во время эффекта, /// копятся в очереди и разрешаются после него; @@ -84,10 +86,6 @@ public sealed class GameEngine 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, @@ -97,7 +95,7 @@ public sealed class GameEngine Phase = State.Phase, }; - var triggered = script.Abilities + var triggered = AbilitiesOf(source) .Where(a => a.Trigger == trigger && (a.Condition is null || a.Condition(context))) .Select(a => new TriggeredAbility { Definition = a, Context = context }) .ToList(); @@ -108,6 +106,20 @@ public sealed class GameEngine RaiseBatch(triggered); } + /// Печатные способности карты плюс выданные эффектами. + private IEnumerable AbilitiesOf(CardInstance unit) + { + var nameId = State.Catalog.CardOf(unit.PrintingId).NameId; + if (_scripts.TryGetValue(nameId, out var script)) + { + foreach (var ability in script.Abilities) + yield return ability; + } + + foreach (var granted in unit.GrantedAbilities) + yield return granted.Definition; + } + private void RaiseBatch(List triggered) { if (triggered.Count == 0) @@ -141,10 +153,6 @@ public sealed class GameEngine { foreach (var unit in AllUnits(owner)) { - var nameId = State.Catalog.CardOf(unit.PrintingId).NameId; - if (!_scripts.TryGetValue(nameId, out var script)) - continue; - var context = new AbilityContext { Engine = this, @@ -153,7 +161,7 @@ public sealed class GameEngine Phase = State.Phase, }; - triggered.AddRange(script.Abilities + triggered.AddRange(AbilitiesOf(unit) .Where(a => a.Trigger == trigger && (a.Condition is null || a.Condition(context))) .Select(a => new TriggeredAbility { Definition = a, Context = context })); } @@ -397,13 +405,17 @@ public sealed class GameEngine return CommandResult.Ok(); } - /// Сила карты с учётом непрерывных эффектов. Пересчитывается при каждом запросе. + /// Сила карты с учётом всех модификаторов и непрерывных эффектов. public int EffectivePower(CardInstance unit) { - var total = State.Catalog.CardOf(unit.PrintingId).Power + unit.TemporaryPower; + var total = State.Catalog.CardOf(unit.PrintingId).Power; + total += unit.Modifiers.Sum(m => m.Power); return total + ContinuousPower(unit); } + public int EffectiveCritical(CardInstance unit) => + State.Catalog.CardOf(unit.PrintingId).Critical + unit.Modifiers.Sum(m => m.Critical); + private int ContinuousPower(CardInstance unit) { var owner = OwnerOf(unit); @@ -468,10 +480,53 @@ public sealed class GameEngine } /// Бонус к силе текущего боя от способности карты. - public void AddBattlePower(int amount) + public void AddBattlePower(CardInstance source, int amount) => + AddBattlePower(source, amount, source); + + public void AddBattlePower(CardInstance target, int amount, CardInstance source) => + AddPower(target, amount, source, "ability", ModifierDuration.UntilEndOfBattle); + + /// Добавить модификатор силы и крита с указанием источника и срока жизни. + public void AddPower( + CardInstance unit, + int power, + CardInstance source, + string effectId, + ModifierDuration duration, + int critical = 0) { - if (State.Battle is { } battle) - battle.AbilityPower += amount; + unit.Modifiers.Add(new StatModifier + { + Power = power, + Critical = critical, + Source = source, + EffectId = effectId, + Duration = duration, + }); + } + + /// Выдать карте способность, которой нет в её тексте. + public void GrantAbility( + CardInstance unit, + AbilityDefinition definition, + CardInstance source, + string effectId, + ModifierDuration duration) + { + unit.GrantedAbilities.Add(new GrantedAbility + { + Definition = definition, + Source = source, + EffectId = effectId, + Duration = duration, + }); + } + + /// Снять способности, выданные конкретным эффектом. + public void RemoveGrantedAbilities(Func predicate) + { + foreach (var unit in AllUnits(0).Concat(AllUnits(1))) + unit.GrantedAbilities.RemoveAll(g => predicate(g)); } // ---------- стоимости ---------- @@ -1119,7 +1174,13 @@ public sealed class GameEngine if (booster is not null) { booster.Rested = true; - State.Battle.BoostPower += State.Catalog.CardOf(booster.PrintingId).Power; + AddPower( + attacker, + State.Catalog.CardOf(booster.PrintingId).Power, + booster, + "boost", + ModifierDuration.UntilEndOfBattle); + _events.Add(new BoostedEvent(attackerPlayer, boosterSlot, attackerSlot)); TriggerFromCard(attackerPlayer, booster, AbilityTrigger.OnBoost, attacker); @@ -1313,8 +1374,8 @@ public sealed class GameEngine { foreach (var unit in AllUnits(0).Concat(AllUnits(1))) { - unit.TemporaryPower = 0; - unit.TemporaryCritical = 0; + unit.Modifiers.RemoveAll(m => m.Duration == ModifierDuration.UntilEndOfTurn); + unit.GrantedAbilities.RemoveAll(g => g.Duration == ModifierDuration.UntilEndOfTurn); } } @@ -1418,12 +1479,21 @@ public sealed class GameEngine } if (pending.GrantsPower) - State.GetUnit(player, command.PowerTarget)!.TemporaryPower += TriggerPowerBonus; + { + var powerTarget = State.GetUnit(player, command.PowerTarget)!; + AddPower(powerTarget, TriggerPowerBonus, pending.Card, "trigger", ModifierDuration.UntilEndOfTurn); + } switch (pending.Trigger) { case TriggerType.Critical: - State.GetUnit(player, command.EffectTarget)!.TemporaryCritical += 1; + AddPower( + State.GetUnit(player, command.EffectTarget)!, + 0, + pending.Card, + "trigger", + ModifierDuration.UntilEndOfTurn, + critical: 1); break; case TriggerType.Stand: State.GetUnit(player, command.EffectTarget)!.Rested = false; @@ -1480,7 +1550,7 @@ public sealed class GameEngine var attackerDef = State.Catalog.CardOf(attacker.PrintingId); var defenseUnit = battle.Interceptor ?? target; - var attackPower = EffectivePower(attacker) + battle.BoostPower + battle.AbilityPower; + var attackPower = EffectivePower(attacker); var defensePower = EffectivePower(defenseUnit) + battle.GuardShield; var hit = !battle.Nullified && attackPower > defensePower; @@ -1496,7 +1566,7 @@ public sealed class GameEngine } else if (battle.TargetSlot < 0) { - var damage = attackerDef.Critical + attacker.TemporaryCritical; + var damage = EffectiveCritical(attacker); void AfterDamage() { @@ -1551,6 +1621,12 @@ public sealed class GameEngine defender.Drop.Add(guard); } + foreach (var unit in AllUnits(0).Concat(AllUnits(1))) + { + unit.Modifiers.RemoveAll(m => m.Duration == ModifierDuration.UntilEndOfBattle); + unit.GrantedAbilities.RemoveAll(g => g.Duration == ModifierDuration.UntilEndOfBattle); + } + State.Battle = null; State.PendingTrigger = null; State.Step = Step.None; diff --git a/server/src/Vanguard.Rules/Engine/GameModels.cs b/server/src/Vanguard.Rules/Engine/GameModels.cs index 4a55de8..d64a1ea 100644 --- a/server/src/Vanguard.Rules/Engine/GameModels.cs +++ b/server/src/Vanguard.Rules/Engine/GameModels.cs @@ -72,12 +72,11 @@ public sealed class CardInstance /// Открыта ли карта. Для урона — лицом вверх после Counter Blast. public bool FaceUp { get; set; } - /// Временный бонус силы до конца текущего боя (триггеры, эффекты). - public int TemporaryPower { get; set; } - - /// Временный бонус крита до конца текущего боя. - public int TemporaryCritical { get; set; } + /// Модификаторы силы и крита с указанием источника и срока жизни. + public List Modifiers { get; } = new(); + /// Способности, выданные эффектами, а не напечатанные на карте. + public List GrantedAbilities { get; } = new(); /// /// Переопределение способности поддержки, выданное эффектом. /// null — как напечатано на карте. diff --git a/server/src/Vanguard.Rules/Engine/GameState.cs b/server/src/Vanguard.Rules/Engine/GameState.cs index 6cc5c10..483f8af 100644 --- a/server/src/Vanguard.Rules/Engine/GameState.cs +++ b/server/src/Vanguard.Rules/Engine/GameState.cs @@ -22,11 +22,6 @@ public sealed class BattleState /// Карта, вышедшая на перехват. Сила сравнения берётся по ней. public CardInstance? Interceptor { get; set; } - /// Сила карт поддержки, действует только в этом бою. - public int BoostPower { get; set; } - - /// Сила от способностей карт, действует только в этом бою. - public int AbilityPower { get; set; } public List GuardCards { get; } = new(); } diff --git a/server/src/Vanguard.Rules/Engine/Modifiers.cs b/server/src/Vanguard.Rules/Engine/Modifiers.cs new file mode 100644 index 0000000..4caedd6 --- /dev/null +++ b/server/src/Vanguard.Rules/Engine/Modifiers.cs @@ -0,0 +1,32 @@ +using Vanguard.Rules.Engine.Abilities; + +namespace Vanguard.Rules.Engine; + +public enum ModifierDuration +{ + UntilEndOfBattle, + UntilEndOfTurn, + Permanent, +} + +/// +/// Прибавка к силе или криту. Знает, от какой карты пришла и каким эффектом, +/// поэтому её можно точечно снять, не трогая остальные. +/// +public sealed class StatModifier +{ + public required int Power { get; init; } + public int Critical { get; init; } + public required CardInstance Source { get; init; } + public required string EffectId { get; init; } + public required ModifierDuration Duration { get; init; } +} + +/// Способность, выданная эффектом и не напечатанная на карте. +public sealed class GrantedAbility +{ + public required AbilityDefinition Definition { get; init; } + public required CardInstance Source { get; init; } + public required string EffectId { get; init; } + public required ModifierDuration Duration { get; init; } +} diff --git a/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs b/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs index 2975f8f..ee695de 100644 --- a/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs +++ b/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs @@ -1,5 +1,6 @@ -using Vanguard.Rules.Cards; +using Vanguard.Rules.Cards; using Vanguard.Rules.Engine; +using Vanguard.Rules.Engine.Abilities; using Xunit; namespace Vanguard.Rules.Tests; @@ -8,7 +9,7 @@ public class GameEngineTests { private readonly CardCatalog _catalog = TestCatalog.Instance; - private const string Barcgal = "BT01/003EN"; // grade 0, без триггера — стартовый Авангард + private const string Barcgal = "BT01/003EN"; // grade 0, без триггера — стартовый Авангард private const string Marron = "BT01/042EN"; // grade 1, 8000 private const string Gallatin = "BT01/021EN"; // grade 2, 10000 private const string Alfred = "BT01/001EN"; // grade 3 @@ -18,11 +19,11 @@ 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 Conroe = "BT01/016EN"; // Kagero, grade 0 без триггера private const string BlasterBlade = "BT01/002EN"; private const string Wingal = "BT01/044EN"; - private const string Lien = "BT01/043EN"; // activated: rest + discard → draw - private const string Cocoa = "BT01/007EN"; // OTT, смотрит верхнюю карту + private const string Lien = "BT01/043EN"; // activated: rest + discard в†’ draw + private const string Cocoa = "BT01/007EN"; // OTT, смотрит верхнюю карту private const string Libra = "BT01/017EN"; // OTT, grade 2 private const string Deathrex = "BT01/033EN"; // Tachikaze, 10000 private const string DemonEater = "BT01/036EN"; // Dark Irregulars, 10000 @@ -179,7 +180,7 @@ public class GameEngineTests Assert.NotNull(pending); Assert.Equal(TriggerContext.DamageCheck, pending!.Context); - // до разрешения карта лежит в зоне триггера, а не в уроне + // РґРѕ разрешения карта лежит РІ Р·РѕРЅРµ триггера, Р° РЅРµ РІ СѓСЂРѕРЅРµ Assert.Empty(engine.State.Players[1].Damage); Assert.Contains(engine.State.Players[1].Trigger, c => c.PrintingId == Nike); @@ -204,10 +205,10 @@ public class GameEngineTests engine.Execute(new ResolveTriggerCommand(-1, -1)); var vanguard = engine.State.Players[0].Vanguard!; - Assert.Equal(GameEngine.TriggerPowerBonus, vanguard.TemporaryPower); + Assert.Equal(GameEngine.TriggerPowerBonus, vanguard.Modifiers.Sum(m => m.Power)); Assert.True(engine.Execute(new EndTurnCommand()).Accepted); - Assert.Equal(0, vanguard.TemporaryPower); + Assert.Empty(vanguard.Modifiers); } [Fact] @@ -335,8 +336,8 @@ public class GameEngineTests Assert.True(engine.Execute(new PassGuardCommand()).Accepted); var defender = engine.State.Players[1]; - Assert.NotNull(defender.RearGuards[0]); // цель выжила - Assert.Null(defender.RearGuards[3]); // перехватчик покинул круг + Assert.NotNull(defender.RearGuards[0]); // цель выжила + Assert.Null(defender.RearGuards[3]); // перехватчик РїРѕРєРёРЅСѓР» РєСЂСѓРі Assert.Contains(defender.Drop, c => c.PrintingId == Gallatin); Assert.Contains(engine.Events, e => e is InterceptedEvent); Assert.Contains(engine.Events, e => e is BattleResolvedEvent { Hit: false }); @@ -476,7 +477,7 @@ public class GameEngineTests [Fact] public void Move_Is_Available_Only_In_Main_Phase() { - var engine = StartedEngine(); // фаза Смены + var engine = StartedEngine(); // фаза Смены Assert.False(engine.Execute(new MoveCommand(0, 3)).Accepted); } @@ -586,7 +587,7 @@ public class GameEngineTests var engine = ReadyToAttackWithWingal(BlasterBlade); Assert.True(engine.Execute(new AttackCommand(3, -1, 0)).Accepted); - Assert.Equal(4000, engine.State.Battle!.AbilityPower); + Assert.Equal(4000, engine.State.Players[0].RearGuards[3]!.Modifiers.Where(m => m.EffectId == "ability").Sum(m => m.Power)); } [Fact] @@ -595,7 +596,7 @@ public class GameEngineTests var engine = ReadyToAttackWithWingal(Gallatin); Assert.True(engine.Execute(new AttackCommand(3, -1, 0)).Accepted); - Assert.Equal(0, engine.State.Battle!.AbilityPower); + Assert.Equal(0, engine.State.Players[0].RearGuards[3]!.Modifiers.Where(m => m.EffectId == "ability").Sum(m => m.Power)); } [Fact] @@ -704,7 +705,7 @@ public class GameEngineTests Assert.True(lien.Rested); Assert.Contains(engine.State.Players[0].Drop, c => c.PrintingId == Marron); - Assert.Equal(handBefore, engine.State.Players[0].Hand.Count); // -1 сброс, +1 добор + Assert.Equal(handBefore, engine.State.Players[0].Hand.Count); // -1 СЃР±СЂРѕСЃ, +1 РґРѕР±РѕСЂ } [Fact] @@ -737,7 +738,7 @@ public class GameEngineTests var engine = StartedEngine(); var handBefore = engine.State.Players[0].Hand.Count; - engine.SearchDeckToHand(0, "Карта X", d => d.NameId == "no-such-card"); + engine.SearchDeckToHand(0, "Карта X", d => d.NameId == "no-such-card"); Assert.Contains(engine.Events, e => e is SearchFailedEvent); Assert.Null(engine.State.PendingChoice); @@ -885,7 +886,7 @@ public class GameEngineTests Assert.True(engine.Execute(new AttackCommand(-1, -1)).Accepted); - Assert.Equal(5000, engine.State.Battle!.AbilityPower); + Assert.Equal(5000, engine.State.Players[0].Vanguard!.Modifiers.Sum(m => m.Power)); } [Fact] @@ -936,7 +937,7 @@ public class GameEngineTests Assert.True(engine.Execute(new AttackCommand(-1, -1)).Accepted); Assert.True(engine.Execute(new PassGuardCommand()).Accepted); - // урон уже нанесён, и только теперь спрашивают про эффект попадания + // СѓСЂРѕРЅ СѓР¶Рµ нанесён, Рё только теперь спрашивают РїСЂРѕ эффект попадания Assert.Single(engine.State.Players[1].Damage); Assert.NotNull(engine.State.PendingAbility); } @@ -960,11 +961,85 @@ public class GameEngineTests Assert.True(engine.Execute(new ChooseCommand(0)).Accepted); - Assert.Equal(4000, morgana.TemporaryPower); + Assert.Equal(4000, morgana.Modifiers.Sum(m => m.Power)); Assert.Equal(handBefore - 1, engine.State.Players[0].Hand.Count); Assert.Equal(Step.Guard, engine.State.Step); } + [Fact] + public void Battle_Modifiers_Expire_But_Turn_Modifiers_Stay() + { + var engine = StartedEngine(); + SetVanguard(engine, 0, DemonEater); + engine.Execute(new SkipRideCommand()); + + var vanguard = engine.State.Players[0].Vanguard!; + Assert.Equal(2000, vanguard.Modifiers.Sum(m => m.Power)); + + StackDeck(engine.State.Players[0], Gallatin, Gallatin); + engine.Execute(new EnterBattleCommand()); + engine.Execute(new AttackCommand(-1, -1)); + engine.Execute(new PassGuardCommand()); + + Assert.Equal(2000, vanguard.Modifiers.Sum(m => m.Power)); + + engine.Execute(new EndTurnCommand()); + Assert.Empty(vanguard.Modifiers); + } + + [Fact] + public void Granted_Ability_Triggers() + { + var engine = StartedEngine(); + engine.Execute(new SkipRideCommand()); + SetVanguard(engine, 0, Gallatin); + StackDeck(engine.State.Players[0], Gallatin, Gallatin); + engine.Execute(new EnterBattleCommand()); + + var vanguard = engine.State.Players[0].Vanguard!; + engine.GrantAbility( + vanguard, + new AbilityDefinition + { + Trigger = AbilityTrigger.OnAttackDeclared, + Resolve = ctx => ctx.Engine.AddBattlePower(ctx.Source, 3000), + }, + vanguard, + "test", + ModifierDuration.UntilEndOfTurn); + + engine.Execute(new AttackCommand(-1, -1)); + + Assert.Equal(3000, vanguard.Modifiers.Where(m => m.EffectId == "ability").Sum(m => m.Power)); + } + + [Fact] + public void Granted_Ability_Can_Be_Removed() + { + var engine = StartedEngine(); + engine.Execute(new SkipRideCommand()); + SetVanguard(engine, 0, Gallatin); + StackDeck(engine.State.Players[0], Gallatin, Gallatin); + engine.Execute(new EnterBattleCommand()); + + var vanguard = engine.State.Players[0].Vanguard!; + engine.GrantAbility( + vanguard, + new AbilityDefinition + { + Trigger = AbilityTrigger.OnAttackDeclared, + Resolve = ctx => ctx.Engine.AddBattlePower(ctx.Source, 3000), + }, + vanguard, + "test", + ModifierDuration.UntilEndOfTurn); + + engine.RemoveGrantedAbilities(g => g.EffectId == "test"); + engine.Execute(new AttackCommand(-1, -1)); + + Assert.Equal(0, vanguard.Modifiers.Where(m => m.EffectId == "ability").Sum(m => m.Power)); + } + [Fact] public void ResolveTrigger_Rejects_When_Nothing_Is_Pending() { @@ -1005,7 +1080,7 @@ public class GameEngineTests engine.Start(); engine.Execute(new FinishMulliganCommand()); - // фиксируем стартовый Авангард, чтобы Barcgal не срабатывал в обычных тестах + // фиксируем стартовый Авангард, чтобы Barcgal РЅРµ срабатывал РІ обычных тестах SetVanguard(engine, 0, Conroe); return engine; } @@ -1061,14 +1136,14 @@ public class GameEngineTests Give(engine, 0, Marron); engine.Execute(new RideCommand(Marron)); - // сзади слева — поддержка (1 ранг), впереди слева — атакующий (2 ранг) + // сзади слева — поддержка (1 ранг), впереди слева — атакующий (2 ранг) Give(engine, 0, Marron); engine.Execute(new CallCommand(Marron, 0)); Give(engine, 0, Gallatin); engine.Execute(new CallCommand(Gallatin, 3)); - // призванные карты входят повёрнутыми — для теста поддержки поднимаем обе + // призванные карты РІС…РѕРґСЏС‚ повёрнутыми — для теста поддержки поднимаем РѕР±Рµ engine.State.Players[0].RearGuards[0]!.Rested = false; engine.State.Players[0].RearGuards[3]!.Rested = false; @@ -1144,7 +1219,7 @@ public class GameEngineTests { var p = engine.State.Players[player]; var index = p.Deck.FindIndex(c => c.PrintingId == printingId); - Assert.True(index >= 0, $"В колоде нет {printingId}"); + Assert.True(index >= 0, $"Р’ колоде нет {printingId}"); var card = p.Deck[index]; p.Deck.RemoveAt(index); @@ -1157,7 +1232,7 @@ public class GameEngineTests foreach (var printingId in printingIds) { var index = player.Deck.FindIndex(c => c.PrintingId == printingId); - Assert.True(index >= 0, $"В колоде нет {printingId}"); + Assert.True(index >= 0, $"Р’ колоде нет {printingId}"); cards.Add(player.Deck[index]); player.Deck.RemoveAt(index); @@ -1195,7 +1270,7 @@ public class GameEngineTests { var p = engine.State.Players[player]; var index = p.Deck.FindIndex(c => c.PrintingId == printingId); - Assert.True(index >= 0, $"В колоде нет {printingId}"); + Assert.True(index >= 0, $"Р’ колоде нет {printingId}"); var card = p.Deck[index]; p.Deck.RemoveAt(index); @@ -1205,7 +1280,7 @@ public class GameEngineTests private static CardInstance TakeFromDeck(PlayerState player, string printingId) { var index = player.Deck.FindIndex(c => c.PrintingId == printingId); - Assert.True(index >= 0, $"В колоде нет {printingId}"); + Assert.True(index >= 0, $"Р’ колоде нет {printingId}"); var card = player.Deck[index]; player.Deck.RemoveAt(index); @@ -1220,3 +1295,5 @@ public class GameEngineTests return card; } } + +