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 };