Implement Boost as a separate ability that effects can grant or remove

Cards of grade 0 and 1 have boost by default (HasBoost); CardInstance.BoostOverride lets an effect grant or remove it at runtime. AttackCommand takes an optional BoosterSlot: the back-row unit in the same column adds its power for that battle only and becomes rested. Four new tests.
This commit is contained in:
MpakobecEx 2026-10-21 00:34:58 +05:00
parent c2480bab63
commit 66f2ecc3f5
9 changed files with 176 additions and 11 deletions

View File

@ -107,7 +107,8 @@
- [x] Фазы: Mulligan → Stand → Draw → Ride → Main → Battle → End. - [x] Фазы: Mulligan → Stand → Draw → Ride → Main → Battle → End.
- [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.
- [ ] Move, Intercept, Boost, Discard, Shuffle как отдельные интенты. - [x] Поддержка (Boost) как отдельная способность: по умолчанию у 0–1 ранга, передаёт силу карте впереди в той же колонне; `BoostOverride` позволяет выдать или снять её эффектом.
- [ ] Move, Intercept, Discard, Shuffle как отдельные интенты.
- [ ] `driveCheckCount` как модифицируемое свойство (сейчас константа 2). - [ ] `driveCheckCount` как модифицируемое свойство (сейчас константа 2).
- [x] Результат боя: `Hit` / `Guarded` как событие. - [x] Результат боя: `Hit` / `Guarded` как событие.
- [ ] Событийная шина и три обработчика: Continuous / Activated / Triggered. - [ ] Событийная шина и три обработчика: Continuous / Activated / Triggered.

View File

@ -24,6 +24,9 @@ public sealed class CardEntry
public Clan Clan { get; set; } public Clan Clan { get; set; }
public bool Sentinel { get; set; } public bool Sentinel { get; set; }
public bool Restraint { get; set; } public bool Restraint { get; set; }
/// <summary>null — по умолчанию (0 и 1 ранг поддерживают).</summary>
public bool? Boost { get; set; }
public string? TextEn { get; set; } public string? TextEn { get; set; }
public string? TextRu { get; set; } public string? TextRu { get; set; }
@ -41,6 +44,7 @@ public sealed class CardEntry
Clan = Clan, Clan = Clan,
IsSentinel = Sentinel, IsSentinel = Sentinel,
IsRestraint = Restraint, IsRestraint = Restraint,
BoostOverride = Boost,
TextEn = TextEn, TextEn = TextEn,
TextRu = TextRu, TextRu = TextRu,
}; };

View File

@ -25,6 +25,15 @@ public sealed record CardDefinition
/// <summary>Restraint: не может атаковать.</summary> /// <summary>Restraint: не может атаковать.</summary>
public bool IsRestraint { get; init; } public bool IsRestraint { get; init; }
/// <summary>
/// Явное переопределение печатной поддержки. null — по умолчанию:
/// карты 0 и 1 ранга поддерживают, остальные нет.
/// </summary>
public bool? BoostOverride { get; init; }
/// <summary>Печатная способность поддержки. Эффекты могут изменить её в рантайме.</summary>
public bool HasBoost => BoostOverride ?? Grade <= 1;
public string? TextEn { get; init; } public string? TextEn { get; init; }
public string? TextRu { get; init; } public string? TextRu { get; init; }

View File

@ -8,7 +8,7 @@ public sealed record RideCommand(string PrintingId) : GameCommand;
public sealed record SkipRideCommand : GameCommand; 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) : 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) : 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;

View File

@ -8,7 +8,8 @@ namespace Vanguard.Rules.Engine;
/// ///
/// Упрощения первого среза (карты со способностями подключаются следующим шагом): /// Упрощения первого среза (карты со способностями подключаются следующим шагом):
/// * Sentinel добавляет 0 щита и не оплачивает стоимость — Perfect Guard пока не работает; /// * Sentinel добавляет 0 щита и не оплачивает стоимость — Perfect Guard пока не работает;
/// * Boost не реализован; /// * поддержка работает как отдельная способность: карты 0 и 1 ранга могут передать
/// свою силу атакующему Арьергарду из той же колонны, сила действует только в этом бою;
/// * триггер требует выбора целей через ResolveTriggerCommand: /// * триггер требует выбора целей через ResolveTriggerCommand:
/// +5000 любой карте в кругах Авангарда или Арьергарда, затем второй эффект; /// +5000 любой карте в кругах Авангарда или Арьергарда, затем второй эффект;
/// правило одинаково для drive check и damage check; /// правило одинаково для drive check и damage check;
@ -105,7 +106,7 @@ public sealed class GameEngine
SkipRideCommand => SkipRide(), SkipRideCommand => SkipRide(),
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), AttackCommand c => Attack(c.AttackerSlot, c.TargetSlot, c.BoosterSlot),
GuardCommand c => Guard(c.PrintingId), GuardCommand c => Guard(c.PrintingId),
PassGuardCommand => PassGuard(), PassGuardCommand => PassGuard(),
ResolveTriggerCommand c => ResolveTrigger(c), ResolveTriggerCommand c => ResolveTrigger(c),
@ -228,7 +229,7 @@ public sealed class GameEngine
return CommandResult.Ok(); return CommandResult.Ok();
} }
private CommandResult Attack(int attackerSlot, int targetSlot) private CommandResult Attack(int attackerSlot, int targetSlot, int boosterSlot = -1)
{ {
if (State.Phase != Phase.Battle) if (State.Phase != Phase.Battle)
return CommandResult.Reject("Атака доступна только в Боевой фазе."); return CommandResult.Reject("Атака доступна только в Боевой фазе.");
@ -257,6 +258,27 @@ public sealed class GameEngine
if (target is null) if (target is null)
return CommandResult.Reject("Нет цели."); return CommandResult.Reject("Нет цели.");
CardInstance? booster = null;
if (boosterSlot >= 0)
{
if (attackerSlot is not (3 or 4))
return CommandResult.Reject("Поддерживать можно только атаку Арьергарда из переднего ряда.");
var expected = attackerSlot == 3 ? 0 : 2;
if (boosterSlot != expected)
return CommandResult.Reject("Поддержка возможна только из заднего ряда той же колонны.");
booster = State.GetUnit(State.ActivePlayer, boosterSlot);
if (booster is null)
return CommandResult.Reject("Нет карты для поддержки.");
if (booster.Rested)
return CommandResult.Reject("Карта поддержки уже повёрнута.");
if (!State.CanBoost(booster))
return CommandResult.Reject("Эта карта не может поддерживать.");
}
var attackerPlayer = State.ActivePlayer; var attackerPlayer = State.ActivePlayer;
attacker.Rested = true; attacker.Rested = true;
State.Battle = new BattleState State.Battle = new BattleState
@ -265,6 +287,14 @@ public sealed class GameEngine
AttackerSlot = attackerSlot, AttackerSlot = attackerSlot,
TargetSlot = targetSlot, TargetSlot = targetSlot,
}; };
if (booster is not null)
{
booster.Rested = true;
State.Battle.BoostPower += State.Catalog.CardOf(booster.PrintingId).Power;
_events.Add(new BoostedEvent(attackerPlayer, boosterSlot, attackerSlot));
}
State.Step = Step.Guard; State.Step = Step.Guard;
// на шаге защиты ход ввода переходит обороняющемуся // на шаге защиты ход ввода переходит обороняющемуся
@ -534,7 +564,7 @@ public sealed class GameEngine
var attackerDef = State.Catalog.CardOf(attacker.PrintingId); var attackerDef = State.Catalog.CardOf(attacker.PrintingId);
var targetDef = State.Catalog.CardOf(target.PrintingId); var targetDef = State.Catalog.CardOf(target.PrintingId);
var attackPower = attackerDef.Power + attacker.TemporaryPower; 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 = attackPower > defensePower;

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 UnitRodeEvent(int Player, string PrintingId) : GameEvent;
public sealed record UnitCalledEvent(int Player, int Slot, string PrintingId) : GameEvent; public sealed record UnitCalledEvent(int Player, int Slot, string PrintingId) : GameEvent;
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 GuardAddedEvent(int Player, string PrintingId, int Shield) : GameEvent; public sealed record GuardAddedEvent(int Player, string PrintingId, int Shield) : 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;

View File

@ -60,6 +60,12 @@ public sealed class CardInstance
/// <summary>Временный бонус крита до конца текущего боя.</summary> /// <summary>Временный бонус крита до конца текущего боя.</summary>
public int TemporaryCritical { get; set; } public int TemporaryCritical { get; set; }
/// <summary>
/// Переопределение способности поддержки, выданное эффектом.
/// null — как напечатано на карте.
/// </summary>
public bool? BoostOverride { get; set; }
} }
public sealed class PlayerState public sealed class PlayerState

View File

@ -14,6 +14,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 int BoostPower { get; set; }
public List<CardInstance> GuardCards { get; } = new(); public List<CardInstance> GuardCards { get; } = new();
} }
@ -49,6 +52,12 @@ public sealed class GameState
public CardDefinition Definition(CardInstance card) => Catalog.CardOf(card.PrintingId); public CardDefinition Definition(CardInstance card) => Catalog.CardOf(card.PrintingId);
/// <summary>
/// Итоговая способность поддержки: выданная эффектом важнее печатной.
/// </summary>
public bool CanBoost(CardInstance unit) =>
unit.BoostOverride ?? Definition(unit).HasBoost;
public CardInstance? GetUnit(int player, int slot) => public CardInstance? GetUnit(int player, int slot) =>
slot < 0 slot < 0
? Players[player].Vanguard ? Players[player].Vanguard

View File

@ -198,6 +198,78 @@ public class GameEngineTests
Assert.Equal(0, vanguard.TemporaryPower); Assert.Equal(0, vanguard.TemporaryPower);
} }
[Fact]
public void Boost_Adds_Back_Row_Power_To_Attacker()
{
var engine = ReadyToBoost();
Assert.True(engine.Execute(new AttackCommand(3, -1, 0)).Accepted);
Assert.True(engine.State.Players[0].RearGuards[0]!.Rested);
Assert.True(engine.Execute(new PassGuardCommand()).Accepted);
Assert.Single(engine.State.Players[1].Damage);
Assert.Contains(engine.Events, e => e is BoostedEvent { BoosterSlot: 0, AttackerSlot: 3 });
}
[Fact]
public void Without_Boost_The_Same_Attack_Misses()
{
var engine = ReadyToBoost();
Assert.True(engine.Execute(new AttackCommand(3, -1)).Accepted);
Assert.True(engine.Execute(new PassGuardCommand()).Accepted);
Assert.Empty(engine.State.Players[1].Damage);
Assert.False(engine.State.Players[0].RearGuards[0]!.Rested);
}
[Fact]
public void Boost_Rejects_Wrong_Column()
{
var engine = ReadyToBoost();
var result = engine.Execute(new AttackCommand(3, -1, 2));
Assert.False(result.Accepted);
}
[Fact]
public void Grade_Two_Cannot_Boost()
{
var engine = ReadyToBoost();
engine.State.Players[0].RearGuards[0] = TakeFromDeck(engine.State.Players[0], Gallatin);
var result = engine.Execute(new AttackCommand(3, -1, 0));
Assert.False(result.Accepted);
}
[Fact]
public void Effect_Can_Grant_Boost_To_Grade_Two()
{
var engine = ReadyToBoost();
var booster = TakeFromDeck(engine.State.Players[0], Gallatin);
engine.State.Players[0].RearGuards[0] = booster;
Assert.False(engine.Execute(new AttackCommand(3, -1, 0)).Accepted);
booster.BoostOverride = true;
Assert.True(engine.Execute(new AttackCommand(3, -1, 0)).Accepted);
}
[Fact]
public void Effect_Can_Remove_Boost_From_Grade_One()
{
var engine = ReadyToBoost();
var booster = engine.State.Players[0].RearGuards[0]!;
booster.BoostOverride = false;
Assert.False(engine.Execute(new AttackCommand(3, -1, 0)).Accepted);
}
[Fact] [Fact]
public void ResolveTrigger_Rejects_When_Nothing_Is_Pending() public void ResolveTrigger_Rejects_When_Nothing_Is_Pending()
{ {
@ -267,6 +339,28 @@ public class GameEngineTests
return engine; return engine;
} }
private GameEngine ReadyToBoost()
{
var engine = StartedEngine();
Give(engine, 0, Marron);
engine.Execute(new RideCommand(Marron));
// сзади слева — поддержка (1 ранг), впереди слева — атакующий (2 ранг)
Give(engine, 0, Marron);
engine.Execute(new CallCommand(Marron, 0));
Give(engine, 0, Gallatin);
engine.Execute(new CallCommand(Gallatin, 3));
// призванные карты входят повёрнутыми — для теста поддержки поднимаем обе
engine.State.Players[0].RearGuards[0]!.Rested = false;
engine.State.Players[0].RearGuards[3]!.Rested = false;
SetVanguard(engine, 1, Gallatin);
engine.Execute(new EnterBattleCommand());
return engine;
}
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 };
@ -329,12 +423,15 @@ public class GameEngineTests
player.Deck.Insert(0, cards[i]); player.Deck.Insert(0, cards[i]);
} }
private static CardInstance TakeAnyFromDeck(PlayerState player) private static void SetVanguard(GameEngine engine, int player, string printingId)
{ {
Assert.NotEmpty(player.Deck); var p = engine.State.Players[player];
var card = player.Deck[0]; var index = p.Deck.FindIndex(c => c.PrintingId == printingId);
player.Deck.RemoveAt(0); Assert.True(index >= 0, $"В колоде нет {printingId}");
return card;
var card = p.Deck[index];
p.Deck.RemoveAt(index);
p.Vanguard = card;
} }
private static CardInstance TakeFromDeck(PlayerState player, string printingId) private static CardInstance TakeFromDeck(PlayerState player, string printingId)
@ -346,4 +443,12 @@ public class GameEngineTests
player.Deck.RemoveAt(index); player.Deck.RemoveAt(index);
return card; return card;
} }
private static CardInstance TakeAnyFromDeck(PlayerState player)
{
Assert.NotEmpty(player.Deck);
var card = player.Deck[0];
player.Deck.RemoveAt(0);
return card;
}
} }