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:
parent
66f2ecc3f5
commit
830785a8d6
|
|
@ -108,6 +108,7 @@
|
|||
- [x] Старт: выбор grade 0 → выставление → маллиган.
|
||||
- [x] Действия: Ride, Call, Attack, Guard, Drive, Damage, Stand/Rest, Retire, Draw.
|
||||
- [x] Поддержка (Boost) как отдельная способность: по умолчанию у 0–1 ранга, передаёт силу карте впереди в той же колонне; `BoostOverride` позволяет выдать или снять её эффектом.
|
||||
- [x] Идеальная защита (Sentinel): щит 0; оплата сбросом карты того же клана полностью отменяет попадание; без оплаты не даёт ничего.
|
||||
- [ ] Move, Intercept, Discard, Shuffle как отдельные интенты.
|
||||
- [ ] `driveCheckCount` как модифицируемое свойство (сейчас константа 2).
|
||||
- [x] Результат боя: `Hit` / `Guarded` как событие.
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ public sealed record SkipRideCommand : GameCommand;
|
|||
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) : GameCommand;
|
||||
public sealed record GuardCommand(string PrintingId, string? DiscardPrintingId = null) : GameCommand;
|
||||
public sealed record PassGuardCommand : GameCommand;
|
||||
public sealed record ResolveTriggerCommand(int PowerTarget, int EffectTarget) : GameCommand;
|
||||
public sealed record EndTurnCommand : GameCommand;
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@ namespace Vanguard.Rules.Engine;
|
|||
/// Ядро партии. Не знает ни о сети, ни о БД: на вход команды, на выход события.
|
||||
///
|
||||
/// Упрощения первого среза (карты со способностями подключаются следующим шагом):
|
||||
/// * Sentinel добавляет 0 щита и не оплачивает стоимость — Perfect Guard пока не работает;
|
||||
/// * идеальная защита (Sentinel): щит 0, но при оплате стоимости (сброс карты того же клана)
|
||||
/// атака полностью отменяется; без оплаты не даёт ничего;
|
||||
/// * поддержка работает как отдельная способность: карты 0 и 1 ранга могут передать
|
||||
/// свою силу атакующему Арьергарду из той же колонны, сила действует только в этом бою;
|
||||
/// * триггер требует выбора целей через ResolveTriggerCommand:
|
||||
|
|
@ -107,7 +108,7 @@ public sealed class GameEngine
|
|||
CallCommand c => Call(c.PrintingId, c.Slot),
|
||||
EnterBattleCommand => EnterBattle(),
|
||||
AttackCommand c => Attack(c.AttackerSlot, c.TargetSlot, c.BoosterSlot),
|
||||
GuardCommand c => Guard(c.PrintingId),
|
||||
GuardCommand c => Guard(c.PrintingId, c.DiscardPrintingId),
|
||||
PassGuardCommand => PassGuard(),
|
||||
ResolveTriggerCommand c => ResolveTrigger(c),
|
||||
EndTurnCommand => EndTurn(),
|
||||
|
|
@ -304,7 +305,7 @@ public sealed class GameEngine
|
|||
return CommandResult.Ok();
|
||||
}
|
||||
|
||||
private CommandResult Guard(string printingId)
|
||||
private CommandResult Guard(string printingId, string? discardPrintingId = null)
|
||||
{
|
||||
var battle = State.Battle;
|
||||
if (State.Step != Step.Guard || battle is null)
|
||||
|
|
@ -321,14 +322,40 @@ public sealed class GameEngine
|
|||
|
||||
var card = defender.Hand[index];
|
||||
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;
|
||||
|
||||
defender.Hand.RemoveAt(index);
|
||||
defender.Guardian.Add(card);
|
||||
battle.GuardCards.Add(card);
|
||||
battle.GuardShield += 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();
|
||||
}
|
||||
|
||||
|
|
@ -566,7 +593,7 @@ public sealed class GameEngine
|
|||
|
||||
var attackPower = attackerDef.Power + attacker.TemporaryPower + battle.BoostPower;
|
||||
var defensePower = targetDef.Power + target.TemporaryPower + battle.GuardShield;
|
||||
var hit = attackPower > defensePower;
|
||||
var hit = !battle.Nullified && attackPower > defensePower;
|
||||
|
||||
_events.Add(new BattleResolvedEvent(hit));
|
||||
|
||||
|
|
|
|||
|
|
@ -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 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 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;
|
||||
|
|
|
|||
|
|
@ -15,6 +15,9 @@ public sealed class BattleState
|
|||
public int GuardShield { get; set; }
|
||||
public int ChecksRemaining { get; set; }
|
||||
|
||||
/// <summary>Атака отменена идеальной защитой: попадания не будет при любой силе.</summary>
|
||||
public bool Nullified { get; set; }
|
||||
|
||||
/// <summary>Сила карт поддержки, действует только в этом бою.</summary>
|
||||
public int BoostPower { get; set; }
|
||||
public List<CardInstance> GuardCards { get; } = new();
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ public class GameEngineTests
|
|||
private const string Nike = "BT01/056EN"; // Critical
|
||||
private const string Flogal = "BT01/046EN"; // Stand
|
||||
private const string Govannon = "BT01/045EN"; // Draw
|
||||
private const string Iseult = "BT01/011EN"; // Sentinel, Royal Paladin
|
||||
private const string Nehalem = "BT01/022EN"; // Kagero
|
||||
|
||||
[Fact]
|
||||
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);
|
||||
}
|
||||
|
||||
[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]
|
||||
public void ResolveTrigger_Rejects_When_Nothing_Is_Pending()
|
||||
{
|
||||
|
|
@ -363,7 +408,7 @@ public class GameEngineTests
|
|||
|
||||
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
|
||||
.Select(_catalog.CardOf)
|
||||
.Select(c => c.NameId)
|
||||
|
|
|
|||
Loading…
Reference in New Issue