From d30be8cec210693e3a35ea2ff2e0b5e7d88e0af7 Mon Sep 17 00:00:00 2001 From: MpakobecEx Date: Wed, 21 Oct 2026 13:51:45 +0500 Subject: [PATCH] Finish TD01/TD02 scripts and the last BT01 effects New triggers: OnBoostedHit, OnRetireFromRC, OnOpponentRetire, OnGuard; activated abilities from hand and drop zone; superior ride from drop. Cards: Voidmaster, Hagakure, Dreadmaster, Monster Frank, Blightops, Llew, Dragon Knight Aleph, Gancelot hand effect, Deathrex second effect, Brigitte, Bors, Tristan, Starlight Unicorn, Goku, Berserk Dragon, Aermo, Joka, Rakshasa. Four tests; 111 total. --- IMPLEMENTATION_CHECKLIST.md | 6 +- .../Engine/Abilities/Abilities.cs | 10 + .../Engine/Abilities/CardScripts.cs | 35 +++ .../Engine/Abilities/CardScriptsBT01.cs | 246 +++++++++++++++++ .../Engine/Abilities/CardScriptsTD.cs | 249 ++++++++++++++++++ .../src/Vanguard.Rules/Engine/GameEngine.cs | 213 +++++++++++++-- server/src/Vanguard.Rules/Engine/GameState.cs | 3 + .../Vanguard.Rules.Tests/GameEngineTests.cs | 222 ++++++++++++++++ 8 files changed, 963 insertions(+), 21 deletions(-) create mode 100644 server/src/Vanguard.Rules/Engine/Abilities/CardScriptsTD.cs diff --git a/IMPLEMENTATION_CHECKLIST.md b/IMPLEMENTATION_CHECKLIST.md index 9a2da44..33c427e 100644 --- a/IMPLEMENTATION_CHECKLIST.md +++ b/IMPLEMENTATION_CHECKLIST.md @@ -151,9 +151,11 @@ - [x] RU/EN тексты для всех 94. - [x] Сила/щит для 14 карт, которых нет в BT01. - [ ] Дашборд покрытия: `scripted / tested / total`. - — сейчас заскриптовано **42** карты: 13 базовых + 29 в `CardScriptsBT01.cs`. + — сейчас заскриптовано **58** карт из 66 с текстом. Остались: Tejas (нужно правило атаки заднего ряда) и Yaksha (триггер из руки). Шесть карт — только напоминания о Sentinel/Heal, им скрипт не нужен. +- [x] Триггеры `OnDriveCheck` (Asura Kaiser, Brigitte, Tristan, Goku), `OnBoostedHit` (Dreadmaster, Aermo), `OnGuard` (Hagakure), `OnRetireFromRC` (Blightops), `OnOpponentRetire` (Joka, Rakshasa). +- [x] Способности из руки (Gancelot) и из сброса (Monster Frank). - [x] Возврат карты с поля в колоду: после боя (Juggernaut, Brakki) и в конце хода (Lozenge, Battleraizer). -- [x] Триггеры `OnDriveCheck` (Asura Kaiser) и `OnLeaveGuardian` (Rock the Wall, Redshoe Milly). +- [x] Триггер `OnLeaveGuardian` (Rock the Wall, Redshoe Milly). - [ ] Wyvern Strike, Tejas: атака заднего ряда не выделена — движок разрешает атаковать любой круг Арьергарда. **Готово, когда:** 94/94 заскриптованы, 94/94 покрыты тестом, приёмка пройдена по чеклисту карты. diff --git a/server/src/Vanguard.Rules/Engine/Abilities/Abilities.cs b/server/src/Vanguard.Rules/Engine/Abilities/Abilities.cs index 0a2bf48..ef0472a 100644 --- a/server/src/Vanguard.Rules/Engine/Abilities/Abilities.cs +++ b/server/src/Vanguard.Rules/Engine/Abilities/Abilities.cs @@ -11,7 +11,11 @@ public enum AbilityTrigger OnAttackDeclared, OnAttackHit, OnBoosted, + OnBoostedHit, OnDriveCheck, + OnGuard, + OnRetireFromRC, + OnOpponentRetire, OnLeaveGuardian, OnDamageTaken, } @@ -68,6 +72,12 @@ public sealed class ActivatedAbility /// Стоимость: уничтожить саму карту после оплаты. public bool RetiresSelf { get; init; } + /// Способность активируется из руки, а не с поля. + public bool FromHand { get; init; } + + /// Способность активируется из сброса. + public bool FromDropZone { get; init; } + public required Action Resolve { get; init; } } diff --git a/server/src/Vanguard.Rules/Engine/Abilities/CardScripts.cs b/server/src/Vanguard.Rules/Engine/Abilities/CardScripts.cs index 215d4b3..06c383b 100644 --- a/server/src/Vanguard.Rules/Engine/Abilities/CardScripts.cs +++ b/server/src/Vanguard.Rules/Engine/Abilities/CardScripts.cs @@ -50,6 +50,22 @@ public static class CardScripts ["rock-the-wall"] = new DropToSoulFromGuardianScript("rock-the-wall"), ["redshoe-milly"] = new DropToSoulFromGuardianScript("redshoe-milly"), ["guiding-zombie"] = new GuidingZombieScript(), + ["stealth-dragon-voidmaster"] = new VoidmasterScript(), + ["stealth-beast-hagakure"] = new HagakureScript(), + ["stealth-dragon-dreadmaster"] = new DreadmasterScript(), + ["monster-frank"] = new MonsterFrankScript(), + ["assault-dragon-blightops"] = new BlightopsScript(), + ["future-knight-llew"] = new LlewScript(), + ["dragon-knight-aleph"] = new DragonKnightAlephScript(), + ["crimson-butterfly-brigitte"] = new DriveCheckGradeScript("crimson-butterfly-brigitte", Clan.RoyalPaladin), + ["knight-of-the-harp-tristan"] = new DriveCheckGradeScript("knight-of-the-harp-tristan", Clan.RoyalPaladin), + ["knight-of-conviction-bors"] = new BorsScript(), + ["starlight-unicorn"] = new StarlightUnicornScript(), + ["dragon-monk-goku"] = new GokuScript(), + ["berserk-dragon"] = new BerserkDragonScript(), + ["flame-of-hope-aermo"] = new AermoScript(), + ["demonic-dragon-madonna-joka"] = new OpponentRetireBonusScript("demonic-dragon-madonna-joka"), + ["demonic-dragon-mage-rakshasa"] = new OpponentRetireBonusScript("demonic-dragon-mage-rakshasa"), }; } @@ -104,6 +120,25 @@ public sealed class DeathrexScript : CardScript Trigger = AbilityTrigger.OnAttackDeclared, Resolve = ctx => ctx.Engine.AddBattlePower(ctx.Source, 5000), }; + + yield return new AbilityDefinition + { + Trigger = AbilityTrigger.OnAttackHit, + Condition = ctx => ctx.Engine.IsVanguardOf(ctx.Source, ctx.Owner), + Resolve = ctx => + { + var slots = ctx.Engine.RearGuardSlots(ctx.Owner, _ => true); + if (slots.Count == 0) + return; + + ctx.Engine.AskUnitChoice( + ctx.Owner, + ctx.Owner, + "Выберите свой Арьергард и уничтожьте его", + slots, + slot => ctx.Engine.RetireRearGuard(ctx.Owner, slot)); + }, + }; } } } diff --git a/server/src/Vanguard.Rules/Engine/Abilities/CardScriptsBT01.cs b/server/src/Vanguard.Rules/Engine/Abilities/CardScriptsBT01.cs index 0b28505..f7eaf03 100644 --- a/server/src/Vanguard.Rules/Engine/Abilities/CardScriptsBT01.cs +++ b/server/src/Vanguard.Rules/Engine/Abilities/CardScriptsBT01.cs @@ -353,6 +353,23 @@ public sealed class GancelotScript : CardScript critical: 1); }, }; + + yield return new ActivatedAbility + { + FromHand = true, + CanActivate = _ => true, + Resolve = ctx => + { + var p = ctx.State.Players[ctx.Owner]; + p.Hand.Remove(ctx.Source); + p.Deck.Insert(0, ctx.Source); + + ctx.Engine.SearchDeckToHand( + ctx.Owner, + "Найдите Blaster Blade", + d => d.NameId == "blaster-blade"); + }, + }; } } } @@ -813,6 +830,235 @@ public sealed class GuidingZombieScript : CardScript } } +/// +/// Stealth Dragon, Voidmaster: +3000 при атаке, если карт в руке больше; +/// при попадании Counter Blast 1, если карт в руке меньше, — противник сбрасывает карту. +/// +public sealed class VoidmasterScript : CardScript +{ + public override string NameId => "stealth-dragon-voidmaster"; + + public override IEnumerable Abilities + { + get + { + yield return new AbilityDefinition + { + Trigger = AbilityTrigger.OnAttackDeclared, + Condition = ctx => + ctx.Engine.IsVanguardOf(ctx.Source, ctx.Owner) && + ctx.Engine.HandCount(ctx.Owner) > ctx.Engine.HandCount(1 - ctx.Owner), + Resolve = ctx => ctx.Engine.AddBattlePower(ctx.Source, 3000), + }; + + yield return new AbilityDefinition + { + Trigger = AbilityTrigger.OnAttackHit, + Optional = true, + Condition = ctx => + ctx.Engine.HandCount(ctx.Owner) < ctx.Engine.HandCount(1 - ctx.Owner), + CanPay = ctx => ctx.Engine.CanCounterBlast(ctx.Owner, 1), + Pay = ctx => ctx.Engine.CounterBlast(ctx.Owner, 1), + Resolve = ctx => ctx.Engine.OpponentDiscardsChosen( + 1 - ctx.Owner, + "Выберите карту и сбросьте её"), + }; + } + } +} + +/// +/// Stealth Beast, Hagakure: попав в круг защиты, Counter Blast 1 при Авангарде Nubatama +/// и меньшем числе карт в руке — противник сбрасывает карту. +/// +public sealed class HagakureScript : CardScript +{ + public override string NameId => "stealth-beast-hagakure"; + + public override IEnumerable Abilities + { + get + { + yield return new AbilityDefinition + { + Trigger = AbilityTrigger.OnGuard, + Optional = true, + Condition = ctx => + ctx.Engine.VanguardClan(ctx.Owner) == Clan.Nubatama && + ctx.Engine.HandCount(ctx.Owner) < ctx.Engine.HandCount(1 - ctx.Owner), + CanPay = ctx => ctx.Engine.CanCounterBlast(ctx.Owner, 1), + Pay = ctx => ctx.Engine.CounterBlast(ctx.Owner, 1), + Resolve = ctx => ctx.Engine.OpponentDiscardsChosen( + 1 - ctx.Owner, + "Выберите карту и сбросьте её"), + }; + } + } +} + +/// +/// Stealth Dragon, Dreadmaster: когда атака, которую поддержала эта карта, попадает, +/// при меньшем числе карт в руке можно Counter Blast 1, чтобы противник сбросил карту. +/// +public sealed class DreadmasterScript : CardScript +{ + public override string NameId => "stealth-dragon-dreadmaster"; + + public override IEnumerable Abilities + { + get + { + yield return new AbilityDefinition + { + Trigger = AbilityTrigger.OnBoostedHit, + Optional = true, + Condition = ctx => + ctx.Engine.HandCount(ctx.Owner) < ctx.Engine.HandCount(1 - ctx.Owner) && + ctx.State.Battle is { } battle && + ctx.State.GetUnit(battle.AttackerPlayer, battle.AttackerSlot) is { } boosted && + ctx.Catalog.CardOf(boosted.PrintingId).Clan == Clan.Nubatama, + CanPay = ctx => ctx.Engine.CanCounterBlast(ctx.Owner, 1), + Pay = ctx => ctx.Engine.CounterBlast(ctx.Owner, 1), + Resolve = ctx => ctx.Engine.OpponentDiscardsChosen( + 1 - ctx.Owner, + "Выберите карту и сбросьте её"), + }; + } + } +} + +/// +/// Monster Frank: из сброса Counter Blast 3, если Авангард 2 ранга, — райд этой картой. +/// +public sealed class MonsterFrankScript : CardScript +{ + public override string NameId => "monster-frank"; + + public override IEnumerable Activated + { + get + { + yield return new ActivatedAbility + { + FromDropZone = true, + CanActivate = ctx => + ctx.Engine.CanCounterBlast(ctx.Owner, 3) && + ctx.Engine.VanguardGrade(ctx.Owner) == 2, + Resolve = ctx => + { + ctx.Engine.CounterBlast(ctx.Owner, 3); + ctx.Engine.RideFromDrop(ctx.Source, ctx.Owner); + }, + }; + } + } +} + +/// +/// Assault Dragon, Blightops: попав в сброс из круга Арьергарда, Counter Blast 1 → +/// найти Ironclad Dragon, Shieldon в руку. +/// +public sealed class BlightopsScript : CardScript +{ + public override string NameId => "assault-dragon-blightops"; + + public override IEnumerable Abilities + { + get + { + yield return new AbilityDefinition + { + Trigger = AbilityTrigger.OnRetireFromRC, + Optional = true, + CanPay = ctx => ctx.Engine.CanCounterBlast(ctx.Owner, 1), + Pay = ctx => ctx.Engine.CounterBlast(ctx.Owner, 1), + Resolve = ctx => ctx.Engine.SearchDeckToHand( + ctx.Owner, + "Найдите Ironclad Dragon, Shieldon", + d => d.NameId == "ironclad-dragon-shieldon"), + }; + } + } +} + +/// +/// Future Knight, Llew: Counter Blast 1 и три именованные карты с кругов в душу, +/// при Авангарде 1 ранга — найти Blaster Blade и совершить им райд. +/// +public sealed class LlewScript : CardScript +{ + private static readonly string[] Cost = + { + "future-knight-llew", + "barcgal", + "flogal", + }; + + public override string NameId => "future-knight-llew"; + + public override IEnumerable Activated + { + get + { + yield return new ActivatedAbility + { + CanActivate = ctx => + !ctx.Engine.IsVanguardOf(ctx.Source, ctx.Owner) && + ctx.Engine.VanguardGrade(ctx.Owner) == 1 && + ctx.Engine.CanCounterBlast(ctx.Owner, 1) && + ctx.Engine.HasFieldUnits(ctx.Owner, Cost), + Resolve = ctx => + { + ctx.Engine.CounterBlast(ctx.Owner, 1); + ctx.Engine.PutFieldUnitsToSoul(ctx.Owner, Cost); + ctx.Engine.SearchDeckToRide( + ctx.Owner, + "Найдите Blaster Blade", + d => d.NameId == "blaster-blade"); + }, + }; + } + } +} + +/// +/// Dragon Knight, Aleph: Counter Blast 1 и Bahr с Tahr с кругов в душу → +/// найти Embodiment of Victory, Aleph и совершить им райд. +/// +public sealed class DragonKnightAlephScript : CardScript +{ + private static readonly string[] Cost = + { + "embodiment-of-armor-bahr", + "embodiment-of-spear-tahr", + }; + + public override string NameId => "dragon-knight-aleph"; + + public override IEnumerable Activated + { + get + { + yield return new ActivatedAbility + { + CanActivate = ctx => + ctx.Engine.IsVanguardOf(ctx.Source, ctx.Owner) && + ctx.Engine.CanCounterBlast(ctx.Owner, 1) && + ctx.Engine.HasFieldUnits(ctx.Owner, Cost), + Resolve = ctx => + { + ctx.Engine.CounterBlast(ctx.Owner, 1); + ctx.Engine.PutFieldUnitsToSoul(ctx.Owner, Cost); + ctx.Engine.SearchDeckToRide( + ctx.Owner, + "Найдите Embodiment of Victory, Aleph", + d => d.NameId == "embodiment-of-victory-aleph"); + }, + }; + } + } +} + /// /// Brutal Jack: Сдерживание; за Counter Blast 1 теряет его до конца хода; /// получает +5000 до конца боя, когда его поддерживает Nova Grappler. diff --git a/server/src/Vanguard.Rules/Engine/Abilities/CardScriptsTD.cs b/server/src/Vanguard.Rules/Engine/Abilities/CardScriptsTD.cs new file mode 100644 index 0000000..5c118ba --- /dev/null +++ b/server/src/Vanguard.Rules/Engine/Abilities/CardScriptsTD.cs @@ -0,0 +1,249 @@ +using Vanguard.Rules.Cards; + +namespace Vanguard.Rules.Engine.Abilities; + +/// +/// «Когда проверка открывает карту 3 ранга нужного клана, эта карта получает +/// +5000 до конца боя»: Crimson Butterfly, Brigitte и Knight of the Harp, Tristan. +/// +public sealed class DriveCheckGradeScript : CardScript +{ + private readonly Clan _clan; + + public DriveCheckGradeScript(string nameId, Clan clan) + { + NameId = nameId; + _clan = clan; + } + + public override string NameId { get; } + + public override IEnumerable Abilities + { + get + { + yield return new AbilityDefinition + { + Trigger = AbilityTrigger.OnDriveCheck, + Condition = ctx => + ctx.Engine.IsVanguardOf(ctx.Source, ctx.Owner) && + ctx.Other is not null && + ctx.Catalog.CardOf(ctx.Other.PrintingId) is { Grade: 3 } checkedCard && + checkedCard.Clan == _clan, + Resolve = ctx => ctx.Engine.AddBattlePower(ctx.Source, 5000), + }; + } + } +} + +/// Knight of Conviction, Bors: при атаке Counter Blast 1 → +3000 до конца боя. +public sealed class BorsScript : CardScript +{ + public override string NameId => "knight-of-conviction-bors"; + + public override IEnumerable Abilities + { + get + { + yield return new AbilityDefinition + { + Trigger = AbilityTrigger.OnAttackDeclared, + Optional = true, + CanPay = ctx => ctx.Engine.CanCounterBlast(ctx.Owner, 1), + Pay = ctx => ctx.Engine.CounterBlast(ctx.Owner, 1), + Resolve = ctx => ctx.Engine.AddBattlePower(ctx.Source, 3000), + }; + } + } +} + +/// +/// Starlight Unicorn: при размещении на (RC) выбрать другого Royal Paladin +/// и дать ему +2000 до конца хода. +/// +public sealed class StarlightUnicornScript : CardScript +{ + public override string NameId => "starlight-unicorn"; + + public override IEnumerable Abilities + { + get + { + yield return new AbilityDefinition + { + Trigger = AbilityTrigger.OnPlace, + Condition = ctx => !ctx.Engine.IsVanguardOf(ctx.Source, ctx.Owner), + Resolve = ctx => + { + var slots = new List(); + + if (ctx.State.Players[ctx.Owner].Vanguard is { } vanguard + && vanguard != ctx.Source + && ctx.Catalog.CardOf(vanguard.PrintingId).Clan == Clan.RoyalPaladin) + { + slots.Add(-1); + } + + slots.AddRange(ctx.Engine + .RearGuardSlots(ctx.Owner, d => d.Clan == Clan.RoyalPaladin) + .Where(slot => ctx.State.GetUnit(ctx.Owner, slot) != ctx.Source)); + + if (slots.Count == 0) + return; + + ctx.Engine.AskUnitChoice( + ctx.Owner, + ctx.Owner, + "Выберите другого Royal Paladin", + slots, + slot => ctx.Engine.AddPower( + ctx.State.GetUnit(ctx.Owner, slot)!, + 2000, + ctx.Source, + "starlight-unicorn", + ModifierDuration.UntilEndOfTurn)); + }, + }; + } + } +} + +/// +/// Dragon Monk, Goku: проверка открыла Kagero 3 ранга → уничтожить Арьергард +/// противника 1 ранга или ниже. +/// +public sealed class GokuScript : CardScript +{ + public override string NameId => "dragon-monk-goku"; + + public override IEnumerable Abilities + { + get + { + yield return new AbilityDefinition + { + Trigger = AbilityTrigger.OnDriveCheck, + Condition = ctx => + ctx.Engine.IsVanguardOf(ctx.Source, ctx.Owner) && + ctx.Other is not null && + ctx.Catalog.CardOf(ctx.Other.PrintingId) is { Grade: 3, Clan: Clan.Kagero }, + Resolve = ctx => + { + var opponent = 1 - ctx.Owner; + var slots = ctx.Engine.RearGuardSlots(opponent, d => d.Grade <= 1); + if (slots.Count == 0) + return; + + ctx.Engine.AskUnitChoice( + ctx.Owner, + opponent, + "Выберите Арьергард противника 1 ранга или ниже", + slots, + slot => ctx.Engine.RetireRearGuard(opponent, slot)); + }, + }; + } + } +} + +/// +/// Berserk Dragon: при размещении на (VC)/(RC), если Авангард Kagero, +/// Counter Blast 2 → уничтожить Арьергард противника 2 ранга или ниже. +/// +public sealed class BerserkDragonScript : CardScript +{ + public override string NameId => "berserk-dragon"; + + public override IEnumerable Abilities + { + get + { + yield return new AbilityDefinition + { + Trigger = AbilityTrigger.OnPlace, + Optional = true, + Condition = ctx => ctx.Engine.VanguardClan(ctx.Owner) == Clan.Kagero, + CanPay = ctx => ctx.Engine.CanCounterBlast(ctx.Owner, 2), + Pay = ctx => ctx.Engine.CounterBlast(ctx.Owner, 2), + Resolve = ctx => + { + var opponent = 1 - ctx.Owner; + var slots = ctx.Engine.RearGuardSlots(opponent, d => d.Grade <= 2); + if (slots.Count == 0) + return; + + ctx.Engine.AskUnitChoice( + ctx.Owner, + opponent, + "Выберите Арьергард противника 2 ранга или ниже", + slots, + slot => ctx.Engine.RetireRearGuard(opponent, slot)); + }, + }; + } + } +} + +/// +/// Flame of Hope, Aermo: когда атака, которую поддержала эта карта, попадает, +/// можно сбросить карту из руки и взять одну. +/// +public sealed class AermoScript : CardScript +{ + public override string NameId => "flame-of-hope-aermo"; + + public override IEnumerable Abilities + { + get + { + yield return new AbilityDefinition + { + Trigger = AbilityTrigger.OnBoostedHit, + Optional = true, + CanPay = ctx => ctx.Engine.HandCount(ctx.Owner) > 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.Engine.DrawCards(ctx.Owner, 1); + }), + }; + } + } +} + +/// +/// Demonic Dragon Madonna, Joka и Demonic Dragon Mage, Rakshasa: в Основную фазу, +/// когда Арьергард противника уходит в сброс, эта карта получает +3000 до конца хода. +/// +public sealed class OpponentRetireBonusScript : CardScript +{ + public OpponentRetireBonusScript(string nameId) => NameId = nameId; + + public override string NameId { get; } + + public override IEnumerable Abilities + { + get + { + yield return new AbilityDefinition + { + Trigger = AbilityTrigger.OnOpponentRetire, + Condition = ctx => + !ctx.Engine.IsVanguardOf(ctx.Source, ctx.Owner) && + ctx.Phase == Phase.Main, + Resolve = ctx => ctx.Engine.AddPower( + ctx.Source, + 3000, + ctx.Source, + "opponent-retire", + ModifierDuration.UntilEndOfTurn), + }; + } + } +} diff --git a/server/src/Vanguard.Rules/Engine/GameEngine.cs b/server/src/Vanguard.Rules/Engine/GameEngine.cs index 706be5c..4989a54 100644 --- a/server/src/Vanguard.Rules/Engine/GameEngine.cs +++ b/server/src/Vanguard.Rules/Engine/GameEngine.cs @@ -148,8 +148,7 @@ public sealed class GameEngine } /// Собирает срабатывания со всех карт на поле, активный игрок первым. - private void TriggerAll(AbilityTrigger trigger) - { + private void TriggerAll(AbilityTrigger trigger) { var triggered = new List(); for (var owner = 0; owner < 2; owner++) @@ -180,6 +179,37 @@ public sealed class GameEngine RaiseBatch(triggered); } + /// + /// «Когда Арьергард противника ушёл в сброс»: срабатывания карт на поле + /// противоположной стороны. В руке такие способности пока не ищутся. + /// + private void TriggerOpponentRetire(int retiredOwner) + { + var owner = 1 - retiredOwner; + var triggered = new List(); + + foreach (var unit in AllUnits(owner)) + { + var context = new AbilityContext + { + Engine = this, + Source = unit, + Owner = owner, + Phase = State.Phase, + }; + + triggered.AddRange(AbilitiesOf(unit) + .Where(a => a.Trigger == AbilityTrigger.OnOpponentRetire + && (a.Condition is null || a.Condition(context))) + .Select(a => new TriggeredAbility { Definition = a, Context = context })); + } + + if (triggered.Count == 0) + return; + + RaiseBatch(triggered); + } + private void EnterMainPhase() { State.Phase = Phase.Main; @@ -281,9 +311,25 @@ public sealed class GameEngine var player = State.ActivePlayer; var p = State.Players[player]; + var unit = AllUnits(player).FirstOrDefault(u => u.Id == instanceId); + var fromHand = false; + var fromDrop = false; + if (unit is null) - return CommandResult.Reject("Карта не на поле."); + { + unit = p.Hand.FirstOrDefault(c => c.Id == instanceId); + fromHand = unit is not null; + } + + if (unit is null) + { + unit = p.Drop.FirstOrDefault(c => c.Id == instanceId); + fromDrop = unit is not null; + } + + if (unit is null) + return CommandResult.Reject("Карта не на поле, в руке или в сбросе."); var nameId = State.Catalog.CardOf(unit.PrintingId).NameId; if (!_scripts.TryGetValue(nameId, out var script)) @@ -297,10 +343,15 @@ public sealed class GameEngine Phase = State.Phase, }; - var ability = script.Activated.FirstOrDefault(a => a.CanActivate(context)); + var ability = script.Activated.FirstOrDefault(a => + (fromHand ? a.FromHand : fromDrop ? a.FromDropZone : !a.FromHand && !a.FromDropZone) + && a.CanActivate(context)); if (ability is null) return CommandResult.Reject("Нет доступной способности."); + if ((fromHand || fromDrop) && (ability.RestsSelf || ability.RetiresSelf)) + return CommandResult.Reject("Способность из этой зоны не оплачивается поворотом или уничтожением."); + if (ability.RestsSelf && unit.Rested) return CommandResult.Reject("Карта уже повёрнута."); @@ -734,6 +785,9 @@ public sealed class GameEngine State.Players[player].RearGuards[slot] = null; State.Players[player].Drop.Add(unit); _events.Add(new UnitRetiredEvent(player, slot)); + + TriggerFromCard(player, unit, AbilityTrigger.OnRetireFromRC, null); + TriggerOpponentRetire(player); } // ---------- эффекты карт ---------- @@ -1022,7 +1076,18 @@ public sealed class GameEngine } /// Райд картой не из руки (superior ride). - public void RideFromDeck(CardInstance card, int player) + public void RideFromDeck(CardInstance card, int player) => RideUnit(card, player); + + /// Райд картой из сброса (Monster Frank). + public void RideFromDrop(CardInstance card, int player) + { + if (!State.Players[player].Drop.Remove(card)) + return; + + RideUnit(card, player); + } + + private void RideUnit(CardInstance card, int player) { var p = State.Players[player]; var riddenOver = p.Vanguard; @@ -1076,6 +1141,70 @@ public sealed class GameEngine return vanguard is null ? Clan.RoyalPaladin : State.Catalog.CardOf(vanguard.PrintingId).Clan; } + public int VanguardGrade(int player) => + State.Players[player].Vanguard is { } vanguard + ? State.Catalog.CardOf(vanguard.PrintingId).Grade + : 0; + + /// Есть ли на своих кругах Арьергарда по одной карте каждого имени. + public bool HasFieldUnits(int player, IReadOnlyList nameIds) + { + var slots = new HashSet(); + foreach (var nameId in nameIds) + { + var found = false; + for (var slot = 0; slot < PlayerState.RearGuardSlots; slot++) + { + if (slots.Contains(slot)) + continue; + + var unit = State.Players[player].RearGuards[slot]; + if (unit is null || State.Catalog.CardOf(unit.PrintingId).NameId != nameId) + continue; + + slots.Add(slot); + found = true; + break; + } + + if (!found) + return false; + } + + return true; + } + + /// Положить по одной карте каждого имени со своих кругов в душу. + public bool PutFieldUnitsToSoul(int player, IReadOnlyList nameIds) + { + if (!HasFieldUnits(player, nameIds)) + return false; + + var p = State.Players[player]; + var used = new HashSet(); + + foreach (var nameId in nameIds) + { + for (var slot = 0; slot < PlayerState.RearGuardSlots; slot++) + { + if (used.Contains(slot)) + continue; + + var unit = p.RearGuards[slot]; + if (unit is null || State.Catalog.CardOf(unit.PrintingId).NameId != nameId) + continue; + + used.Add(slot); + p.RearGuards[slot] = null; + p.Soul.Add(unit); + _events.Add(new CardMovedToSoulEvent(player, unit.PrintingId)); + break; + } + } + + return true; + } + // ---------- старт партии ---------- public void Start() @@ -1385,6 +1514,7 @@ public sealed class GameEngine AttackerPlayer = attackerPlayer, AttackerSlot = attackerSlot, TargetSlot = targetSlot, + BoosterSlot = boosterSlot, }; var defenderIndex = 1 - attackerPlayer; @@ -1494,6 +1624,8 @@ public sealed class GameEngine _events.Add(new PerfectGuardEvent(defenderIndex, card.PrintingId, discarded.PrintingId)); } + TriggerFromCard(defenderIndex, card, AbilityTrigger.OnGuard, null); + return CommandResult.Ok(); } @@ -1829,6 +1961,44 @@ public sealed class GameEngine if (hit) { + // попадание: сначала «атака, которую поддержала эта карта, попала», + // затем «эта карта попала»; каждый шаг может встать на выбор игрока + void AttackerHitTriggers(CardInstance? hitUnit, Action after) + { + TriggerFromCard(battle.AttackerPlayer, attacker, AbilityTrigger.OnAttackHit, hitUnit); + + if (State.Step is Step.Ability or Step.Choice) + { + State.Continuation = after; + return; + } + + after(); + } + + void BoosterHitTriggers(CardInstance? hitUnit, Action after) + { + var booster = battle.BoosterSlot >= 0 + ? State.GetUnit(battle.AttackerPlayer, battle.BoosterSlot) + : null; + + if (booster is null) + { + AttackerHitTriggers(hitUnit, after); + return; + } + + TriggerFromCard(battle.AttackerPlayer, booster, AbilityTrigger.OnBoostedHit, hitUnit); + + if (State.Step is Step.Ability or Step.Choice) + { + State.Continuation = () => AttackerHitTriggers(hitUnit, after); + return; + } + + AttackerHitTriggers(hitUnit, after); + } + if (battle.Interceptor is not null) { // перехватчик уже вышел из круга и уйдёт в сброс при закрытии боя, @@ -1839,18 +2009,7 @@ public sealed class GameEngine { var damage = EffectiveCritical(attacker); - void AfterDamage() - { - TriggerFromCard(battle.AttackerPlayer, attacker, AbilityTrigger.OnAttackHit, null); - - if (State.Step is Step.Ability or Step.Choice) - { - State.Continuation = () => ClearBattle(battle); - return; - } - - ClearBattle(battle); - } + void AfterDamage() => BoosterHitTriggers(null, () => ClearBattle(battle)); void ResolveDamage() { @@ -1875,13 +2034,29 @@ public sealed class GameEngine State.Players[defenderIndex].Drop.Add(target); _events.Add(new UnitRetiredEvent(defenderIndex, battle.TargetSlot)); - TriggerFromCard(battle.AttackerPlayer, attacker, AbilityTrigger.OnAttackHit, target); + void AfterRetire() + { + TriggerOpponentRetire(defenderIndex); + + if (State.Step is Step.Ability or Step.Choice) + { + State.Continuation = () => BoosterHitTriggers(target, () => ClearBattle(battle)); + return; + } + + BoosterHitTriggers(target, () => ClearBattle(battle)); + } + + TriggerFromCard(defenderIndex, target, AbilityTrigger.OnRetireFromRC, null); if (State.Step is Step.Ability or Step.Choice) { - State.Continuation = () => ClearBattle(battle); + State.Continuation = AfterRetire; return; } + + AfterRetire(); + return; } } diff --git a/server/src/Vanguard.Rules/Engine/GameState.cs b/server/src/Vanguard.Rules/Engine/GameState.cs index 56f7cbb..94ab8e5 100644 --- a/server/src/Vanguard.Rules/Engine/GameState.cs +++ b/server/src/Vanguard.Rules/Engine/GameState.cs @@ -22,6 +22,9 @@ public sealed class BattleState /// Карта, вышедшая на перехват. Сила сравнения берётся по ней. public CardInstance? Interceptor { get; set; } + /// Круг карты поддержки (-1 — поддержки не было). + public int BoosterSlot { get; set; } = -1; + public List GuardCards { get; } = new(); } diff --git a/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs b/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs index 468b1a9..6cef7e4 100644 --- a/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs +++ b/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs @@ -36,6 +36,20 @@ public class GameEngineTests private const string Juggernaut = "BT01/020EN"; // Spike Brothers, возврат в колоду private const string RockTheWall = "BT01/073EN"; private const string GuidingZombie = "BT01/078EN"; + private const string Blightops = "BT01/034EN"; + private const string MonsterFrank = "BT01/037EN"; + private const string Shieldon = "BT01/067EN"; + private const string Dka = "BT01/014EN"; + private const string Bahr = "BT01/048EN"; + private const string Tahr = "BT01/024EN"; + private const string AlephVictory = "BT01/005EN"; + private const string Gancelot = "BT01/010EN"; + private const string Voidmaster = "BT01/035EN"; + private const string BlasterBladePrint = "BT01/002EN"; + private const string Bors = "TD01/002EN"; + private const string Unicorn = "TD01/010EN"; + private const string Goku = "TD02/002EN"; + private const string Joka = "TD02/010EN"; [Fact] public void Start_Places_Starting_Vanguard_And_Draws_Five() @@ -1359,6 +1373,214 @@ public class GameEngineTests Assert.Equal(3, deckBefore - engine.State.Players[0].Deck.Count); } + [Fact] + public void Monster_Frank_Rides_From_Drop_Zone() + { + var engine = InMainPhase(); + engine.State.Players[0].Vanguard = MakeCard(Gallatin); + GiveDamage(engine, 0, 3); + + var frank = MakeCard(MonsterFrank); + engine.State.Players[0].Drop.Add(frank); + + Assert.True(engine.Execute(new ActivateAbilityCommand(frank.Id)).Accepted); + + Assert.Same(frank, engine.State.Players[0].Vanguard); + Assert.DoesNotContain(engine.State.Players[0].Drop, c => c.Id == frank.Id); + Assert.Equal(3, engine.State.Players[0].Damage.Count(c => c.FaceUp)); + } + + [Fact] + public void Gancelot_Hand_Ability_Puts_Itself_On_Top_And_Searches_Blaster_Blade() + { + var engine = InMainPhase(); + KeepOnly(engine, 0, "blaster-blade", 0); + + var gancelot = MakeCard(Gancelot); + engine.State.Players[0].Hand.Add(gancelot); + + var blade = MakeCard(BlasterBladePrint); + engine.State.Players[0].Deck.Insert(0, blade); + + Assert.True(engine.Execute(new ActivateAbilityCommand(gancelot.Id)).Accepted); + + Assert.Contains(engine.State.Players[0].Hand, c => c.PrintingId == BlasterBladePrint); + Assert.DoesNotContain(engine.State.Players[0].Hand, c => c.Id == gancelot.Id); + } + + [Fact] + public void Blightops_Searches_Shieldon_When_Retired() + { + var engine = InMainPhase(); + KeepOnly(engine, 0, "ironclad-dragon-shieldon", 0); + + var blightops = MakeCard(Blightops); + engine.State.Players[0].RearGuards[0] = blightops; + GiveDamage(engine, 0, 1); + + engine.State.Players[0].Deck.Insert(0, MakeCard(Shieldon)); + + engine.RetireRearGuard(0, 0); + + Assert.NotNull(engine.State.PendingAbility); + Assert.True(engine.Execute(new ResolveAbilityCommand(true)).Accepted); + + Assert.Contains(engine.State.Players[0].Hand, c => c.PrintingId == Shieldon); + Assert.Equal(1, engine.State.Players[0].Damage.Count(c => c.FaceUp)); + } + + [Fact] + public void Dragon_Knight_Aleph_Rides_Aleph_After_Putting_Cost_Into_Soul() + { + var engine = InMainPhase(); + KeepOnly(engine, 0, "embodiment-of-victory-aleph", 0); + + var dka = MakeCard(Dka); + engine.State.Players[0].Vanguard = dka; + + var bahr = MakeCard(Bahr); + var tahr = MakeCard(Tahr); + engine.State.Players[0].RearGuards[0] = bahr; + engine.State.Players[0].RearGuards[1] = tahr; + + GiveDamage(engine, 0, 1); + + var aleph = MakeCard(AlephVictory); + engine.State.Players[0].Deck.Insert(0, aleph); + + Assert.True(engine.Execute(new ActivateAbilityCommand(dka.Id)).Accepted); + + Assert.Same(aleph, engine.State.Players[0].Vanguard); + Assert.Contains(engine.State.Players[0].Soul, c => c.Id == bahr.Id); + Assert.Contains(engine.State.Players[0].Soul, c => c.Id == tahr.Id); + Assert.Null(engine.State.Players[0].RearGuards[0]); + Assert.Null(engine.State.Players[0].RearGuards[1]); + } + + [Fact] + public void Voidmaster_Makes_Opponent_Discard_When_Hand_Is_Smaller() + { + var engine = StartedEngine(); + engine.Execute(new SkipRideCommand()); + + var voidmaster = MakeCard(Voidmaster); + engine.State.Players[0].Vanguard = voidmaster; + + var p0 = engine.State.Players[0]; + var p1 = engine.State.Players[1]; + + while (p0.Hand.Count > 0) + { + p0.Drop.Add(p0.Hand[0]); + p0.Hand.RemoveAt(0); + } + + p1.Hand.Add(MakeCard(Gallatin)); + p1.Hand.Add(MakeCard(Gallatin)); + + GiveDamage(engine, 0, 1); + + p0.Deck.Insert(0, MakeCard(Gallatin)); + p0.Deck.Insert(1, MakeCard(Gallatin)); + p1.Deck.Insert(0, MakeCard(Gallatin)); + + engine.Execute(new EnterBattleCommand()); + Assert.True(engine.Execute(new AttackCommand(-1, -1)).Accepted); + Assert.True(engine.Execute(new PassGuardCommand()).Accepted); + + Assert.NotNull(engine.State.PendingAbility); + Assert.True(engine.Execute(new ResolveAbilityCommand(true)).Accepted); + + Assert.NotNull(engine.State.PendingChoice); + var handBefore = p1.Hand.Count; + Assert.True(engine.Execute(new ChooseCommand(0)).Accepted); + + Assert.Equal(handBefore - 1, p1.Hand.Count); + Assert.Equal(1, p0.Damage.Count(c => c.FaceUp)); + } + + [Fact] + public void Bors_Gets_Power_For_Counter_Blast() + { + var engine = StartedEngine(); + engine.Execute(new SkipRideCommand()); + + var bors = MakeCard(Bors); + engine.State.Players[0].Vanguard = bors; + GiveDamage(engine, 0, 1); + + engine.Execute(new EnterBattleCommand()); + Assert.True(engine.Execute(new AttackCommand(-1, -1)).Accepted); + + Assert.NotNull(engine.State.PendingAbility); + Assert.True(engine.Execute(new ResolveAbilityCommand(true)).Accepted); + + Assert.Equal(3000, bors.Modifiers.Where(m => m.EffectId == "ability").Sum(m => m.Power)); + } + + [Fact] + public void Goku_Retires_Small_RearGuard_After_Grade3_Drive_Check() + { + var engine = StartedEngine(); + engine.Execute(new SkipRideCommand()); + + var goku = MakeCard(Goku); + engine.State.Players[0].Vanguard = goku; + + var opponent = engine.State.Players[1]; + opponent.RearGuards[0] = MakeCard(Marron); + + engine.State.Players[0].Deck.Insert(0, MakeCard(Overlord)); // Kagero 3 ранга + engine.State.Players[0].Deck.Insert(1, MakeCard(Gallatin)); + opponent.Deck.Insert(0, MakeCard(Gallatin)); // проверка урона без триггера + + engine.Execute(new EnterBattleCommand()); + Assert.True(engine.Execute(new AttackCommand(-1, -1)).Accepted); + Assert.True(engine.Execute(new PassGuardCommand()).Accepted); + + Assert.NotNull(engine.State.PendingChoice); + Assert.True(engine.Execute(new ChooseCommand(0)).Accepted); + + Assert.Null(opponent.RearGuards[0]); + } + + [Fact] + public void Starlight_Unicorn_Gives_Another_Royal_Paladin_2000() + { + var engine = InMainPhase(); + engine.State.Players[0].Vanguard = MakeCard(Marron); + + var target = MakeCard(Gallatin); + engine.State.Players[0].RearGuards[0] = target; + + engine.State.Players[0].Hand.Add(MakeCard(Unicorn)); + Assert.True(engine.Execute(new CallCommand(Unicorn, 1)).Accepted); + + Assert.NotNull(engine.State.PendingChoice); + Assert.True(engine.Execute(new ChooseCommand(1)).Accepted); // Авангард, затем Gallatin + + Assert.Equal( + 2000, + target.Modifiers.Where(m => m.EffectId == "starlight-unicorn").Sum(m => m.Power)); + } + + [Fact] + public void Joka_Gets_Power_When_Opponent_RearGuard_Is_Retired() + { + var engine = InMainPhase(); + + var joka = MakeCard(Joka); + engine.State.Players[0].RearGuards[0] = joka; + + engine.State.Players[1].RearGuards[1] = MakeCard(Gallatin); + + engine.RetireRearGuard(1, 1); + + Assert.Equal( + 3000, + joka.Modifiers.Where(m => m.EffectId == "opponent-retire").Sum(m => m.Power)); + } + [Fact] public void ResolveTrigger_Rejects_When_Nothing_Is_Pending() {