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.
This commit is contained in:
MpakobecEx 2026-10-21 00:37:05 +05:00
parent 66f2ecc3f5
commit 830785a8d6
6 changed files with 84 additions and 7 deletions

View File

@ -108,6 +108,7 @@
- [x] Старт: выбор grade 0 → выставление → маллиган. - [x] Старт: выбор grade 0 → выставление → маллиган.
- [x] Действия: Ride, Call, Attack, Guard, Drive, Damage, Stand/Rest, Retire, Draw. - [x] Действия: Ride, Call, Attack, Guard, Drive, Damage, Stand/Rest, Retire, Draw.
- [x] Поддержка (Boost) как отдельная способность: по умолчанию у 0–1 ранга, передаёт силу карте впереди в той же колонне; `BoostOverride` позволяет выдать или снять её эффектом. - [x] Поддержка (Boost) как отдельная способность: по умолчанию у 0–1 ранга, передаёт силу карте впереди в той же колонне; `BoostOverride` позволяет выдать или снять её эффектом.
- [x] Идеальная защита (Sentinel): щит 0; оплата сбросом карты того же клана полностью отменяет попадание; без оплаты не даёт ничего.
- [ ] Move, Intercept, Discard, Shuffle как отдельные интенты. - [ ] Move, Intercept, Discard, Shuffle как отдельные интенты.
- [ ] `driveCheckCount` как модифицируемое свойство (сейчас константа 2). - [ ] `driveCheckCount` как модифицируемое свойство (сейчас константа 2).
- [x] Результат боя: `Hit` / `Guarded` как событие. - [x] Результат боя: `Hit` / `Guarded` как событие.

View File

@ -9,7 +9,7 @@ public sealed record SkipRideCommand : GameCommand;
public sealed record CallCommand(string PrintingId, int Slot) : GameCommand; public sealed record CallCommand(string PrintingId, int Slot) : GameCommand;
public sealed record EnterBattleCommand : GameCommand; public sealed record EnterBattleCommand : GameCommand;
public sealed record AttackCommand(int AttackerSlot, int TargetSlot, int BoosterSlot = -1) : 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 PassGuardCommand : GameCommand;
public sealed record ResolveTriggerCommand(int PowerTarget, int EffectTarget) : GameCommand; public sealed record ResolveTriggerCommand(int PowerTarget, int EffectTarget) : GameCommand;
public sealed record EndTurnCommand : GameCommand; public sealed record EndTurnCommand : GameCommand;

View File

@ -7,7 +7,8 @@ namespace Vanguard.Rules.Engine;
/// Ядро партии. Не знает ни о сети, ни о БД: на вход команды, на выход события. /// Ядро партии. Не знает ни о сети, ни о БД: на вход команды, на выход события.
/// ///
/// Упрощения первого среза (карты со способностями подключаются следующим шагом): /// Упрощения первого среза (карты со способностями подключаются следующим шагом):
/// * Sentinel добавляет 0 щита и не оплачивает стоимость — Perfect Guard пока не работает; /// * идеальная защита (Sentinel): щит 0, но при оплате стоимости (сброс карты того же клана)
/// атака полностью отменяется; без оплаты не даёт ничего;
/// * поддержка работает как отдельная способность: карты 0 и 1 ранга могут передать /// * поддержка работает как отдельная способность: карты 0 и 1 ранга могут передать
/// свою силу атакующему Арьергарду из той же колонны, сила действует только в этом бою; /// свою силу атакующему Арьергарду из той же колонны, сила действует только в этом бою;
/// * триггер требует выбора целей через ResolveTriggerCommand: /// * триггер требует выбора целей через ResolveTriggerCommand:
@ -107,7 +108,7 @@ public sealed class GameEngine
CallCommand c => Call(c.PrintingId, c.Slot), CallCommand c => Call(c.PrintingId, c.Slot),
EnterBattleCommand => EnterBattle(), EnterBattleCommand => EnterBattle(),
AttackCommand c => Attack(c.AttackerSlot, c.TargetSlot, c.BoosterSlot), AttackCommand c => Attack(c.AttackerSlot, c.TargetSlot, c.BoosterSlot),
GuardCommand c => Guard(c.PrintingId), GuardCommand c => Guard(c.PrintingId, c.DiscardPrintingId),
PassGuardCommand => PassGuard(), PassGuardCommand => PassGuard(),
ResolveTriggerCommand c => ResolveTrigger(c), ResolveTriggerCommand c => ResolveTrigger(c),
EndTurnCommand => EndTurn(), EndTurnCommand => EndTurn(),
@ -304,7 +305,7 @@ public sealed class GameEngine
return CommandResult.Ok(); return CommandResult.Ok();
} }
private CommandResult Guard(string printingId) private CommandResult Guard(string printingId, string? discardPrintingId = null)
{ {
var battle = State.Battle; var battle = State.Battle;
if (State.Step != Step.Guard || battle is null) if (State.Step != Step.Guard || battle is null)
@ -321,14 +322,40 @@ public sealed class GameEngine
var card = defender.Hand[index]; var card = defender.Hand[index];
var def = State.Catalog.CardOf(card.PrintingId); 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; var shield = def.IsSentinel ? 0 : def.Shield;
defender.Hand.RemoveAt(index); defender.Hand.RemoveAt(index);
defender.Guardian.Add(card); defender.Guardian.Add(card);
battle.GuardCards.Add(card); battle.GuardCards.Add(card);
battle.GuardShield += shield; battle.GuardShield += shield;
_events.Add(new GuardAddedEvent(defenderIndex, card.PrintingId, 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(); return CommandResult.Ok();
} }
@ -566,7 +593,7 @@ public sealed class GameEngine
var attackPower = attackerDef.Power + attacker.TemporaryPower + battle.BoostPower; var attackPower = attackerDef.Power + attacker.TemporaryPower + battle.BoostPower;
var defensePower = targetDef.Power + target.TemporaryPower + battle.GuardShield; var defensePower = targetDef.Power + target.TemporaryPower + battle.GuardShield;
var hit = attackPower > defensePower; var hit = !battle.Nullified && attackPower > defensePower;
_events.Add(new BattleResolvedEvent(hit)); _events.Add(new BattleResolvedEvent(hit));

View File

@ -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 AttackDeclaredEvent(int Player, int AttackerSlot, int TargetSlot) : GameEvent;
public sealed record BoostedEvent(int Player, int BoosterSlot, int AttackerSlot) : 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 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 TriggerResolvedEvent(int Player, string PrintingId, TriggerType Trigger) : GameEvent;
public sealed record TriggerAppliedEvent(int Player, int PowerTarget, int EffectTarget) : GameEvent; public sealed record TriggerAppliedEvent(int Player, int PowerTarget, int EffectTarget) : GameEvent;
public sealed record BattleResolvedEvent(bool Hit) : GameEvent; public sealed record BattleResolvedEvent(bool Hit) : GameEvent;

View File

@ -15,6 +15,9 @@ public sealed class BattleState
public int GuardShield { get; set; } public int GuardShield { get; set; }
public int ChecksRemaining { get; set; } public int ChecksRemaining { get; set; }
/// <summary>Атака отменена идеальной защитой: попадания не будет при любой силе.</summary>
public bool Nullified { get; set; }
/// <summary>Сила карт поддержки, действует только в этом бою.</summary> /// <summary>Сила карт поддержки, действует только в этом бою.</summary>
public int BoostPower { get; set; } public int BoostPower { get; set; }
public List<CardInstance> GuardCards { get; } = new(); public List<CardInstance> GuardCards { get; } = new();

View File

@ -15,6 +15,8 @@ public class GameEngineTests
private const string Nike = "BT01/056EN"; // Critical private const string Nike = "BT01/056EN"; // Critical
private const string Flogal = "BT01/046EN"; // Stand private const string Flogal = "BT01/046EN"; // Stand
private const string Govannon = "BT01/045EN"; // Draw private const string Govannon = "BT01/045EN"; // Draw
private const string Iseult = "BT01/011EN"; // Sentinel, Royal Paladin
private const string Nehalem = "BT01/022EN"; // Kagero
[Fact] [Fact]
public void Start_Places_Starting_Vanguard_And_Draws_Five() 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); 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] [Fact]
public void ResolveTrigger_Rejects_When_Nothing_Is_Pending() public void ResolveTrigger_Rejects_When_Nothing_Is_Pending()
{ {
@ -363,7 +408,7 @@ public class GameEngineTests
private List<string> BuildDeck() private List<string> 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 var names = mustInclude
.Select(_catalog.CardOf) .Select(_catalog.CardOf)
.Select(c => c.NameId) .Select(c => c.NameId)