diff --git a/IMPLEMENTATION_CHECKLIST.md b/IMPLEMENTATION_CHECKLIST.md
index e286d3f..b662deb 100644
--- a/IMPLEMENTATION_CHECKLIST.md
+++ b/IMPLEMENTATION_CHECKLIST.md
@@ -108,6 +108,7 @@
- [x] Старт: выбор grade 0 → выставление → маллиган.
- [x] Действия: Ride, Call, Attack, Guard, Drive, Damage, Stand/Rest, Retire, Draw.
- [x] Поддержка (Boost) как отдельная способность: по умолчанию у 0–1 ранга, передаёт силу карте впереди в той же колонне; `BoostOverride` позволяет выдать или снять её эффектом.
+- [x] Идеальная защита (Sentinel): щит 0; оплата сбросом карты того же клана полностью отменяет попадание; без оплаты не даёт ничего.
- [ ] Move, Intercept, Discard, Shuffle как отдельные интенты.
- [ ] `driveCheckCount` как модифицируемое свойство (сейчас константа 2).
- [x] Результат боя: `Hit` / `Guarded` как событие.
diff --git a/server/src/Vanguard.Rules/Engine/GameCommands.cs b/server/src/Vanguard.Rules/Engine/GameCommands.cs
index bbe743c..fe8fd96 100644
--- a/server/src/Vanguard.Rules/Engine/GameCommands.cs
+++ b/server/src/Vanguard.Rules/Engine/GameCommands.cs
@@ -9,7 +9,7 @@ public sealed record SkipRideCommand : GameCommand;
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) : GameCommand;
+public sealed record GuardCommand(string PrintingId, string? DiscardPrintingId = null) : 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 ff78dc7..f7258b1 100644
--- a/server/src/Vanguard.Rules/Engine/GameEngine.cs
+++ b/server/src/Vanguard.Rules/Engine/GameEngine.cs
@@ -7,7 +7,8 @@ namespace Vanguard.Rules.Engine;
/// Ядро партии. Не знает ни о сети, ни о БД: на вход команды, на выход события.
///
/// Упрощения первого среза (карты со способностями подключаются следующим шагом):
-/// * Sentinel добавляет 0 щита и не оплачивает стоимость — Perfect Guard пока не работает;
+/// * идеальная защита (Sentinel): щит 0, но при оплате стоимости (сброс карты того же клана)
+/// атака полностью отменяется; без оплаты не даёт ничего;
/// * поддержка работает как отдельная способность: карты 0 и 1 ранга могут передать
/// свою силу атакующему Арьергарду из той же колонны, сила действует только в этом бою;
/// * триггер требует выбора целей через ResolveTriggerCommand:
@@ -107,7 +108,7 @@ public sealed class GameEngine
CallCommand c => Call(c.PrintingId, c.Slot),
EnterBattleCommand => EnterBattle(),
AttackCommand c => Attack(c.AttackerSlot, c.TargetSlot, c.BoosterSlot),
- GuardCommand c => Guard(c.PrintingId),
+ GuardCommand c => Guard(c.PrintingId, c.DiscardPrintingId),
PassGuardCommand => PassGuard(),
ResolveTriggerCommand c => ResolveTrigger(c),
EndTurnCommand => EndTurn(),
@@ -304,7 +305,7 @@ public sealed class GameEngine
return CommandResult.Ok();
}
- private CommandResult Guard(string printingId)
+ private CommandResult Guard(string printingId, string? discardPrintingId = null)
{
var battle = State.Battle;
if (State.Step != Step.Guard || battle is null)
@@ -321,14 +322,40 @@ public sealed class GameEngine
var card = defender.Hand[index];
var def = State.Catalog.CardOf(card.PrintingId);
+
+ if (!def.IsSentinel && discardPrintingId is not null)
+ return CommandResult.Reject("Эта карта не требует оплаты стоимости.");
+
+ if (def.IsSentinel && discardPrintingId is not null)
+ {
+ var discardIndex = defender.Hand.FindIndex(c => c.PrintingId == discardPrintingId);
+ if (discardIndex < 0)
+ return CommandResult.Reject("Карты для оплаты нет в руке.");
+
+ if (State.Catalog.CardOf(defender.Hand[discardIndex].PrintingId).Clan != def.Clan)
+ return CommandResult.Reject("Для оплаты нужна карта того же клана.");
+ }
+
+ // Sentinel сам по себе даёт ноль щита: либо оплата и отмена атаки, либо ничего
var shield = def.IsSentinel ? 0 : def.Shield;
defender.Hand.RemoveAt(index);
defender.Guardian.Add(card);
battle.GuardCards.Add(card);
battle.GuardShield += shield;
-
_events.Add(new GuardAddedEvent(defenderIndex, card.PrintingId, shield));
+
+ if (def.IsSentinel && discardPrintingId is not null)
+ {
+ var discardIndex = defender.Hand.FindIndex(c => c.PrintingId == discardPrintingId);
+ var discarded = defender.Hand[discardIndex];
+ defender.Hand.RemoveAt(discardIndex);
+ defender.Drop.Add(discarded);
+
+ battle.Nullified = true;
+ _events.Add(new PerfectGuardEvent(defenderIndex, card.PrintingId, discarded.PrintingId));
+ }
+
return CommandResult.Ok();
}
@@ -566,7 +593,7 @@ public sealed class GameEngine
var attackPower = attackerDef.Power + attacker.TemporaryPower + battle.BoostPower;
var defensePower = targetDef.Power + target.TemporaryPower + battle.GuardShield;
- var hit = attackPower > defensePower;
+ var hit = !battle.Nullified && attackPower > defensePower;
_events.Add(new BattleResolvedEvent(hit));
diff --git a/server/src/Vanguard.Rules/Engine/GameEvents.cs b/server/src/Vanguard.Rules/Engine/GameEvents.cs
index f9ecb6c..5b44380 100644
--- a/server/src/Vanguard.Rules/Engine/GameEvents.cs
+++ b/server/src/Vanguard.Rules/Engine/GameEvents.cs
@@ -13,6 +13,7 @@ public sealed record UnitCalledEvent(int Player, int Slot, string PrintingId) :
public sealed record AttackDeclaredEvent(int Player, int AttackerSlot, int TargetSlot) : GameEvent;
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 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/GameState.cs b/server/src/Vanguard.Rules/Engine/GameState.cs
index 0efcf8c..e83a1d9 100644
--- a/server/src/Vanguard.Rules/Engine/GameState.cs
+++ b/server/src/Vanguard.Rules/Engine/GameState.cs
@@ -15,6 +15,9 @@ public sealed class BattleState
public int GuardShield { get; set; }
public int ChecksRemaining { get; set; }
+ /// Атака отменена идеальной защитой: попадания не будет при любой силе.
+ public bool Nullified { get; set; }
+
/// Сила карт поддержки, действует только в этом бою.
public int BoostPower { get; set; }
public List GuardCards { get; } = new();
diff --git a/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs b/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs
index 65d2aec..9f666fb 100644
--- a/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs
+++ b/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs
@@ -15,6 +15,8 @@ public class GameEngineTests
private const string Nike = "BT01/056EN"; // Critical
private const string Flogal = "BT01/046EN"; // Stand
private const string Govannon = "BT01/045EN"; // Draw
+ private const string Iseult = "BT01/011EN"; // Sentinel, Royal Paladin
+ private const string Nehalem = "BT01/022EN"; // Kagero
[Fact]
public void Start_Places_Starting_Vanguard_And_Draws_Five()
@@ -270,6 +272,49 @@ public class GameEngineTests
Assert.False(engine.Execute(new AttackCommand(3, -1, 0)).Accepted);
}
+ [Fact]
+ public void Perfect_Guard_Nullifies_Attack_When_Cost_Paid()
+ {
+ var engine = ReadyToAttack();
+ StackDeck(engine.State.Players[0], Gallatin, Gallatin);
+ Give(engine, 1, Iseult);
+ Give(engine, 1, Marron);
+
+ Assert.True(engine.Execute(new AttackCommand(-1, -1)).Accepted);
+ Assert.True(engine.Execute(new GuardCommand(Iseult, Marron)).Accepted);
+ Assert.True(engine.Execute(new PassGuardCommand()).Accepted);
+
+ Assert.Empty(engine.State.Players[1].Damage);
+ Assert.Contains(engine.State.Players[1].Drop, c => c.PrintingId == Marron);
+ Assert.Contains(engine.Events, e => e is PerfectGuardEvent);
+ }
+
+ [Fact]
+ public void Perfect_Guard_Without_Cost_Adds_No_Shield()
+ {
+ var engine = ReadyToAttack();
+ StackDeck(engine.State.Players[0], Gallatin, Gallatin);
+ Give(engine, 1, Iseult);
+
+ Assert.True(engine.Execute(new AttackCommand(-1, -1)).Accepted);
+ Assert.True(engine.Execute(new GuardCommand(Iseult)).Accepted);
+ Assert.True(engine.Execute(new PassGuardCommand()).Accepted);
+
+ Assert.Single(engine.State.Players[1].Damage);
+ Assert.DoesNotContain(engine.Events, e => e is PerfectGuardEvent);
+ }
+
+ [Fact]
+ public void Perfect_Guard_Rejects_Wrong_Clan_Cost()
+ {
+ var engine = ReadyToAttack();
+ Give(engine, 1, Iseult);
+ Give(engine, 1, Nehalem);
+
+ Assert.True(engine.Execute(new AttackCommand(-1, -1)).Accepted);
+ Assert.False(engine.Execute(new GuardCommand(Iseult, Nehalem)).Accepted);
+ }
+
[Fact]
public void ResolveTrigger_Rejects_When_Nothing_Is_Pending()
{
@@ -363,7 +408,7 @@ public class GameEngineTests
private List BuildDeck()
{
- var mustInclude = new[] { Barcgal, Marron, Gallatin, Alfred, Nike, Flogal, Govannon };
+ var mustInclude = new[] { Barcgal, Marron, Gallatin, Alfred, Nike, Flogal, Govannon, Iseult, Nehalem };
var names = mustInclude
.Select(_catalog.CardOf)
.Select(c => c.NameId)