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; + } }