diff --git a/IMPLEMENTATION_CHECKLIST.md b/IMPLEMENTATION_CHECKLIST.md
index 9b8ea8a..9a2da44 100644
--- a/IMPLEMENTATION_CHECKLIST.md
+++ b/IMPLEMENTATION_CHECKLIST.md
@@ -114,7 +114,7 @@
- [ ] Discard, Shuffle как отдельные интенты.
- [x] Число проверок принадлежит атакующей карте: 0–2 ранг — одна, 3+ — две; Арьергард по умолчанию без проверок; `DriveChecksOverride` меняет число (в том числе снимает двойную).
- [x] Результат боя: `Hit` / `Guarded` как событие.
-- [x] Событийная шина: `Triggered` (OnRideOver, OnPlace, OnBoost, OnBoosted, OnPhaseStart, OnAttackDeclared, OnAttackHit), `Continuous` с пересчётом при запросе, `Activated` (повернуть себя + сброс карты).
+- [x] Событийная шина: `Triggered` (OnRideOver, OnPlace, OnBoost, OnBoosted, OnDriveCheck, OnLeaveGuardian, OnPhaseStart, OnAttackDeclared, OnAttackHit), `Continuous` с пересчётом при запросе, `Activated` (повернуть себя + сброс карты).
- [ ] Событие OnDamageTaken и триггеры на получение урона.
- [x] Check Timing: сбор сработавших, порядок (активный игрок первым, выбор игрока), пауза на опциональные способности.
- [x] Выбор целей: `PendingChoice` + `ChooseCommand`, используется Blaster Blade для уничтожения Арьергарда противника.
@@ -151,7 +151,9 @@
- [x] RU/EN тексты для всех 94.
- [x] Сила/щит для 14 карт, которых нет в BT01.
- [ ] Дашборд покрытия: `scripted / tested / total`.
- — сейчас заскриптовано **34** карты: 13 базовых + 21 в `CardScriptsBT01.cs`.
+ — сейчас заскриптовано **42** карты: 13 базовых + 29 в `CardScriptsBT01.cs`.
+- [x] Возврат карты с поля в колоду: после боя (Juggernaut, Brakki) и в конце хода (Lozenge, Battleraizer).
+- [x] Триггеры `OnDriveCheck` (Asura Kaiser) и `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 54e16e0..0a2bf48 100644
--- a/server/src/Vanguard.Rules/Engine/Abilities/Abilities.cs
+++ b/server/src/Vanguard.Rules/Engine/Abilities/Abilities.cs
@@ -11,6 +11,8 @@ public enum AbilityTrigger
OnAttackDeclared,
OnAttackHit,
OnBoosted,
+ OnDriveCheck,
+ OnLeaveGuardian,
OnDamageTaken,
}
diff --git a/server/src/Vanguard.Rules/Engine/Abilities/CardScripts.cs b/server/src/Vanguard.Rules/Engine/Abilities/CardScripts.cs
index 38cff3d..215d4b3 100644
--- a/server/src/Vanguard.Rules/Engine/Abilities/CardScripts.cs
+++ b/server/src/Vanguard.Rules/Engine/Abilities/CardScripts.cs
@@ -42,6 +42,14 @@ public static class CardScripts
["ceo-amaterasu"] = new CeoAmaterasuScript(),
["mr-invincible"] = new MrInvincibleScript(),
["brutal-jack"] = new BrutalJackScript(),
+ ["asura-kaiser"] = new AsuraKaiserScript(),
+ ["juggernaut-maximum"] = new ReturnToDeckOnAttackScript("juggernaut-maximum"),
+ ["highspeed-brakki"] = new ReturnToDeckOnAttackScript("highspeed-brakki"),
+ ["lozenge-magus"] = new ReturnToDeckOnBoostScript("lozenge-magus", Clan.OracleThinkTank),
+ ["battleraizer"] = new ReturnToDeckOnBoostScript("battleraizer", Clan.NovaGrappler),
+ ["rock-the-wall"] = new DropToSoulFromGuardianScript("rock-the-wall"),
+ ["redshoe-milly"] = new DropToSoulFromGuardianScript("redshoe-milly"),
+ ["guiding-zombie"] = new GuidingZombieScript(),
};
}
diff --git a/server/src/Vanguard.Rules/Engine/Abilities/CardScriptsBT01.cs b/server/src/Vanguard.Rules/Engine/Abilities/CardScriptsBT01.cs
index 23af091..0b28505 100644
--- a/server/src/Vanguard.Rules/Engine/Abilities/CardScriptsBT01.cs
+++ b/server/src/Vanguard.Rules/Engine/Abilities/CardScriptsBT01.cs
@@ -619,6 +619,200 @@ public sealed class MrInvincibleScript : CardScript
}
}
+///
+/// Asura Kaiser: −2000 без другого Nova Grappler; когда проверка открывает
+/// Nova Grappler 3 ранга, можно поднять один свой Арьергард.
+///
+public sealed class AsuraKaiserScript : CardScript
+{
+ public override string NameId => "asura-kaiser";
+
+ public override IEnumerable Continuous
+ {
+ get
+ {
+ yield return new ContinuousAbility
+ {
+ Applies = ctx => ctx.Engine.CountFieldUnits(ctx.Owner, Clan.NovaGrappler, ctx.Source) == 0,
+ PowerBonus = _ => -2000,
+ };
+ }
+ }
+
+ 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.NovaGrappler },
+ 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.State.GetUnit(ctx.Owner, slot)!.Rested = false);
+ },
+ };
+ }
+ }
+}
+
+///
+/// Juggernaut Maximum и Highspeed Brakki: на (RC) Soul Blast 1 → +5000 до конца боя,
+/// в начале завершающего шага боя карта возвращается в колоду.
+///
+public sealed class ReturnToDeckOnAttackScript : CardScript
+{
+ public ReturnToDeckOnAttackScript(string nameId) => NameId = nameId;
+
+ public override string NameId { get; }
+
+ public override IEnumerable Abilities
+ {
+ get
+ {
+ yield return new AbilityDefinition
+ {
+ Trigger = AbilityTrigger.OnAttackDeclared,
+ Optional = true,
+ Condition = ctx => !ctx.Engine.IsVanguardOf(ctx.Source, ctx.Owner),
+ CanPay = ctx => ctx.Engine.CanSoulBlast(ctx.Owner, 1),
+ Pay = ctx => ctx.Engine.SoulBlast(ctx.Owner, 1),
+ Resolve = ctx =>
+ {
+ ctx.Engine.AddBattlePower(ctx.Source, 5000);
+ ctx.Source.ReturnToDeck = ReturnToDeckTiming.AfterBattle;
+ },
+ };
+ }
+ }
+}
+
+///
+/// Lozenge Magus и Battleraizer: при смене можно призвать себя; при поддержке
+/// усиленный получает +3000 до конца боя, а в начале Завершающей фазы карта
+/// возвращается в колоду.
+///
+public sealed class ReturnToDeckOnBoostScript : CardScript
+{
+ private readonly Clan _clan;
+
+ public ReturnToDeckOnBoostScript(string nameId, Clan clan)
+ {
+ NameId = nameId;
+ _clan = clan;
+ }
+
+ public override string NameId { get; }
+
+ public override IEnumerable Abilities
+ {
+ get
+ {
+ yield return new AbilityDefinition
+ {
+ Trigger = AbilityTrigger.OnRideOver,
+ Optional = true,
+ Condition = ctx =>
+ ctx.Other is not null &&
+ ctx.Catalog.CardOf(ctx.Other.PrintingId).Clan == _clan,
+ Resolve = ctx => ctx.Engine.CallFromSoul(ctx.Source, ctx.Owner),
+ };
+
+ yield return new AbilityDefinition
+ {
+ Trigger = AbilityTrigger.OnBoost,
+ Condition = ctx => ctx.Other is not null,
+ Resolve = ctx =>
+ {
+ ctx.Engine.AddBattlePower(ctx.Other!, 3000, ctx.Source);
+ ctx.Source.ReturnToDeck = ReturnToDeckTiming.EndOfTurn;
+ },
+ };
+ }
+ }
+}
+
+///
+/// Rock the Wall и Redshoe Milly: попав в сброс из круга защиты, карта уходит в душу.
+///
+public sealed class DropToSoulFromGuardianScript : CardScript
+{
+ public DropToSoulFromGuardianScript(string nameId) => NameId = nameId;
+
+ public override string NameId { get; }
+
+ public override IEnumerable Abilities
+ {
+ get
+ {
+ yield return new AbilityDefinition
+ {
+ Trigger = AbilityTrigger.OnLeaveGuardian,
+ Resolve = ctx => ctx.Engine.MoveDropToSoul(ctx.Owner, ctx.Source),
+ };
+ }
+ }
+}
+
+///
+/// Guiding Zombie: при смене можно призвать себя; на (RC) положить себя в душу
+/// и отправить три верхние карты колоды в сброс.
+///
+public sealed class GuidingZombieScript : CardScript
+{
+ public override string NameId => "guiding-zombie";
+
+ public override IEnumerable Abilities
+ {
+ get
+ {
+ yield return new AbilityDefinition
+ {
+ Trigger = AbilityTrigger.OnRideOver,
+ Optional = true,
+ Condition = ctx =>
+ ctx.Other is not null &&
+ ctx.Catalog.CardOf(ctx.Other.PrintingId).Clan == Clan.Granblue,
+ Resolve = ctx => ctx.Engine.CallFromSoul(ctx.Source, ctx.Owner),
+ };
+ }
+ }
+
+ public override IEnumerable Activated
+ {
+ get
+ {
+ yield return new ActivatedAbility
+ {
+ CanActivate = _ => true,
+ Resolve = ctx =>
+ {
+ var p = ctx.State.Players[ctx.Owner];
+ var slot = Array.IndexOf(p.RearGuards, ctx.Source);
+ if (slot < 0)
+ return;
+
+ p.RearGuards[slot] = null;
+ p.Soul.Add(ctx.Source);
+ ctx.Engine.MillTop(ctx.Owner, 3);
+ },
+ };
+ }
+ }
+}
+
///
/// Brutal Jack: Сдерживание; за Counter Blast 1 теряет его до конца хода;
/// получает +5000 до конца боя, когда его поддерживает Nova Grappler.
diff --git a/server/src/Vanguard.Rules/Engine/GameEngine.cs b/server/src/Vanguard.Rules/Engine/GameEngine.cs
index 9c2544d..706be5c 100644
--- a/server/src/Vanguard.Rules/Engine/GameEngine.cs
+++ b/server/src/Vanguard.Rules/Engine/GameEngine.cs
@@ -815,6 +815,46 @@ public sealed class GameEngine
});
}
+ /// Положить верхние карты колоды в сброс.
+ public void MillTop(int player, int count)
+ {
+ var p = State.Players[player];
+ for (var i = 0; i < count && p.Deck.Count > 0; i++)
+ {
+ var card = p.Deck[0];
+ p.Deck.RemoveAt(0);
+ p.Drop.Add(card);
+ }
+ }
+
+ /// Переместить карту из сброса в душу (Rock the Wall, Redshoe Milly).
+ public bool MoveDropToSoul(int player, CardInstance card)
+ {
+ var p = State.Players[player];
+ if (!p.Drop.Remove(card))
+ return false;
+
+ p.Soul.Add(card);
+ _events.Add(new CardMovedToSoulEvent(player, card.PrintingId));
+ return true;
+ }
+
+ /// Вернуть карту с поля в колоду и перемешать. Авангард так вернуть нельзя.
+ public bool ReturnUnitToDeck(int player, CardInstance unit)
+ {
+ var p = State.Players[player];
+ var slot = Array.IndexOf(p.RearGuards, unit);
+ if (slot < 0)
+ return false;
+
+ p.RearGuards[slot] = null;
+ unit.ReturnToDeck = null;
+ p.Deck.Add(unit);
+ ShuffleDeck(player);
+ _events.Add(new UnitReturnedToDeckEvent(player, unit.PrintingId));
+ return true;
+ }
+
// ---------- поиск в колоде ----------
///
@@ -1521,6 +1561,16 @@ public sealed class GameEngine
State.Phase = Phase.End;
_events.Add(new PhaseChangedEvent(State.ActivePlayer, Phase.End));
+ // «в начале Завершающей фазы вернуть в колоду»
+ foreach (var player in new[] { 0, 1 })
+ {
+ foreach (var unit in AllUnits(player).ToList())
+ {
+ if (unit.ReturnToDeck == ReturnToDeckTiming.EndOfTurn)
+ ReturnUnitToDeck(player, unit);
+ }
+ }
+
ClearTurnModifiers();
if (State.IsFinished)
@@ -1638,6 +1688,16 @@ public sealed class GameEngine
State.Players[player].Hand.Add(card);
_events.Add(new CardDrawnEvent(player));
battle.ChecksRemaining--;
+
+ var checkedAttacker = State.GetUnit(player, battle.AttackerSlot);
+ TriggerFromCard(player, checkedAttacker, AbilityTrigger.OnDriveCheck, card);
+
+ if (State.Step is Step.Ability or Step.Choice)
+ {
+ State.Continuation = () => ContinueDriveChecks(battle);
+ return;
+ }
+
continue;
}
@@ -1833,11 +1893,25 @@ public sealed class GameEngine
private void ClearBattle(BattleState battle)
{
- var defender = State.Players[1 - battle.AttackerPlayer];
+ var defenderIndex = 1 - battle.AttackerPlayer;
+ var defender = State.Players[defenderIndex];
+
+ var leftGuardian = new List();
foreach (var guard in battle.GuardCards)
{
defender.Guardian.Remove(guard);
defender.Drop.Add(guard);
+ leftGuardian.Add(guard);
+ }
+
+ // карты, которые должны вернуться в колоду сразу после боя
+ foreach (var player in new[] { 0, 1 })
+ {
+ foreach (var unit in AllUnits(player).ToList())
+ {
+ if (unit.ReturnToDeck == ReturnToDeckTiming.AfterBattle)
+ ReturnUnitToDeck(player, unit);
+ }
}
foreach (var unit in AllUnits(0).Concat(AllUnits(1)))
@@ -1851,6 +1925,10 @@ public sealed class GameEngine
State.PendingTrigger = null;
State.Step = Step.None;
State.ActivePlayer = battle.AttackerPlayer;
+
+ // бой уже закрыт: эффекты «попала в сброс из (GC)» срабатывают после
+ foreach (var guard in leftGuardian)
+ TriggerFromCard(defenderIndex, guard, AbilityTrigger.OnLeaveGuardian, null);
}
private void DealDamage(int player, int amount)
diff --git a/server/src/Vanguard.Rules/Engine/GameEvents.cs b/server/src/Vanguard.Rules/Engine/GameEvents.cs
index dc6f41b..6a38ba9 100644
--- a/server/src/Vanguard.Rules/Engine/GameEvents.cs
+++ b/server/src/Vanguard.Rules/Engine/GameEvents.cs
@@ -30,6 +30,8 @@ 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 UnitReturnedToDeckEvent(int Player, string PrintingId) : GameEvent;
+public sealed record CardMovedToSoulEvent(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/src/Vanguard.Rules/Engine/GameModels.cs b/server/src/Vanguard.Rules/Engine/GameModels.cs
index 789889f..5f028da 100644
--- a/server/src/Vanguard.Rules/Engine/GameModels.cs
+++ b/server/src/Vanguard.Rules/Engine/GameModels.cs
@@ -47,6 +47,13 @@ public enum TriggerContext
DamageCheck,
}
+/// Когда карта, находившаяся на поле, возвращается в колоду.
+public enum ReturnToDeckTiming
+{
+ AfterBattle,
+ EndOfTurn,
+}
+
/// Триггер, который ждёт выбора целей от владельца.
public sealed class PendingTrigger
{
@@ -84,6 +91,9 @@ public sealed class CardInstance
/// Запреты, выданные эффектами.
public List Restrictions { get; } = new();
+ /// Вернуть карту в колоду после боя или в конце хода (Juggernaut, Lozenge).
+ public ReturnToDeckTiming? ReturnToDeck { get; set; }
+
///
/// Переопределение Сдерживания, выданное эффектом.
/// null — как напечатано на карте.
diff --git a/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs b/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs
index 096e849..468b1a9 100644
--- a/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs
+++ b/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs
@@ -32,6 +32,10 @@ public class GameEngineTests
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
+ private const string Asura = "BT01/008EN"; // Nova Grappler, grade 3
+ private const string Juggernaut = "BT01/020EN"; // Spike Brothers, возврат в колоду
+ private const string RockTheWall = "BT01/073EN";
+ private const string GuidingZombie = "BT01/078EN";
[Fact]
public void Start_Places_Starting_Vanguard_And_Draws_Five()
@@ -1270,6 +1274,91 @@ public class GameEngineTests
Assert.Empty(overlord.GrantedAbilities);
}
+ [Fact]
+ public void Asura_Kaiser_Stands_RearGuard_On_Grade3_Drive_Check()
+ {
+ var engine = StartedEngine();
+ engine.Execute(new SkipRideCommand());
+
+ var asura = MakeCard(Asura);
+ engine.State.Players[0].Vanguard = asura;
+
+ var rear = MakeCard(ToughBoy);
+ engine.State.Players[0].RearGuards[0] = rear;
+ rear.Rested = true;
+
+ // проверка открывает Mr. Invincible (Nova Grappler 3 ранга), затем обычную карту
+ engine.State.Players[0].Deck.Insert(0, MakeCard("BT01/028EN"));
+ engine.State.Players[0].Deck.Insert(1, MakeCard(Gallatin));
+ engine.State.Players[1].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.False(rear.Rested);
+ }
+
+ [Fact]
+ public void Juggernaut_Returns_To_Deck_After_Battle()
+ {
+ var engine = InMainPhase();
+ var juggernaut = MakeCard(Juggernaut);
+ engine.State.Players[0].RearGuards[3] = juggernaut;
+ juggernaut.Rested = false;
+ engine.SoulCharge(0, 1);
+
+ StackDeck(engine.State.Players[1], Gallatin);
+
+ engine.Execute(new EnterBattleCommand());
+ Assert.True(engine.Execute(new AttackCommand(3, -1)).Accepted);
+ Assert.NotNull(engine.State.PendingAbility);
+ Assert.True(engine.Execute(new ResolveAbilityCommand(true)).Accepted);
+ Assert.True(engine.Execute(new PassGuardCommand()).Accepted);
+
+ Assert.Null(engine.State.Players[0].RearGuards[3]);
+ Assert.Contains(engine.State.Players[0].Deck, c => c.PrintingId == Juggernaut);
+ }
+
+ [Fact]
+ public void Rock_The_Wall_Moves_To_Soul_After_Leaving_Guardian()
+ {
+ var engine = ReadyToAttack();
+ StackDeck(engine.State.Players[0], Gallatin, Gallatin);
+
+ engine.State.Players[1].Hand.Add(MakeCard(RockTheWall));
+
+ Assert.True(engine.Execute(new AttackCommand(-1, -1)).Accepted);
+ Assert.True(engine.Execute(new GuardCommand(RockTheWall)).Accepted);
+ Assert.True(engine.Execute(new PassGuardCommand()).Accepted);
+
+ var defender = engine.State.Players[1];
+ Assert.Contains(defender.Soul, c => c.PrintingId == RockTheWall);
+ Assert.DoesNotContain(defender.Drop, c => c.PrintingId == RockTheWall);
+ }
+
+ [Fact]
+ public void Guiding_Zombie_Puts_Itself_Into_Soul_And_Mills_Three()
+ {
+ var engine = InMainPhase();
+ var zombie = MakeCard(GuidingZombie);
+ engine.State.Players[0].Hand.Add(zombie);
+
+ Assert.True(engine.Execute(new CallCommand(GuidingZombie, 0)).Accepted);
+ zombie.Rested = false;
+
+ var deckBefore = engine.State.Players[0].Deck.Count;
+
+ Assert.True(engine.Execute(new ActivateAbilityCommand(zombie.Id)).Accepted);
+
+ Assert.Null(engine.State.Players[0].RearGuards[0]);
+ Assert.Contains(engine.State.Players[0].Soul, c => c.PrintingId == GuidingZombie);
+ Assert.Equal(3, deckBefore - engine.State.Players[0].Deck.Count);
+ }
+
[Fact]
public void ResolveTrigger_Rejects_When_Nothing_Is_Pending()
{