diff --git a/IMPLEMENTATION_CHECKLIST.md b/IMPLEMENTATION_CHECKLIST.md
index 18e06d3..e286d3f 100644
--- a/IMPLEMENTATION_CHECKLIST.md
+++ b/IMPLEMENTATION_CHECKLIST.md
@@ -107,7 +107,8 @@
- [x] Фазы: Mulligan → Stand → Draw → Ride → Main → Battle → End.
- [x] Старт: выбор grade 0 → выставление → маллиган.
- [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).
- [x] Результат боя: `Hit` / `Guarded` как событие.
- [ ] Событийная шина и три обработчика: Continuous / Activated / Triggered.
diff --git a/server/src/Vanguard.Content/CardSetFile.cs b/server/src/Vanguard.Content/CardSetFile.cs
index 7622485..adbaf7a 100644
--- a/server/src/Vanguard.Content/CardSetFile.cs
+++ b/server/src/Vanguard.Content/CardSetFile.cs
@@ -24,6 +24,9 @@ public sealed class CardEntry
public Clan Clan { get; set; }
public bool Sentinel { get; set; }
public bool Restraint { get; set; }
+
+ /// null — по умолчанию (0 и 1 ранг поддерживают).
+ public bool? Boost { get; set; }
public string? TextEn { get; set; }
public string? TextRu { get; set; }
@@ -41,6 +44,7 @@ public sealed class CardEntry
Clan = Clan,
IsSentinel = Sentinel,
IsRestraint = Restraint,
+ BoostOverride = Boost,
TextEn = TextEn,
TextRu = TextRu,
};
diff --git a/server/src/Vanguard.Rules/Cards/CardDefinition.cs b/server/src/Vanguard.Rules/Cards/CardDefinition.cs
index f94104c..3a9b12a 100644
--- a/server/src/Vanguard.Rules/Cards/CardDefinition.cs
+++ b/server/src/Vanguard.Rules/Cards/CardDefinition.cs
@@ -25,6 +25,15 @@ public sealed record CardDefinition
/// Restraint: не может атаковать.
public bool IsRestraint { get; init; }
+ ///
+ /// Явное переопределение печатной поддержки. null — по умолчанию:
+ /// карты 0 и 1 ранга поддерживают, остальные нет.
+ ///
+ public bool? BoostOverride { get; init; }
+
+ /// Печатная способность поддержки. Эффекты могут изменить её в рантайме.
+ public bool HasBoost => BoostOverride ?? Grade <= 1;
+
public string? TextEn { get; init; }
public string? TextRu { get; init; }
diff --git a/server/src/Vanguard.Rules/Engine/GameCommands.cs b/server/src/Vanguard.Rules/Engine/GameCommands.cs
index 685e32d..bbe743c 100644
--- a/server/src/Vanguard.Rules/Engine/GameCommands.cs
+++ b/server/src/Vanguard.Rules/Engine/GameCommands.cs
@@ -8,7 +8,7 @@ public sealed record RideCommand(string PrintingId) : GameCommand;
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) : GameCommand;
+public sealed record AttackCommand(int AttackerSlot, int TargetSlot, int BoosterSlot = -1) : GameCommand;
public sealed record GuardCommand(string PrintingId) : GameCommand;
public sealed record PassGuardCommand : GameCommand;
public sealed record ResolveTriggerCommand(int PowerTarget, int EffectTarget) : GameCommand;
diff --git a/server/src/Vanguard.Rules/Engine/GameEngine.cs b/server/src/Vanguard.Rules/Engine/GameEngine.cs
index 662a6eb..ff78dc7 100644
--- a/server/src/Vanguard.Rules/Engine/GameEngine.cs
+++ b/server/src/Vanguard.Rules/Engine/GameEngine.cs
@@ -8,7 +8,8 @@ namespace Vanguard.Rules.Engine;
///
/// Упрощения первого среза (карты со способностями подключаются следующим шагом):
/// * Sentinel добавляет 0 щита и не оплачивает стоимость — Perfect Guard пока не работает;
-/// * Boost не реализован;
+/// * поддержка работает как отдельная способность: карты 0 и 1 ранга могут передать
+/// свою силу атакующему Арьергарду из той же колонны, сила действует только в этом бою;
/// * триггер требует выбора целей через ResolveTriggerCommand:
/// +5000 любой карте в кругах Авангарда или Арьергарда, затем второй эффект;
/// правило одинаково для drive check и damage check;
@@ -105,7 +106,7 @@ public sealed class GameEngine
SkipRideCommand => SkipRide(),
CallCommand c => Call(c.PrintingId, c.Slot),
EnterBattleCommand => EnterBattle(),
- AttackCommand c => Attack(c.AttackerSlot, c.TargetSlot),
+ AttackCommand c => Attack(c.AttackerSlot, c.TargetSlot, c.BoosterSlot),
GuardCommand c => Guard(c.PrintingId),
PassGuardCommand => PassGuard(),
ResolveTriggerCommand c => ResolveTrigger(c),
@@ -228,7 +229,7 @@ public sealed class GameEngine
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)
return CommandResult.Reject("Атака доступна только в Боевой фазе.");
@@ -257,6 +258,27 @@ public sealed class GameEngine
if (target is null)
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;
attacker.Rested = true;
State.Battle = new BattleState
@@ -265,6 +287,14 @@ public sealed class GameEngine
AttackerSlot = attackerSlot,
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;
// на шаге защиты ход ввода переходит обороняющемуся
@@ -534,7 +564,7 @@ public sealed class GameEngine
var attackerDef = State.Catalog.CardOf(attacker.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 hit = attackPower > defensePower;
diff --git a/server/src/Vanguard.Rules/Engine/GameEvents.cs b/server/src/Vanguard.Rules/Engine/GameEvents.cs
index 0a80ba9..f9ecb6c 100644
--- a/server/src/Vanguard.Rules/Engine/GameEvents.cs
+++ b/server/src/Vanguard.Rules/Engine/GameEvents.cs
@@ -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 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 TriggerResolvedEvent(int Player, string PrintingId, TriggerType Trigger) : GameEvent;
public sealed record TriggerAppliedEvent(int Player, int PowerTarget, int EffectTarget) : GameEvent;
diff --git a/server/src/Vanguard.Rules/Engine/GameModels.cs b/server/src/Vanguard.Rules/Engine/GameModels.cs
index 20d26c8..e6ef876 100644
--- a/server/src/Vanguard.Rules/Engine/GameModels.cs
+++ b/server/src/Vanguard.Rules/Engine/GameModels.cs
@@ -60,6 +60,12 @@ public sealed class CardInstance
/// Временный бонус крита до конца текущего боя.
public int TemporaryCritical { get; set; }
+
+ ///
+ /// Переопределение способности поддержки, выданное эффектом.
+ /// null — как напечатано на карте.
+ ///
+ public bool? BoostOverride { get; set; }
}
public sealed class PlayerState
diff --git a/server/src/Vanguard.Rules/Engine/GameState.cs b/server/src/Vanguard.Rules/Engine/GameState.cs
index 1ded7ac..0efcf8c 100644
--- a/server/src/Vanguard.Rules/Engine/GameState.cs
+++ b/server/src/Vanguard.Rules/Engine/GameState.cs
@@ -14,6 +14,9 @@ public sealed class BattleState
public int GuardShield { get; set; }
public int ChecksRemaining { get; set; }
+
+ /// Сила карт поддержки, действует только в этом бою.
+ public int BoostPower { get; set; }
public List GuardCards { get; } = new();
}
@@ -49,6 +52,12 @@ public sealed class GameState
public CardDefinition Definition(CardInstance card) => Catalog.CardOf(card.PrintingId);
+ ///
+ /// Итоговая способность поддержки: выданная эффектом важнее печатной.
+ ///
+ public bool CanBoost(CardInstance unit) =>
+ unit.BoostOverride ?? Definition(unit).HasBoost;
+
public CardInstance? GetUnit(int player, int slot) =>
slot < 0
? Players[player].Vanguard
diff --git a/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs b/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs
index ebdc21b..65d2aec 100644
--- a/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs
+++ b/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs
@@ -198,6 +198,78 @@ public class GameEngineTests
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]
public void ResolveTrigger_Rejects_When_Nothing_Is_Pending()
{
@@ -267,6 +339,28 @@ public class GameEngineTests
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 BuildDeck()
{
var mustInclude = new[] { Barcgal, Marron, Gallatin, Alfred, Nike, Flogal, Govannon };
@@ -329,12 +423,15 @@ public class GameEngineTests
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 card = player.Deck[0];
- player.Deck.RemoveAt(0);
- return card;
+ var p = engine.State.Players[player];
+ var index = p.Deck.FindIndex(c => c.PrintingId == printingId);
+ Assert.True(index >= 0, $"В колоде нет {printingId}");
+
+ var card = p.Deck[index];
+ p.Deck.RemoveAt(index);
+ p.Vanguard = card;
}
private static CardInstance TakeFromDeck(PlayerState player, string printingId)
@@ -346,4 +443,12 @@ public class GameEngineTests
player.Deck.RemoveAt(index);
return card;
}
+
+ private static CardInstance TakeAnyFromDeck(PlayerState player)
+ {
+ Assert.NotEmpty(player.Deck);
+ var card = player.Deck[0];
+ player.Deck.RemoveAt(0);
+ return card;
+ }
}