diff --git a/IMPLEMENTATION_CHECKLIST.md b/IMPLEMENTATION_CHECKLIST.md
index b662deb..c054ea2 100644
--- a/IMPLEMENTATION_CHECKLIST.md
+++ b/IMPLEMENTATION_CHECKLIST.md
@@ -109,7 +109,8 @@
- [x] Действия: Ride, Call, Attack, Guard, Drive, Damage, Stand/Rest, Retire, Draw.
- [x] Поддержка (Boost) как отдельная способность: по умолчанию у 0–1 ранга, передаёт силу карте впереди в той же колонне; `BoostOverride` позволяет выдать или снять её эффектом.
- [x] Идеальная защита (Sentinel): щит 0; оплата сбросом карты того же клана полностью отменяет попадание; без оплаты не даёт ничего.
-- [ ] Move, Intercept, Discard, Shuffle как отдельные интенты.
+- [x] Перехват: карта 2 ранга из переднего ряда выходит в круг защиты, сила атаки сравнивается с ней; `InterceptOverride` и `InterceptFromBackRow` позволяют выдать способность эффектом.
+- [ ] Move, Discard, Shuffle как отдельные интенты.
- [ ] `driveCheckCount` как модифицируемое свойство (сейчас константа 2).
- [x] Результат боя: `Hit` / `Guarded` как событие.
- [ ] Событийная шина и три обработчика: Continuous / Activated / Triggered.
diff --git a/server/src/Vanguard.Rules/Cards/CardDefinition.cs b/server/src/Vanguard.Rules/Cards/CardDefinition.cs
index 3a9b12a..965d85f 100644
--- a/server/src/Vanguard.Rules/Cards/CardDefinition.cs
+++ b/server/src/Vanguard.Rules/Cards/CardDefinition.cs
@@ -34,6 +34,12 @@ public sealed record CardDefinition
/// Печатная способность поддержки. Эффекты могут изменить её в рантайме.
public bool HasBoost => BoostOverride ?? Grade <= 1;
+ /// Явное переопределение печатного перехвата. null — по умолчанию 2 ранг.
+ public bool? InterceptOverride { get; init; }
+
+ /// Печатный перехват: по умолчанию у карт 2 ранга.
+ public bool HasIntercept => InterceptOverride ?? Grade == 2;
+
public string? TextEn { get; init; }
public string? TextRu { get; init; }
diff --git a/server/src/Vanguard.Rules/Engine/GameCommands.cs b/server/src/Vanguard.Rules/Engine/GameCommands.cs
index fe8fd96..825cf46 100644
--- a/server/src/Vanguard.Rules/Engine/GameCommands.cs
+++ b/server/src/Vanguard.Rules/Engine/GameCommands.cs
@@ -10,6 +10,7 @@ public sealed record CallCommand(string PrintingId, int Slot) : GameCommand;
public sealed record EnterBattleCommand : GameCommand;
public sealed record AttackCommand(int AttackerSlot, int TargetSlot, int BoosterSlot = -1) : GameCommand;
public sealed record GuardCommand(string PrintingId, string? DiscardPrintingId = null) : GameCommand;
+public sealed record InterceptCommand(int Slot) : GameCommand;
public sealed record PassGuardCommand : GameCommand;
public sealed record ResolveTriggerCommand(int PowerTarget, int EffectTarget) : GameCommand;
public sealed record EndTurnCommand : GameCommand;
diff --git a/server/src/Vanguard.Rules/Engine/GameEngine.cs b/server/src/Vanguard.Rules/Engine/GameEngine.cs
index f7258b1..0f34134 100644
--- a/server/src/Vanguard.Rules/Engine/GameEngine.cs
+++ b/server/src/Vanguard.Rules/Engine/GameEngine.cs
@@ -11,6 +11,9 @@ namespace Vanguard.Rules.Engine;
/// атака полностью отменяется; без оплаты не даёт ничего;
/// * поддержка работает как отдельная способность: карты 0 и 1 ранга могут передать
/// свою силу атакующему Арьергарду из той же колонны, сила действует только в этом бою;
+/// * перехват — отдельная способность карт 2 ранга: карта из переднего ряда выходит
+/// в круг защиты, и сила атаки сравнивается с ней; из заднего ряда —
+/// только если эффект разрешил (InterceptFromBackRow);
/// * триггер требует выбора целей через ResolveTriggerCommand:
/// +5000 любой карте в кругах Авангарда или Арьергарда, затем второй эффект;
/// правило одинаково для drive check и damage check;
@@ -109,6 +112,7 @@ public sealed class GameEngine
EnterBattleCommand => EnterBattle(),
AttackCommand c => Attack(c.AttackerSlot, c.TargetSlot, c.BoosterSlot),
GuardCommand c => Guard(c.PrintingId, c.DiscardPrintingId),
+ InterceptCommand c => Intercept(c.Slot),
PassGuardCommand => PassGuard(),
ResolveTriggerCommand c => ResolveTrigger(c),
EndTurnCommand => EndTurn(),
@@ -359,6 +363,49 @@ public sealed class GameEngine
return CommandResult.Ok();
}
+ private CommandResult Intercept(int slot)
+ {
+ var battle = State.Battle;
+ if (State.Step != Step.Guard || battle is null)
+ return CommandResult.Reject("Перехват доступен только на шаге защиты.");
+
+ var defenderIndex = 1 - battle.AttackerPlayer;
+ if (State.ActivePlayer != defenderIndex)
+ return CommandResult.Reject("Перехватывает только обороняющийся.");
+
+ if (battle.TargetSlot < 0)
+ return CommandResult.Reject("Перехват возможен только при атаке по Арьергарду.");
+
+ if (battle.Interceptor is not null)
+ return CommandResult.Reject("Перехват уже объявлен.");
+
+ if (slot is < 0 or >= PlayerState.RearGuardSlots || slot == battle.TargetSlot)
+ return CommandResult.Reject("Некорректный круг для перехвата.");
+
+ var defender = State.Players[defenderIndex];
+ var unit = defender.RearGuards[slot];
+ if (unit is null)
+ return CommandResult.Reject("Круг пуст.");
+
+ if (unit.Rested)
+ return CommandResult.Reject("Карта повёрнута и не может перехватывать.");
+
+ var isFrontRow = slot is 3 or 4;
+ if (!isFrontRow && !unit.InterceptFromBackRow)
+ return CommandResult.Reject("Перехват возможен только из переднего ряда.");
+
+ if (!State.CanIntercept(unit))
+ return CommandResult.Reject("Эта карта не может перехватывать.");
+
+ defender.RearGuards[slot] = null;
+ defender.Guardian.Add(unit);
+ battle.GuardCards.Add(unit);
+ battle.Interceptor = unit;
+
+ _events.Add(new InterceptedEvent(defenderIndex, slot, unit.PrintingId));
+ return CommandResult.Ok();
+ }
+
private CommandResult PassGuard()
{
var battle = State.Battle;
@@ -589,10 +636,11 @@ public sealed class GameEngine
}
var attackerDef = State.Catalog.CardOf(attacker.PrintingId);
- var targetDef = State.Catalog.CardOf(target.PrintingId);
+ var defenseUnit = battle.Interceptor ?? target;
+ var defenseDef = State.Catalog.CardOf(defenseUnit.PrintingId);
var attackPower = attackerDef.Power + attacker.TemporaryPower + battle.BoostPower;
- var defensePower = targetDef.Power + target.TemporaryPower + battle.GuardShield;
+ var defensePower = defenseDef.Power + defenseUnit.TemporaryPower + battle.GuardShield;
var hit = !battle.Nullified && attackPower > defensePower;
_events.Add(new BattleResolvedEvent(hit));
@@ -604,6 +652,11 @@ public sealed class GameEngine
var damage = attackerDef.Critical + attacker.TemporaryCritical;
DealDamage(defenderIndex, damage);
}
+ else if (battle.Interceptor is not null)
+ {
+ // перехватчик уже вышел из круга и уйдёт в сброс при закрытии боя
+ _events.Add(new InterceptorRetiredEvent(defenderIndex, battle.Interceptor.PrintingId));
+ }
else
{
State.Players[defenderIndex].RearGuards[battle.TargetSlot] = null;
diff --git a/server/src/Vanguard.Rules/Engine/GameEvents.cs b/server/src/Vanguard.Rules/Engine/GameEvents.cs
index 5b44380..593e6d4 100644
--- a/server/src/Vanguard.Rules/Engine/GameEvents.cs
+++ b/server/src/Vanguard.Rules/Engine/GameEvents.cs
@@ -14,6 +14,8 @@ public sealed record AttackDeclaredEvent(int Player, int AttackerSlot, int Targe
public sealed record BoostedEvent(int Player, int BoosterSlot, int AttackerSlot) : GameEvent;
public sealed record GuardAddedEvent(int Player, string PrintingId, int Shield) : GameEvent;
public sealed record PerfectGuardEvent(int Player, string SentinelPrintingId, string? DiscardedPrintingId) : GameEvent;
+public sealed record InterceptedEvent(int Player, int Slot, string PrintingId) : GameEvent;
+public sealed record InterceptorRetiredEvent(int Player, string PrintingId) : GameEvent;
public sealed record TriggerResolvedEvent(int Player, string PrintingId, TriggerType Trigger) : GameEvent;
public sealed record TriggerAppliedEvent(int Player, int PowerTarget, int EffectTarget) : GameEvent;
public sealed record BattleResolvedEvent(bool Hit) : GameEvent;
diff --git a/server/src/Vanguard.Rules/Engine/GameModels.cs b/server/src/Vanguard.Rules/Engine/GameModels.cs
index e6ef876..890b705 100644
--- a/server/src/Vanguard.Rules/Engine/GameModels.cs
+++ b/server/src/Vanguard.Rules/Engine/GameModels.cs
@@ -66,6 +66,12 @@ public sealed class CardInstance
/// null — как напечатано на карте.
///
public bool? BoostOverride { get; set; }
+
+ /// Переопределение перехвата, выданное эффектом.
+ public bool? InterceptOverride { get; set; }
+
+ /// Эффект разрешил перехватывать из заднего ряда.
+ public bool InterceptFromBackRow { get; set; }
}
public sealed class PlayerState
diff --git a/server/src/Vanguard.Rules/Engine/GameState.cs b/server/src/Vanguard.Rules/Engine/GameState.cs
index e83a1d9..7050faa 100644
--- a/server/src/Vanguard.Rules/Engine/GameState.cs
+++ b/server/src/Vanguard.Rules/Engine/GameState.cs
@@ -18,6 +18,9 @@ public sealed class BattleState
/// Атака отменена идеальной защитой: попадания не будет при любой силе.
public bool Nullified { get; set; }
+ /// Карта, вышедшая на перехват. Сила сравнения берётся по ней.
+ public CardInstance? Interceptor { get; set; }
+
/// Сила карт поддержки, действует только в этом бою.
public int BoostPower { get; set; }
public List GuardCards { get; } = new();
@@ -61,6 +64,10 @@ public sealed class GameState
public bool CanBoost(CardInstance unit) =>
unit.BoostOverride ?? Definition(unit).HasBoost;
+ /// Итоговая способность перехвата: выданная эффектом важнее печатной.
+ public bool CanIntercept(CardInstance unit) =>
+ unit.InterceptOverride ?? Definition(unit).HasIntercept;
+
public CardInstance? GetUnit(int player, int slot) =>
slot < 0
? Players[player].Vanguard
diff --git a/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs b/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs
index 9f666fb..b630eee 100644
--- a/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs
+++ b/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs
@@ -315,6 +315,73 @@ public class GameEngineTests
Assert.False(engine.Execute(new GuardCommand(Iseult, Nehalem)).Accepted);
}
+ [Fact]
+ public void Intercept_Saves_Target_And_Retires_Interceptor()
+ {
+ var engine = ReadyToAttackRearGuard();
+
+ Assert.True(engine.Execute(new AttackCommand(3, 0)).Accepted);
+ Assert.True(engine.Execute(new InterceptCommand(3)).Accepted);
+ Assert.True(engine.Execute(new PassGuardCommand()).Accepted);
+
+ var defender = engine.State.Players[1];
+ Assert.NotNull(defender.RearGuards[0]); // цель выжила
+ Assert.Null(defender.RearGuards[3]); // перехватчик покинул круг
+ Assert.Contains(defender.Drop, c => c.PrintingId == Gallatin);
+ Assert.Contains(engine.Events, e => e is InterceptedEvent);
+ Assert.Contains(engine.Events, e => e is BattleResolvedEvent { Hit: false });
+ }
+
+ [Fact]
+ public void Without_Intercept_RearGuard_Is_Retired()
+ {
+ var engine = ReadyToAttackRearGuard();
+
+ Assert.True(engine.Execute(new AttackCommand(3, 0)).Accepted);
+ Assert.True(engine.Execute(new PassGuardCommand()).Accepted);
+
+ var defender = engine.State.Players[1];
+ Assert.Null(defender.RearGuards[0]);
+ Assert.Contains(defender.Drop, c => c.PrintingId == Marron);
+ Assert.Contains(engine.Events, e => e is UnitRetiredEvent);
+ }
+
+ [Fact]
+ public void Grade_One_Cannot_Intercept()
+ {
+ var engine = ReadyToAttackRearGuard();
+ var defender = engine.State.Players[1];
+ defender.RearGuards[3] = TakeFromDeck(defender, Marron);
+
+ Assert.True(engine.Execute(new AttackCommand(3, 0)).Accepted);
+ Assert.False(engine.Execute(new InterceptCommand(3)).Accepted);
+ }
+
+ [Fact]
+ public void Back_Row_Cannot_Intercept_By_Default()
+ {
+ var engine = ReadyToAttackRearGuard();
+ var defender = engine.State.Players[1];
+ defender.RearGuards[1] = TakeFromDeck(defender, Gallatin);
+
+ Assert.True(engine.Execute(new AttackCommand(3, 0)).Accepted);
+ Assert.False(engine.Execute(new InterceptCommand(1)).Accepted);
+ }
+
+ [Fact]
+ public void Effect_Can_Grant_Back_Row_Intercept()
+ {
+ var engine = ReadyToAttackRearGuard();
+ var defender = engine.State.Players[1];
+ var unit = TakeFromDeck(defender, Gallatin);
+ unit.InterceptOverride = true;
+ unit.InterceptFromBackRow = true;
+ defender.RearGuards[1] = unit;
+
+ Assert.True(engine.Execute(new AttackCommand(3, 0)).Accepted);
+ Assert.True(engine.Execute(new InterceptCommand(1)).Accepted);
+ }
+
[Fact]
public void ResolveTrigger_Rejects_When_Nothing_Is_Pending()
{
@@ -406,6 +473,24 @@ public class GameEngineTests
return engine;
}
+ private GameEngine ReadyToAttackRearGuard()
+ {
+ var engine = StartedEngine();
+ Give(engine, 0, Marron);
+ engine.Execute(new RideCommand(Marron));
+
+ Give(engine, 0, Gallatin);
+ engine.Execute(new CallCommand(Gallatin, 3));
+ engine.State.Players[0].RearGuards[3]!.Rested = false;
+
+ var defender = engine.State.Players[1];
+ defender.RearGuards[0] = TakeFromDeck(defender, Marron);
+ defender.RearGuards[3] = TakeFromDeck(defender, Gallatin);
+
+ engine.Execute(new EnterBattleCommand());
+ return engine;
+ }
+
private List BuildDeck()
{
var mustInclude = new[] { Barcgal, Marron, Gallatin, Alfred, Nike, Flogal, Govannon, Iseult, Nehalem };