From bf2ea337f8ec201a7f359d27b1c413a8991c5d0c Mon Sep 17 00:00:00 2001 From: MpakobecEx Date: Wed, 21 Oct 2026 00:47:44 +0500 Subject: [PATCH] Drive checks belong to the attacking card Grade 0-2 vanguard performs one check, grade 3+ performs two, rear-guards perform none by default. CardInstance.DriveChecksOverride lets effects remove Twin Drive or grant checks to a rear-guard. Added DriveCheckedEvent for the log. Five tests. --- IMPLEMENTATION_CHECKLIST.md | 2 +- .../Vanguard.Rules/Cards/CardDefinition.cs | 6 ++ .../src/Vanguard.Rules/Engine/GameEngine.cs | 29 +++++++- .../src/Vanguard.Rules/Engine/GameEvents.cs | 1 + .../src/Vanguard.Rules/Engine/GameModels.cs | 6 ++ .../Vanguard.Rules.Tests/GameEngineTests.cs | 67 +++++++++++++++++++ 6 files changed, 107 insertions(+), 4 deletions(-) diff --git a/IMPLEMENTATION_CHECKLIST.md b/IMPLEMENTATION_CHECKLIST.md index b9dda45..99b01cd 100644 --- a/IMPLEMENTATION_CHECKLIST.md +++ b/IMPLEMENTATION_CHECKLIST.md @@ -112,7 +112,7 @@ - [x] Перехват: карта 2 ранга из переднего ряда выходит в круг защиты, сила атаки сравнивается с ней; `InterceptOverride` и `InterceptFromBackRow` позволяют выдать способность эффектом. - [x] Перемещение Арьергарда в пределах колонки; если круг занят — карты меняются местами; у центрального заднего ряда пары нет. - [ ] Discard, Shuffle как отдельные интенты. -- [ ] `driveCheckCount` как модифицируемое свойство (сейчас константа 2). +- [x] Число проверок принадлежит атакующей карте: 0–2 ранг — одна, 3+ — две; Арьергард по умолчанию без проверок; `DriveChecksOverride` меняет число (в том числе снимает двойную). - [x] Результат боя: `Hit` / `Guarded` как событие. - [ ] Событийная шина и три обработчика: Continuous / Activated / Triggered. - [ ] Check Timing: сбор → сортировка → разрешение по одному → повтор. diff --git a/server/src/Vanguard.Rules/Cards/CardDefinition.cs b/server/src/Vanguard.Rules/Cards/CardDefinition.cs index 965d85f..86e2420 100644 --- a/server/src/Vanguard.Rules/Cards/CardDefinition.cs +++ b/server/src/Vanguard.Rules/Cards/CardDefinition.cs @@ -40,6 +40,12 @@ public sealed record CardDefinition /// Печатный перехват: по умолчанию у карт 2 ранга. public bool HasIntercept => InterceptOverride ?? Grade == 2; + /// + /// Печатное число проверок при атаке из круга Авангарда: + /// 0–2 ранг — одна, 3 ранг и выше — двойная. + /// + public int DefaultDriveChecks => Grade >= 3 ? 2 : 1; + public string? TextEn { get; init; } public string? TextRu { get; init; } diff --git a/server/src/Vanguard.Rules/Engine/GameEngine.cs b/server/src/Vanguard.Rules/Engine/GameEngine.cs index ced8234..b529f73 100644 --- a/server/src/Vanguard.Rules/Engine/GameEngine.cs +++ b/server/src/Vanguard.Rules/Engine/GameEngine.cs @@ -15,6 +15,8 @@ namespace Vanguard.Rules.Engine; /// в круг защиты и при атаке по Авангарду, и при атаке по Арьергарду; /// атакованный Арьергард не может перехватить атаку на самого себя; /// из заднего ряда — только если эффект разрешил (InterceptFromBackRow); +/// * число проверок принадлежит атакующей карте: 0–2 ранг — одна, 3+ — две; +/// Арьергард по умолчанию без проверок, но эффект может их выдать; /// * триггер требует выбора целей через ResolveTriggerCommand: /// +5000 любой карте в кругах Авангарда или Арьергарда, затем второй эффект; /// правило одинаково для drive check и damage check; @@ -25,7 +27,6 @@ public sealed class GameEngine { public const int StartingHand = 5; public const int MaxDamage = 6; - public const int DriveChecks = 2; public const int TriggerPowerBonus = 5000; private readonly List _events = new(); @@ -48,6 +49,18 @@ public sealed class GameEngine public GameState State { get; } public IReadOnlyList Events => _events; + /// + /// Сколько проверок делает атакующая карта. Эффект может изменить число; + /// Арьергард по умолчанию проверок не делает. + /// + public int DriveChecksFor(int slot, CardInstance attacker) + { + if (attacker.DriveChecksOverride is { } overridden) + return Math.Max(0, overridden); + + return slot < 0 ? State.Catalog.CardOf(attacker.PrintingId).DefaultDriveChecks : 0; + } + // ---------- старт партии ---------- public void Start() @@ -516,13 +529,21 @@ public sealed class GameEngine private void BeginResolution(BattleState battle) { - if (battle.AttackerSlot >= 0) + var attacker = State.GetUnit(battle.AttackerPlayer, battle.AttackerSlot); + if (attacker is null) { FinishBattle(battle); return; } - battle.ChecksRemaining = DriveChecks; + var checks = DriveChecksFor(battle.AttackerSlot, attacker); + if (checks == 0) + { + FinishBattle(battle); + return; + } + + battle.ChecksRemaining = checks; State.ActivePlayer = battle.AttackerPlayer; State.Step = Step.Drive; ContinueDriveChecks(battle); @@ -546,6 +567,8 @@ public sealed class GameEngine } var def = State.Catalog.CardOf(card.PrintingId); + _events.Add(new DriveCheckedEvent(player, card.PrintingId, def.Trigger)); + if (def.Trigger == TriggerType.None) { State.Players[player].Hand.Add(card); diff --git a/server/src/Vanguard.Rules/Engine/GameEvents.cs b/server/src/Vanguard.Rules/Engine/GameEvents.cs index 77ebf33..55a5485 100644 --- a/server/src/Vanguard.Rules/Engine/GameEvents.cs +++ b/server/src/Vanguard.Rules/Engine/GameEvents.cs @@ -11,6 +11,7 @@ public sealed record CardDrawnEvent(int Player) : GameEvent; public sealed record UnitRodeEvent(int Player, string PrintingId) : GameEvent; public sealed record UnitCalledEvent(int Player, int Slot, string PrintingId) : GameEvent; public sealed record UnitMovedEvent(int Player, int FromSlot, int ToSlot) : GameEvent; +public sealed record DriveCheckedEvent(int Player, string PrintingId, TriggerType Trigger) : GameEvent; 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; diff --git a/server/src/Vanguard.Rules/Engine/GameModels.cs b/server/src/Vanguard.Rules/Engine/GameModels.cs index 890b705..b104761 100644 --- a/server/src/Vanguard.Rules/Engine/GameModels.cs +++ b/server/src/Vanguard.Rules/Engine/GameModels.cs @@ -72,6 +72,12 @@ public sealed class CardInstance /// Эффект разрешил перехватывать из заднего ряда. public bool InterceptFromBackRow { get; set; } + + /// + /// Переопределение числа проверок, выданное эффектом. + /// null — по умолчанию: 1 для 0–2 ранга, 2 для 3+, у Арьергарда 0. + /// + public int? DriveChecksOverride { get; set; } } public sealed class PlayerState diff --git a/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs b/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs index b688776..1a94ad5 100644 --- a/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs +++ b/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs @@ -472,6 +472,73 @@ public class GameEngineTests Assert.False(engine.Execute(new MoveCommand(0, 3)).Accepted); } + [Fact] + public void Grade_One_Vanguard_Performs_One_Check() + { + var engine = ReadyToAttack(); + StackDeck(engine.State.Players[0], Gallatin, Gallatin); + + engine.Execute(new AttackCommand(-1, -1)); + engine.Execute(new PassGuardCommand()); + + Assert.Single(engine.Events.OfType()); + } + + [Fact] + public void Grade_Three_Vanguard_Performs_Two_Checks() + { + var engine = StartedEngine(); + engine.Execute(new SkipRideCommand()); + SetVanguard(engine, 0, Overlord); + StackDeck(engine.State.Players[0], Gallatin, Gallatin); + engine.Execute(new EnterBattleCommand()); + + engine.Execute(new AttackCommand(-1, -1)); + engine.Execute(new PassGuardCommand()); + + Assert.Equal(2, engine.Events.OfType().Count()); + } + + [Fact] + public void Twin_Drive_Can_Be_Removed_By_Effect() + { + var engine = StartedEngine(); + engine.Execute(new SkipRideCommand()); + SetVanguard(engine, 0, Overlord); + engine.State.Players[0].Vanguard!.DriveChecksOverride = 1; + StackDeck(engine.State.Players[0], Gallatin, Gallatin); + engine.Execute(new EnterBattleCommand()); + + engine.Execute(new AttackCommand(-1, -1)); + engine.Execute(new PassGuardCommand()); + + Assert.Single(engine.Events.OfType()); + } + + [Fact] + public void Rear_Guard_Performs_No_Checks_By_Default() + { + var engine = ReadyToAttackRearGuard(); + + engine.Execute(new AttackCommand(3, 0)); + engine.Execute(new PassGuardCommand()); + + Assert.Empty(engine.Events.OfType()); + } + + [Fact] + public void Effect_Can_Give_Drive_Check_To_Rear_Guard() + { + var engine = ReadyToAttackRearGuard(); + engine.State.Players[0].RearGuards[3]!.DriveChecksOverride = 1; + StackDeck(engine.State.Players[0], Gallatin); + + engine.Execute(new AttackCommand(3, 0)); + engine.Execute(new PassGuardCommand()); + + Assert.Single(engine.Events.OfType()); + } + [Fact] public void ResolveTrigger_Rejects_When_Nothing_Is_Pending() {