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()
{