From 830785a8d691b399c47575848be6b00e03317f39 Mon Sep 17 00:00:00 2001 From: MpakobecEx Date: Wed, 21 Oct 2026 00:37:05 +0500 Subject: [PATCH] Perfect Guard: pay the cost to nullify the attack, otherwise zero shield GuardCommand takes an optional discard. Sentinel cards have zero shield; paying with a card of the same clan sets Nullified on the battle so power no longer matters. Three tests: paid, unpaid, wrong clan. --- IMPLEMENTATION_CHECKLIST.md | 1 + .../src/Vanguard.Rules/Engine/GameCommands.cs | 2 +- .../src/Vanguard.Rules/Engine/GameEngine.cs | 37 +++++++++++++-- .../src/Vanguard.Rules/Engine/GameEvents.cs | 1 + server/src/Vanguard.Rules/Engine/GameState.cs | 3 ++ .../Vanguard.Rules.Tests/GameEngineTests.cs | 47 ++++++++++++++++++- 6 files changed, 84 insertions(+), 7 deletions(-) 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)