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 };
}