Intercept: grade 2 front-row rear-guard moves to the guardian circle
InterceptCommand during the guard step. The interceptor leaves its circle, becomes the power reference for the attack and goes to the drop zone at the end of the battle; the original target survives. Grade 1 cannot intercept, back row only with InterceptFromBackRow granted by an effect. Five tests.
This commit is contained in:
parent
830785a8d6
commit
fdc21c8a26
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -34,6 +34,12 @@ public sealed record CardDefinition
|
|||
/// <summary>Печатная способность поддержки. Эффекты могут изменить её в рантайме.</summary>
|
||||
public bool HasBoost => BoostOverride ?? Grade <= 1;
|
||||
|
||||
/// <summary>Явное переопределение печатного перехвата. null — по умолчанию 2 ранг.</summary>
|
||||
public bool? InterceptOverride { get; init; }
|
||||
|
||||
/// <summary>Печатный перехват: по умолчанию у карт 2 ранга.</summary>
|
||||
public bool HasIntercept => InterceptOverride ?? Grade == 2;
|
||||
|
||||
public string? TextEn { get; init; }
|
||||
public string? TextRu { get; init; }
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -66,6 +66,12 @@ public sealed class CardInstance
|
|||
/// null — как напечатано на карте.
|
||||
/// </summary>
|
||||
public bool? BoostOverride { get; set; }
|
||||
|
||||
/// <summary>Переопределение перехвата, выданное эффектом.</summary>
|
||||
public bool? InterceptOverride { get; set; }
|
||||
|
||||
/// <summary>Эффект разрешил перехватывать из заднего ряда.</summary>
|
||||
public bool InterceptFromBackRow { get; set; }
|
||||
}
|
||||
|
||||
public sealed class PlayerState
|
||||
|
|
|
|||
|
|
@ -18,6 +18,9 @@ public sealed class BattleState
|
|||
/// <summary>Атака отменена идеальной защитой: попадания не будет при любой силе.</summary>
|
||||
public bool Nullified { get; set; }
|
||||
|
||||
/// <summary>Карта, вышедшая на перехват. Сила сравнения берётся по ней.</summary>
|
||||
public CardInstance? Interceptor { get; set; }
|
||||
|
||||
/// <summary>Сила карт поддержки, действует только в этом бою.</summary>
|
||||
public int BoostPower { get; set; }
|
||||
public List<CardInstance> GuardCards { get; } = new();
|
||||
|
|
@ -61,6 +64,10 @@ public sealed class GameState
|
|||
public bool CanBoost(CardInstance unit) =>
|
||||
unit.BoostOverride ?? Definition(unit).HasBoost;
|
||||
|
||||
/// <summary>Итоговая способность перехвата: выданная эффектом важнее печатной.</summary>
|
||||
public bool CanIntercept(CardInstance unit) =>
|
||||
unit.InterceptOverride ?? Definition(unit).HasIntercept;
|
||||
|
||||
public CardInstance? GetUnit(int player, int slot) =>
|
||||
slot < 0
|
||||
? Players[player].Vanguard
|
||||
|
|
|
|||
|
|
@ -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<string> BuildDeck()
|
||||
{
|
||||
var mustInclude = new[] { Barcgal, Marron, Gallatin, Alfred, Nike, Flogal, Govannon, Iseult, Nehalem };
|
||||
|
|
|
|||
Loading…
Reference in New Issue