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.
This commit is contained in:
MpakobecEx 2026-10-21 00:47:44 +05:00
parent 71db856480
commit bf2ea337f8
6 changed files with 107 additions and 4 deletions

View File

@ -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: сбор → сортировка → разрешение по одному → повтор.

View File

@ -40,6 +40,12 @@ public sealed record CardDefinition
/// <summary>Печатный перехват: по умолчанию у карт 2 ранга.</summary>
public bool HasIntercept => InterceptOverride ?? Grade == 2;
/// <summary>
/// Печатное число проверок при атаке из круга Авангарда:
/// 0–2 ранг — одна, 3 ранг и выше — двойная.
/// </summary>
public int DefaultDriveChecks => Grade >= 3 ? 2 : 1;
public string? TextEn { get; init; }
public string? TextRu { get; init; }

View File

@ -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<GameEvent> _events = new();
@ -48,6 +49,18 @@ public sealed class GameEngine
public GameState State { get; }
public IReadOnlyList<GameEvent> Events => _events;
/// <summary>
/// Сколько проверок делает атакующая карта. Эффект может изменить число;
/// Арьергард по умолчанию проверок не делает.
/// </summary>
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);

View File

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

View File

@ -72,6 +72,12 @@ public sealed class CardInstance
/// <summary>Эффект разрешил перехватывать из заднего ряда.</summary>
public bool InterceptFromBackRow { get; set; }
/// <summary>
/// Переопределение числа проверок, выданное эффектом.
/// null — по умолчанию: 1 для 0–2 ранга, 2 для 3+, у Арьергарда 0.
/// </summary>
public int? DriveChecksOverride { get; set; }
}
public sealed class PlayerState

View File

@ -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<DriveCheckedEvent>());
}
[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<DriveCheckedEvent>().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<DriveCheckedEvent>());
}
[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<DriveCheckedEvent>());
}
[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<DriveCheckedEvent>());
}
[Fact]
public void ResolveTrigger_Rejects_When_Nothing_Is_Pending()
{