diff --git a/IMPLEMENTATION_CHECKLIST.md b/IMPLEMENTATION_CHECKLIST.md index 25e8e25..9b8ea8a 100644 --- a/IMPLEMENTATION_CHECKLIST.md +++ b/IMPLEMENTATION_CHECKLIST.md @@ -114,19 +114,19 @@ - [ ] Discard, Shuffle как отдельные интенты. - [x] Число проверок принадлежит атакующей карте: 0–2 ранг — одна, 3+ — две; Арьергард по умолчанию без проверок; `DriveChecksOverride` меняет число (в том числе снимает двойную). - [x] Результат боя: `Hit` / `Guarded` как событие. -- [x] Событийная шина: `Triggered` (OnRideOver, OnPlace, OnBoost, OnPhaseStart, OnAttackDeclared, OnAttackHit), `Continuous` с пересчётом при запросе, `Activated` (повернуть себя + сброс карты). +- [x] Событийная шина: `Triggered` (OnRideOver, OnPlace, OnBoost, OnBoosted, OnPhaseStart, OnAttackDeclared, OnAttackHit), `Continuous` с пересчётом при запросе, `Activated` (повернуть себя + сброс карты). - [ ] Событие OnDamageTaken и триггеры на получение урона. - [x] Check Timing: сбор сработавших, порядок (активный игрок первым, выбор игрока), пауза на опциональные способности. - [x] Выбор целей: `PendingChoice` + `ChooseCommand`, используется Blaster Blade для уничтожения Арьергарда противника. -- [ ] Стоимости: CB, SB, discard, rest self, retire self, put to soul, return to deck. -- [x] Выбор цели игроком (`PendingChoice` / `ChooseCommand`); «до N» / «ровно N» ещё нет. +- [x] Стоимости: CB, SB, сброс карты, поворот себя, уничтожение себя; «карты из поля в душу» и «карта из руки в колоду» — частными хелперами. +- [x] Выбор цели игроком (`PendingChoice` / `ChooseCommand`); «до N» / «ровно N» — `AskUnitMultiChoice`. - [x] Выбор карты из руки как стоимости эффекта (Morgana: сброс → +4000). - [x] Модификаторы силы/крита с источником и сроком жизни (`StatModifier`); выдача и снятие способностей эффектами (`GrantedAbility`). - [x] Выбор от `min` до `max` целей на поле (`AskUnitMultiChoice`) для «до N» и «ровно N». - [x] Запреты с источником и сроком жизни (`CardRestriction`): не вставать, не атаковать, не поддерживать, не перехватывать, не быть выбранным; снятие Сдерживания эффектом. - [x] `OnAttackHit` срабатывает и при попадании по Арьергарду. - [x] Поиск в колоде без просмотра: 0 совпадений — уведомление и перемешивание, 1 — сразу в руку, несколько — выбор; разные арты считаются разными картами. -- [x] Поиск с призывом в круг Арьергарда (Alfred) и с райдом (API, Llew). +- [x] Поиск с призывом в круг Арьергарда (Alfred) и с райдом (API готов; Llew и Aleph ждут стоимость «карты из поля в душу»). - [x] Просмотр верхней карты с решением «оставить на верху / положить в низ» (Cocoa). - [~] Слои модификаторов силы/крита: усиления от триггеров живут до конца хода, постоянных слоёв нет. - [x] Валидация интентов: `Rejected(reason)` без изменения состояния. @@ -144,12 +144,15 @@ ## 4. Карты `[15–25 дней]` - [ ] Хелперы: `Draw`, `Retire`, `SearchCall`, `SearchHand`, `SearchRide`, `Stand`, `SoulCharge`, `SoulBlast`, `CounterBlast`, `FlipDamage`, `ToSoul`, `ToDeck`, `Discard`, `LookTop`, `ModifyPower`, `ModifyCrit`, `GainKeyword`. -- [ ] Шаблон карты: 1 файл = 1 `nameId`, комментарий с официальным текстом и ссылкой. -- [ ] Порядок: TD01 → TD02 → BT01 (сначала ванильные, потом со способностями). -- [ ] По каждой карте: скрипт + тест «дано → сыграл → ожидаю». -- [ ] RU/EN тексты для всех 94. -- [ ] Сила/щит для 14 карт, которых нет в BT01. + — большая часть уже есть в `GameEngine`; не хватает общих «карты из поля в душу» и «из руки в колоду» с выбором любых карт. +- [~] Шаблон карты: скрипт = класс `CardScript`, текст карты хранится в YAML (отдельного комментария с текстом нет). +- [x] Порядок: сначала простые триггеры, затем стоимости и активируемые способности. +- [x] По каждой карте: скрипт + тест «дано → сыграл → ожидаю» (для ключевых механик). +- [x] RU/EN тексты для всех 94. +- [x] Сила/щит для 14 карт, которых нет в BT01. - [ ] Дашборд покрытия: `scripted / tested / total`. + — сейчас заскриптовано **34** карты: 13 базовых + 21 в `CardScriptsBT01.cs`. +- [ ] 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 eb1b832..54e16e0 100644 --- a/server/src/Vanguard.Rules/Engine/Abilities/Abilities.cs +++ b/server/src/Vanguard.Rules/Engine/Abilities/Abilities.cs @@ -10,6 +10,7 @@ public enum AbilityTrigger OnPhaseStart, OnAttackDeclared, OnAttackHit, + OnBoosted, OnDamageTaken, } diff --git a/server/src/Vanguard.Rules/Engine/Abilities/CardScripts.cs b/server/src/Vanguard.Rules/Engine/Abilities/CardScripts.cs index 5c32799..38cff3d 100644 --- a/server/src/Vanguard.Rules/Engine/Abilities/CardScripts.cs +++ b/server/src/Vanguard.Rules/Engine/Abilities/CardScripts.cs @@ -21,6 +21,27 @@ public static class CardScripts ["demon-eater"] = new DemonEaterScript(), ["maiden-of-libra"] = new MaidenOfLibraScript(), ["knight-of-rose-morgana"] = new MorganaScript(), + ["battle-sister-mocha"] = new BattleSisterMochaScript(), + ["covenant-knight-randolf"] = new MoreHandsPowerScript("covenant-knight-randolf"), + ["stealth-beast-chigasumi"] = new MoreHandsPowerScript("stealth-beast-chigasumi"), + ["queen-of-heart"] = new NamedBoostScript("queen-of-heart", "king-of-sword"), + ["wyvern-strike-jarran"] = new NamedBoostScript("wyvern-strike-jarran", "wyvern-strike-tejas"), + ["nightmare-baby"] = new NamedBoostScript("nightmare-baby", "blue-dust"), + ["weather-girl-milk"] = new WeatherGirlMilkScript(), + ["hungry-dumpty"] = new FlipDamageOnPlaceScript("hungry-dumpty", Clan.NovaGrappler), + ["clay-doll-mechanic"] = new FlipDamageOnPlaceScript("clay-doll-mechanic", Clan.NovaGrappler), + ["blue-dust"] = new BlueDustScript(), + ["karma-queen"] = new KarmaQueenScript(), + ["hell-spider"] = new HellSpiderScript(), + ["dragonic-overlord"] = new DragonicOverlordScript(), + ["solitary-knight-gancelot"] = new GancelotScript(), + ["embodiment-of-victory-aleph"] = new AlephScript(), + ["oracle-guardian-apollon"] = new ApollonScript(), + ["demon-slaying-knight-lohengrin"] = new LohengrinScript(), + ["vortex-dragon"] = new VortexDragonScript(), + ["ceo-amaterasu"] = new CeoAmaterasuScript(), + ["mr-invincible"] = new MrInvincibleScript(), + ["brutal-jack"] = new BrutalJackScript(), }; } @@ -102,6 +123,8 @@ public sealed class DemonEaterScript : CardScript ModifierDuration.UntilEndOfTurn); }, }; + + yield return Bt01Abilities.RetireAllOnHit(); } } } diff --git a/server/src/Vanguard.Rules/Engine/Abilities/CardScriptsBT01.cs b/server/src/Vanguard.Rules/Engine/Abilities/CardScriptsBT01.cs new file mode 100644 index 0000000..23af091 --- /dev/null +++ b/server/src/Vanguard.Rules/Engine/Abilities/CardScriptsBT01.cs @@ -0,0 +1,660 @@ +using Vanguard.Rules.Cards; + +namespace Vanguard.Rules.Engine.Abilities; + +/// Общие способности, которые встречаются у нескольких карт BT01. +internal static class Bt01Abilities +{ + /// В начале Основной фазы: Soul-Charge 1 и +2000 до конца хода. + public static AbilityDefinition MainPhaseSoulCharge() => new() + { + Trigger = AbilityTrigger.OnPhaseStart, + Condition = ctx => ctx.Engine.IsVanguardOf(ctx.Source, ctx.Owner), + Resolve = ctx => + { + ctx.Engine.SoulCharge(ctx.Owner, 1); + ctx.Engine.AddPower( + ctx.Source, + 2000, + ctx.Source, + "main-phase", + ModifierDuration.UntilEndOfTurn); + }, + }; + + /// При попадании: Soul Blast 8 и Counter Blast 5 → уничтожить все Арьергарды противника. + public static AbilityDefinition RetireAllOnHit() => new() + { + Trigger = AbilityTrigger.OnAttackHit, + Optional = true, + CanPay = ctx => + ctx.Engine.CanSoulBlast(ctx.Owner, 8) && + ctx.Engine.CanCounterBlast(ctx.Owner, 5), + Pay = ctx => + { + ctx.Engine.SoulBlast(ctx.Owner, 8); + ctx.Engine.CounterBlast(ctx.Owner, 5); + }, + Resolve = ctx => ctx.Engine.RetireAllRearGuards(1 - ctx.Owner), + }; + + /// + /// При размещении на (VC) или (RC) Counter Blast 2: выбранный Арьергард противника + /// не встаёт в следующую фазу Подъёма. + /// + public static AbilityDefinition CannotStandOnPlace() => new() + { + Trigger = AbilityTrigger.OnPlace, + Optional = true, + 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, _ => true); + if (slots.Count == 0) + return; + + ctx.Engine.AskUnitChoice( + ctx.Owner, + opponent, + "Выберите Арьергард противника", + slots, + slot => ctx.Engine.AddRestriction( + ctx.State.GetUnit(opponent, slot)!, + RestrictionType.CannotStand, + ctx.Source, + "cannot-stand", + ModifierDuration.UntilOwnerNextStand)); + }, + }; +} + +/// Battle Sister, Mocha: при атаке при четырёх картах в руке получает +3000 до конца боя. +public sealed class BattleSisterMochaScript : CardScript +{ + public override string NameId => "battle-sister-mocha"; + + public override IEnumerable Abilities + { + get + { + yield return new AbilityDefinition + { + Trigger = AbilityTrigger.OnAttackDeclared, + Condition = ctx => ctx.Engine.HandCount(ctx.Owner) >= 4, + Resolve = ctx => ctx.Engine.AddBattlePower(ctx.Source, 3000), + }; + } + } +} + +/// +/// «Когда эта карта атакует, если карт в руке больше, чем у противника, +/// получает +3000 до конца боя»: Covenant Knight, Randolf и Stealth Beast, Chigasumi. +/// +public sealed class MoreHandsPowerScript : CardScript +{ + public MoreHandsPowerScript(string nameId) => NameId = nameId; + + public override string NameId { get; } + + public override IEnumerable Abilities + { + get + { + yield return new AbilityDefinition + { + Trigger = AbilityTrigger.OnAttackDeclared, + Condition = ctx => ctx.Engine.HandCount(ctx.Owner) > ctx.Engine.HandCount(1 - ctx.Owner), + Resolve = ctx => ctx.Engine.AddBattlePower(ctx.Source, 3000), + }; + } + } +} + +/// +/// «Когда эта карта поддерживает указанную карту, усиленная карта получает +4000 +/// до конца боя»: Queen of Heart, Wyvern Strike, Jarran, Nightmare Baby. +/// +public sealed class NamedBoostScript : CardScript +{ + private readonly string _targetNameId; + + public NamedBoostScript(string nameId, string targetNameId) + { + NameId = nameId; + _targetNameId = targetNameId; + } + + public override string NameId { get; } + + 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 == _targetNameId, + Resolve = ctx => ctx.Engine.AddBattlePower(ctx.Other!, 4000, ctx.Source), + }; + } + } +} + +/// +/// Weather Girl, Milk: поддерживает Авангард Oracle Think Tank при четырёх картах +/// в руке — усиленная карта получает +4000 до конца боя. +/// +public sealed class WeatherGirlMilkScript : CardScript +{ + public override string NameId => "weather-girl-milk"; + + public override IEnumerable Abilities + { + get + { + yield return new AbilityDefinition + { + Trigger = AbilityTrigger.OnBoost, + Condition = ctx => + ctx.Other is not null && + ctx.Engine.IsVanguardOf(ctx.Other, ctx.Owner) && + ctx.Catalog.CardOf(ctx.Other.PrintingId).Clan == Clan.OracleThinkTank && + ctx.Engine.HandCount(ctx.Owner) >= 4, + Resolve = ctx => ctx.Engine.AddBattlePower(ctx.Other!, 4000, ctx.Source), + }; + } + } +} + +/// +/// «При размещении на (RC), если Авангард нужного клана, перевернуть одну карту +/// урона лицом вверх»: Hungry Dumpty и Clay Doll Mechanic. +/// +public sealed class FlipDamageOnPlaceScript : CardScript +{ + private readonly Clan _clan; + + public FlipDamageOnPlaceScript(string nameId, Clan clan) + { + NameId = nameId; + _clan = clan; + } + + public override string NameId { get; } + + public override IEnumerable Abilities + { + get + { + yield return new AbilityDefinition + { + Trigger = AbilityTrigger.OnPlace, + Condition = ctx => + !ctx.Engine.IsVanguardOf(ctx.Source, ctx.Owner) && + ctx.Engine.VanguardClan(ctx.Owner) == _clan, + Resolve = ctx => ctx.Engine.TurnOneDamageFaceUp(ctx.Owner), + }; + } + } +} + +/// Blue Dust: при попадании, если Авангард Dark Irregulars, можно Soul-Charge 1. +public sealed class BlueDustScript : CardScript +{ + public override string NameId => "blue-dust"; + + public override IEnumerable Abilities + { + get + { + yield return new AbilityDefinition + { + Trigger = AbilityTrigger.OnAttackHit, + Optional = true, + Condition = ctx => ctx.Engine.VanguardClan(ctx.Owner) == Clan.DarkIrregulars, + Resolve = ctx => ctx.Engine.SoulCharge(ctx.Owner, 1), + }; + } + } +} + +/// Karma Queen: при размещении выбранный Арьергард противника не встаёт. +public sealed class KarmaQueenScript : CardScript +{ + public override string NameId => "karma-queen"; + + public override IEnumerable Abilities + { + get { yield return Bt01Abilities.CannotStandOnPlace(); } + } +} + +/// +/// Hell Spider: +3000, пока все юниты противника повёрнуты; при размещении +/// выбранный Арьергард противника не встаёт. +/// +public sealed class HellSpiderScript : CardScript +{ + public override string NameId => "hell-spider"; + + public override IEnumerable Continuous + { + get + { + yield return new ContinuousAbility + { + Applies = ctx => + ctx.Engine.IsVanguardOf(ctx.Source, ctx.Owner) && + ctx.State.ActivePlayer == ctx.Owner && + ctx.Engine.AllUnitsRested(1 - ctx.Owner), + PowerBonus = _ => 3000, + }; + } + } + + public override IEnumerable Abilities + { + get { yield return Bt01Abilities.CannotStandOnPlace(); } + } +} + +/// +/// Dragonic Overlord: −2000 без другого Kagero; за Counter Blast 3 получает +5000, +/// способность вставать при попадании по Арьергарду и теряет Twin Drive!!. +/// +public sealed class DragonicOverlordScript : CardScript +{ + public override string NameId => "dragonic-overlord"; + + public override IEnumerable Continuous + { + get + { + yield return new ContinuousAbility + { + Applies = ctx => ctx.Engine.CountFieldUnits(ctx.Owner, Clan.Kagero, ctx.Source) == 0, + PowerBonus = _ => -2000, + }; + } + } + + public override IEnumerable Activated + { + get + { + yield return new ActivatedAbility + { + CanActivate = ctx => ctx.Engine.CanCounterBlast(ctx.Owner, 3), + Resolve = ctx => + { + ctx.Engine.CounterBlast(ctx.Owner, 3); + ctx.Engine.AddPower( + ctx.Source, + 5000, + ctx.Source, + "dragonic-overlord", + ModifierDuration.UntilEndOfTurn); + + // теряет Twin Drive!! до конца хода + ctx.Source.DriveChecksOverride = 1; + + ctx.Engine.RemoveGrantedAbilities(g => + g.Source == ctx.Source && g.EffectId == "dragonic-overlord-stand"); + + ctx.Engine.GrantAbility( + ctx.Source, + new AbilityDefinition + { + Trigger = AbilityTrigger.OnAttackHit, + Condition = c => c.Other is not null, + Resolve = c => c.Source.Rested = false, + }, + ctx.Source, + "dragonic-overlord-stand", + ModifierDuration.UntilEndOfTurn); + }, + }; + } + } +} + +/// +/// Solitary Knight, Gancelot: на Авангарде Counter Blast 2, если в душе Blaster Blade, +/// получает +5000/+1 критический до конца хода. +/// +public sealed class GancelotScript : CardScript +{ + public override string NameId => "solitary-knight-gancelot"; + + public override IEnumerable Activated + { + get + { + yield return new ActivatedAbility + { + CanActivate = ctx => + ctx.Engine.IsVanguardOf(ctx.Source, ctx.Owner) && + ctx.Engine.CanCounterBlast(ctx.Owner, 2) && + ctx.Engine.HasInSoul(ctx.Owner, "blaster-blade"), + Resolve = ctx => + { + ctx.Engine.CounterBlast(ctx.Owner, 2); + ctx.Engine.AddPower( + ctx.Source, + 5000, + ctx.Source, + "gancelot", + ModifierDuration.UntilEndOfTurn, + critical: 1); + }, + }; + } + } +} + +/// +/// Embodiment of Victory, Aleph: Counter Blast 4 → +3000/+1 критический до конца хода; +/// на Авангарде — отправить три именованные карты из души в сброс и перевернуть весь урон. +/// +public sealed class AlephScript : CardScript +{ + private static readonly string[] SoulCost = + { + "dragon-knight-aleph", + "embodiment-of-armor-bahr", + "embodiment-of-spear-tahr", + }; + + public override string NameId => "embodiment-of-victory-aleph"; + + public override IEnumerable Activated + { + get + { + yield return new ActivatedAbility + { + CanActivate = ctx => ctx.Engine.CanCounterBlast(ctx.Owner, 4), + Resolve = ctx => + { + ctx.Engine.CounterBlast(ctx.Owner, 4); + ctx.Engine.AddPower( + ctx.Source, + 3000, + ctx.Source, + "aleph", + ModifierDuration.UntilEndOfTurn, + critical: 1); + }, + }; + + yield return new ActivatedAbility + { + CanActivate = ctx => + ctx.Engine.IsVanguardOf(ctx.Source, ctx.Owner) && + SoulCost.All(name => ctx.Engine.HasInSoul(ctx.Owner, name)), + Resolve = ctx => + { + var p = ctx.State.Players[ctx.Owner]; + foreach (var nameId in SoulCost) + { + var card = p.Soul.First(c => + ctx.Catalog.CardOf(c.PrintingId).NameId == nameId); + p.Soul.Remove(card); + p.Drop.Add(card); + } + + ctx.Engine.TurnAllDamageFaceUp(ctx.Owner); + }, + }; + } + } +} + +/// +/// Oracle Guardian, Apollon: при попадании Counter Blast 2. +/// На Авангарде — взять две карты и вернуть одну в колоду, на Арьергарде — взять одну. +/// +public sealed class ApollonScript : CardScript +{ + public override string NameId => "oracle-guardian-apollon"; + + public override IEnumerable Abilities + { + get + { + yield return new AbilityDefinition + { + Trigger = AbilityTrigger.OnAttackHit, + Optional = true, + Condition = ctx => ctx.Engine.IsVanguardOf(ctx.Source, ctx.Owner), + CanPay = ctx => ctx.Engine.CanCounterBlast(ctx.Owner, 2), + Pay = ctx => ctx.Engine.CounterBlast(ctx.Owner, 2), + Resolve = ctx => + { + ctx.Engine.DrawCards(ctx.Owner, 2); + ctx.Engine.ReturnHandCardToDeck(ctx.Owner, "Верните карту из руки в колоду"); + }, + }; + + yield return new AbilityDefinition + { + Trigger = AbilityTrigger.OnAttackHit, + Optional = true, + Condition = ctx => !ctx.Engine.IsVanguardOf(ctx.Source, ctx.Owner), + CanPay = ctx => ctx.Engine.CanCounterBlast(ctx.Owner, 2), + Pay = ctx => ctx.Engine.CounterBlast(ctx.Owner, 2), + Resolve = ctx => ctx.Engine.DrawCards(ctx.Owner, 1), + }; + } + } +} + +/// +/// Demon Slaying Knight, Lohengrin: в начале Основной фазы Soul-Charge 1 и +2000; +/// при попадании Soul Blast 8 и Counter Blast 5 → уничтожить все Арьергарды противника. +/// +public sealed class LohengrinScript : CardScript +{ + public override string NameId => "demon-slaying-knight-lohengrin"; + + public override IEnumerable Abilities + { + get + { + yield return Bt01Abilities.MainPhaseSoulCharge(); + yield return Bt01Abilities.RetireAllOnHit(); + } + } +} + +/// +/// Vortex Dragon: в начале Основной фазы Soul-Charge 1 и +2000; за Soul Blast 8 +/// и Counter Blast 5 выбрать до трёх Арьергардов противника и уничтожить их. +/// +public sealed class VortexDragonScript : CardScript +{ + public override string NameId => "vortex-dragon"; + + public override IEnumerable Abilities + { + get { yield return Bt01Abilities.MainPhaseSoulCharge(); } + } + + public override IEnumerable Activated + { + get + { + yield return new ActivatedAbility + { + CanActivate = ctx => + ctx.Engine.CanSoulBlast(ctx.Owner, 8) && + ctx.Engine.CanCounterBlast(ctx.Owner, 5) && + ctx.Engine.RearGuardSlots(1 - ctx.Owner, _ => true).Count > 0, + Resolve = ctx => + { + ctx.Engine.SoulBlast(ctx.Owner, 8); + ctx.Engine.CounterBlast(ctx.Owner, 5); + + var opponent = 1 - ctx.Owner; + var targets = ctx.Engine.RearGuardSlots(opponent, _ => true) + .Select(slot => new UnitTarget(opponent, slot)) + .ToList(); + + ctx.Engine.AskUnitMultiChoice( + ctx.Owner, + "Выберите до трёх Арьергардов противника", + targets, + 0, + 3, + chosen => + { + foreach (var target in chosen) + ctx.Engine.RetireRearGuard(target.Player, target.Slot); + }); + }, + }; + } + } +} + +/// +/// CEO Amaterasu: +4000 при четырёх картах в руке в свой ход; в начале Основной фазы +/// Soul-Charge 1 и просмотр верха колоды; при попадании Soul Blast 8 и Counter Blast 5 → взять пять. +/// +public sealed class CeoAmaterasuScript : CardScript +{ + public override string NameId => "ceo-amaterasu"; + + public override IEnumerable Continuous + { + get + { + yield return new ContinuousAbility + { + Applies = ctx => + ctx.Engine.IsVanguardOf(ctx.Source, ctx.Owner) && + ctx.State.ActivePlayer == ctx.Owner && + ctx.Engine.HandCount(ctx.Owner) >= 4, + PowerBonus = _ => 4000, + }; + } + } + + public override IEnumerable Abilities + { + get + { + yield return new AbilityDefinition + { + Trigger = AbilityTrigger.OnPhaseStart, + Condition = ctx => ctx.Engine.IsVanguardOf(ctx.Source, ctx.Owner), + Resolve = ctx => + { + ctx.Engine.SoulCharge(ctx.Owner, 1); + ctx.Engine.LookTopAndReorder(ctx.Owner, "Верхняя карта колоды"); + }, + }; + + yield return new AbilityDefinition + { + Trigger = AbilityTrigger.OnAttackHit, + Optional = true, + CanPay = ctx => + ctx.Engine.CanSoulBlast(ctx.Owner, 8) && + ctx.Engine.CanCounterBlast(ctx.Owner, 5), + Pay = ctx => + { + ctx.Engine.SoulBlast(ctx.Owner, 8); + ctx.Engine.CounterBlast(ctx.Owner, 5); + }, + Resolve = ctx => ctx.Engine.DrawCards(ctx.Owner, 5), + }; + } + } +} + +/// +/// Mr. Invincible: в начале Основной фазы Soul-Charge 1 и перевернуть карту урона; +/// при попадании Soul Blast 8 и Counter Blast 5 → поднять все свои юниты. +/// +public sealed class MrInvincibleScript : CardScript +{ + public override string NameId => "mr-invincible"; + + public override IEnumerable Abilities + { + get + { + yield return new AbilityDefinition + { + Trigger = AbilityTrigger.OnPhaseStart, + Condition = ctx => ctx.Engine.IsVanguardOf(ctx.Source, ctx.Owner), + Resolve = ctx => + { + ctx.Engine.SoulCharge(ctx.Owner, 1); + ctx.Engine.TurnOneDamageFaceUp(ctx.Owner); + }, + }; + + yield return new AbilityDefinition + { + Trigger = AbilityTrigger.OnAttackHit, + Optional = true, + CanPay = ctx => + ctx.Engine.CanSoulBlast(ctx.Owner, 8) && + ctx.Engine.CanCounterBlast(ctx.Owner, 5), + Pay = ctx => + { + ctx.Engine.SoulBlast(ctx.Owner, 8); + ctx.Engine.CounterBlast(ctx.Owner, 5); + }, + Resolve = ctx => ctx.Engine.StandAll(ctx.Owner), + }; + } + } +} + +/// +/// Brutal Jack: Сдерживание; за Counter Blast 1 теряет его до конца хода; +/// получает +5000 до конца боя, когда его поддерживает Nova Grappler. +/// +public sealed class BrutalJackScript : CardScript +{ + public override string NameId => "brutal-jack"; + + public override IEnumerable Abilities + { + get + { + yield return new AbilityDefinition + { + Trigger = AbilityTrigger.OnBoosted, + Condition = ctx => + ctx.Other is not null && + ctx.Catalog.CardOf(ctx.Other.PrintingId).Clan == Clan.NovaGrappler, + Resolve = ctx => ctx.Engine.AddBattlePower(ctx.Source, 5000), + }; + } + } + + public override IEnumerable Activated + { + get + { + yield return new ActivatedAbility + { + CanActivate = ctx => ctx.Engine.CanCounterBlast(ctx.Owner, 1), + Resolve = ctx => + { + ctx.Engine.CounterBlast(ctx.Owner, 1); + ctx.Source.RestraintOverride = false; + }, + }; + } + } +} diff --git a/server/src/Vanguard.Rules/Engine/GameEngine.cs b/server/src/Vanguard.Rules/Engine/GameEngine.cs index 516495c..9c2544d 100644 --- a/server/src/Vanguard.Rules/Engine/GameEngine.cs +++ b/server/src/Vanguard.Rules/Engine/GameEngine.cs @@ -28,7 +28,10 @@ namespace Vanguard.Rules.Engine; /// +5000 любой карте в кругах Авангарда или Арьергарда, затем второй эффект; /// правило одинаково для drive check и damage check; /// * порядок проверки: карта → зона триггера → эффекты → рука (drive) или урон (damage); -/// * усиления от триггеров живут до конца хода. +/// * усиления от триггеров живут до конца хода; +/// * атаковать можно Авангард или любой круг Арьергарда: деления на передний +/// и задний ряд при выборе цели пока нет (поэтому Tejas не выделяется); +/// * задний ряд защищён только перехватом, отдельного правила «атакуют лишь передний ряд» нет. /// public sealed class GameEngine { @@ -733,6 +736,85 @@ public sealed class GameEngine _events.Add(new UnitRetiredEvent(player, slot)); } + // ---------- эффекты карт ---------- + + public int HandCount(int player) => State.Players[player].Hand.Count; + + public bool HasInSoul(int player, string nameId) => + State.Players[player].Soul.Any(c => State.Catalog.CardOf(c.PrintingId).NameId == nameId); + + public bool HasInDrop(int player, string nameId) => + State.Players[player].Drop.Any(c => State.Catalog.CardOf(c.PrintingId).NameId == nameId); + + /// Сколько карт клана на поле, не считая указанную. + public int CountFieldUnits(int player, Clan clan, CardInstance? except = null) => + AllUnits(player).Count(u => + !ReferenceEquals(u, except) + && State.Catalog.CardOf(u.PrintingId).Clan == clan); + + public bool AllUnitsRested(int player) => AllUnits(player).All(u => u.Rested); + + /// Перевернуть всю зону урона лицом вверх. + public void TurnAllDamageFaceUp(int player) + { + foreach (var card in State.Players[player].Damage) + card.FaceUp = true; + } + + /// Перевернуть одну закрытую карту урона лицом вверх. false — закрытых нет. + public bool TurnOneDamageFaceUp(int player) + { + var card = State.Players[player].Damage.FirstOrDefault(c => !c.FaceUp); + if (card is null) + return false; + + card.FaceUp = true; + return true; + } + + public void RetireAllRearGuards(int player) + { + for (var slot = 0; slot < PlayerState.RearGuardSlots; slot++) + RetireRearGuard(player, slot); + } + + public void StandAll(int player) + { + foreach (var unit in AllUnits(player)) + unit.Rested = false; + } + + /// Вернуть выбранную карту из руки в колоду и перемешать. + public void ReturnHandCardToDeck(int player, string prompt) + { + if (State.Players[player].Hand.Count == 0) + return; + + AskHandCardChoice(player, prompt, null, card => + { + var p = State.Players[player]; + p.Hand.Remove(card); + p.Deck.Add(card); + ShuffleDeck(player); + _events.Add(new CardReturnedToDeckEvent(player, card.PrintingId)); + }); + } + + /// Противник выбирает карту в руке и сбрасывает её. + public void OpponentDiscardsChosen(int player, string prompt) + { + var p = State.Players[player]; + if (p.Hand.Count == 0) + return; + + AskHandCardChoice(player, prompt, null, card => + { + p.Hand.Remove(card); + p.Drop.Add(card); + _events.Add(new CardDiscardedEvent(player, card.PrintingId)); + }); + } + // ---------- поиск в колоде ---------- /// @@ -1265,6 +1347,35 @@ public sealed class GameEngine TargetSlot = targetSlot, }; + var defenderIndex = 1 - attackerPlayer; + _events.Add(new AttackDeclaredEvent(attackerPlayer, attackerSlot, targetSlot)); + + void BeginGuard() + { + State.Step = Step.Guard; + State.ActivePlayer = defenderIndex; + } + + void AfterAttackDeclared() + { + TriggerFromCard(attackerPlayer, attacker, AbilityTrigger.OnAttackDeclared, null); + + if (State.Step is Step.Ability or Step.Choice) + State.Continuation = BeginGuard; + else + BeginGuard(); + } + + void AfterBoosted() + { + TriggerFromCard(attackerPlayer, attacker, AbilityTrigger.OnBoosted, booster); + + if (State.Step is Step.Ability or Step.Choice) + State.Continuation = AfterAttackDeclared; + else + AfterAttackDeclared(); + } + if (booster is not null) { booster.Rested = true; @@ -1278,23 +1389,16 @@ public sealed class GameEngine _events.Add(new BoostedEvent(attackerPlayer, boosterSlot, attackerSlot)); TriggerFromCard(attackerPlayer, booster, AbilityTrigger.OnBoost, attacker); + + if (State.Step is Step.Ability or Step.Choice) + State.Continuation = AfterBoosted; + else + AfterBoosted(); } - - var defenderIndex = 1 - attackerPlayer; - _events.Add(new AttackDeclaredEvent(attackerPlayer, attackerSlot, targetSlot)); - - void BeginGuard() - { - State.Step = Step.Guard; - State.ActivePlayer = defenderIndex; - } - - TriggerFromCard(attackerPlayer, attacker, AbilityTrigger.OnAttackDeclared, null); - - if (State.Step is Step.Ability or Step.Choice) - State.Continuation = BeginGuard; else - BeginGuard(); + { + AfterAttackDeclared(); + } return CommandResult.Ok(); } @@ -1470,7 +1574,11 @@ public sealed class GameEngine } } - /// Усиления от триггеров живут до конца хода, а не до конца боя. + /// + /// Усиления от триггеров живут до конца хода, а не до конца боя. + /// Переопределения Сдерживания и числа проверок тоже сбрасываются здесь: + /// в BT01 они выдаются только до конца хода. + /// private void ClearTurnModifiers() { foreach (var unit in AllUnits(0).Concat(AllUnits(1))) @@ -1478,6 +1586,8 @@ public sealed class GameEngine unit.Modifiers.RemoveAll(m => m.Duration == ModifierDuration.UntilEndOfTurn); unit.GrantedAbilities.RemoveAll(g => g.Duration == ModifierDuration.UntilEndOfTurn); unit.Restrictions.RemoveAll(r => r.Duration == ModifierDuration.UntilEndOfTurn); + unit.RestraintOverride = null; + unit.DriveChecksOverride = null; } } diff --git a/server/src/Vanguard.Rules/Engine/GameEvents.cs b/server/src/Vanguard.Rules/Engine/GameEvents.cs index 185f2f1..dc6f41b 100644 --- a/server/src/Vanguard.Rules/Engine/GameEvents.cs +++ b/server/src/Vanguard.Rules/Engine/GameEvents.cs @@ -28,6 +28,8 @@ public sealed record UnitRetiredEvent(int Player, int Slot) : GameEvent; public sealed record CardSearchedEvent(int Player, string PrintingId) : GameEvent; public sealed record SearchFailedEvent(int Player, string Query) : GameEvent; public sealed record CardRevealedEvent(int Player, string PrintingId) : GameEvent; +public sealed record CardReturnedToDeckEvent(int Player, string PrintingId) : GameEvent; +public sealed record CardDiscardedEvent(int Player, string PrintingId) : 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/tests/Vanguard.Rules.Tests/GameEngineTests.cs b/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs index 11a5030..096e849 100644 --- a/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs +++ b/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs @@ -28,6 +28,10 @@ public class GameEngineTests private const string Deathrex = "BT01/033EN"; // Tachikaze, 10000 private const string DemonEater = "BT01/036EN"; // Dark Irregulars, 10000 private const string Morgana = "TD01/011EN"; // Royal Paladin, grade 1, discard +4000 + private const string Mocha = "BT01/018EN"; // OTT, +3000 при четырёх картах в руке + private const string HellSpider = "BT01/039EN"; // Megacolony, запрет вставать + private const string BrutalJack = "BT01/029EN"; // Nova Grappler, Сдерживание + private const string ToughBoy = "BT01/060EN"; // Nova Grappler, grade 1 [Fact] public void Start_Places_Starting_Vanguard_And_Draws_Five() @@ -399,6 +403,8 @@ public class GameEngineTests var engine = StartedEngine(); engine.Execute(new SkipRideCommand()); SetVanguard(engine, 0, Overlord); + // второй Kagero, чтобы не срабатывал штраф −2000 + engine.State.Players[0].RearGuards[0] = TakeFromDeck(engine.State.Players[0], Nehalem); StackDeck(engine.State.Players[0], Gallatin, Gallatin); engine.Execute(new EnterBattleCommand()); @@ -1165,6 +1171,105 @@ public class GameEngineTests Assert.Equal(2000, attacker.Modifiers.Where(m => m.EffectId == "hit").Sum(m => m.Power)); } + [Fact] + public void Mocha_Gets_Power_When_Hand_Has_Four_Cards() + { + var engine = StartedEngine(); + engine.Execute(new SkipRideCommand()); + + var mocha = MakeCard(Mocha); + engine.State.Players[0].Vanguard = mocha; + StackDeck(engine.State.Players[0], Gallatin, Gallatin); + + Assert.True(engine.State.Players[0].Hand.Count >= 4); + + engine.Execute(new EnterBattleCommand()); + engine.Execute(new AttackCommand(-1, -1)); + + Assert.Equal(3000, mocha.Modifiers.Where(m => m.EffectId == "ability").Sum(m => m.Power)); + } + + [Fact] + public void HellSpider_Prevents_Chosen_RearGuard_From_Standing() + { + var engine = InMainPhase(); + GiveDamage(engine, 0, 2); + + var opponent = engine.State.Players[1]; + var target = MakeCard(Gallatin); + opponent.RearGuards[0] = target; + + var spider = MakeCard(HellSpider); + engine.State.Players[0].Hand.Add(spider); + + Assert.True(engine.Execute(new CallCommand(HellSpider, 0)).Accepted); + Assert.NotNull(engine.State.PendingAbility); + Assert.True(engine.Execute(new ResolveAbilityCommand(true)).Accepted); + Assert.NotNull(engine.State.PendingChoice); + Assert.True(engine.Execute(new ChooseCommand(0)).Accepted); + + Assert.True(engine.State.HasRestriction(target, RestrictionType.CannotStand)); + Assert.Equal(2, engine.State.Players[0].Damage.Count(c => c.FaceUp)); + } + + [Fact] + public void BrutalJack_Loses_Restraint_And_Gets_Power_From_Nova_Grappler_Boost() + { + var engine = InMainPhase(); + GiveDamage(engine, 0, 1); + + var jack = MakeCard(BrutalJack); + engine.State.Players[0].Hand.Add(jack); + engine.Execute(new CallCommand(BrutalJack, 3)); + + var toughBoy = MakeCard(ToughBoy); + engine.State.Players[0].Hand.Add(toughBoy); + engine.Execute(new CallCommand(ToughBoy, 0)); + + jack.Rested = false; + toughBoy.Rested = false; + + Assert.True(engine.State.IsRestraint(jack)); + Assert.True(engine.Execute(new ActivateAbilityCommand(jack.Id)).Accepted); + Assert.False(engine.State.IsRestraint(jack)); + + Assert.True(engine.Execute(new EnterBattleCommand()).Accepted); + Assert.True(engine.Execute(new AttackCommand(3, -1, 0)).Accepted); + + Assert.Equal(5000, jack.Modifiers.Where(m => m.EffectId == "ability").Sum(m => m.Power)); + } + + [Fact] + public void Overlord_Activation_Adds_Power_Removes_Twin_Drive_And_Stands_On_RearGuard_Hit() + { + var engine = InMainPhase(); + GiveDamage(engine, 0, 3); + + var overlord = MakeCard(Overlord); + engine.State.Players[0].Vanguard = overlord; + + Assert.True(engine.Execute(new ActivateAbilityCommand(overlord.Id)).Accepted); + Assert.Equal(5000, overlord.Modifiers.Where(m => m.EffectId == "dragonic-overlord").Sum(m => m.Power)); + Assert.Equal(1, overlord.DriveChecksOverride); + Assert.Single(overlord.GrantedAbilities); + + StackDeck(engine.State.Players[0], Gallatin); + engine.Execute(new EnterBattleCommand()); + + var opponent = engine.State.Players[1]; + opponent.RearGuards[0] = MakeCard(Marron); + + Assert.True(engine.Execute(new AttackCommand(-1, 0)).Accepted); + Assert.True(engine.Execute(new PassGuardCommand()).Accepted); + + Assert.False(overlord.Rested); + Assert.Null(opponent.RearGuards[0]); + + engine.Execute(new EndTurnCommand()); + Assert.Null(overlord.DriveChecksOverride); + Assert.Empty(overlord.GrantedAbilities); + } + [Fact] public void ResolveTrigger_Rejects_When_Nothing_Is_Pending() { @@ -1419,4 +1524,10 @@ public class GameEngineTests player.Deck.RemoveAt(0); return card; } + + private static int _nextInstanceId = 20_000; + + /// Карта, которой нет в тестовой колоде: кладём в руку или на поле вручную. + private static CardInstance MakeCard(string printingId) => + new() { Id = Interlocked.Increment(ref _nextInstanceId), PrintingId = printingId }; }