diff --git a/IMPLEMENTATION_CHECKLIST.md b/IMPLEMENTATION_CHECKLIST.md index 5576251..422a4e4 100644 --- a/IMPLEMENTATION_CHECKLIST.md +++ b/IMPLEMENTATION_CHECKLIST.md @@ -114,7 +114,7 @@ - [ ] Check Timing: сбор → сортировка → разрешение по одному → повтор. - [ ] Стоимости: CB, SB, discard, rest self, retire self, put to soul, return to deck. - [ ] Цели: авто по фильтру и выбор игроком, «до N» / «ровно N». -- [ ] Слои модификаторов силы/крита с длительностью. +- [~] Слои модификаторов силы/крита: временные бонусы до конца боя есть, постоянных слоёв нет. - [x] Валидация интентов: `Rejected(reason)` без изменения состояния. - [ ] Список легальных интентов на каждый момент (для UI и античита). - [~] Детерминированный RNG: один поток на партию, состояние в `GameState`. diff --git a/server/src/Vanguard.Rules/Engine/GameCommands.cs b/server/src/Vanguard.Rules/Engine/GameCommands.cs index 728877a..685e32d 100644 --- a/server/src/Vanguard.Rules/Engine/GameCommands.cs +++ b/server/src/Vanguard.Rules/Engine/GameCommands.cs @@ -11,6 +11,7 @@ public sealed record EnterBattleCommand : GameCommand; public sealed record AttackCommand(int AttackerSlot, int TargetSlot) : GameCommand; public sealed record GuardCommand(string PrintingId) : GameCommand; public sealed record PassGuardCommand : GameCommand; +public sealed record ResolveTriggerCommand(int PowerTarget, int EffectTarget) : GameCommand; public sealed record EndTurnCommand : GameCommand; public sealed record CommandResult(bool Accepted, string? Reason) diff --git a/server/src/Vanguard.Rules/Engine/GameEngine.cs b/server/src/Vanguard.Rules/Engine/GameEngine.cs index 72145d5..69c87d1 100644 --- a/server/src/Vanguard.Rules/Engine/GameEngine.cs +++ b/server/src/Vanguard.Rules/Engine/GameEngine.cs @@ -9,8 +9,10 @@ namespace Vanguard.Rules.Engine; /// Упрощения первого среза (карты со способностями подключаются следующим шагом): /// * Sentinel добавляет 0 щита и не оплачивает стоимость — Perfect Guard пока не работает; /// * Boost не реализован; -/// * выбор цели триггера автоматический: усиление получает атакующая карта, -/// Stand возвращает первый повёрнутый Арьергард. +/// * триггер на drive check требует выбора целей через ResolveTriggerCommand: +/// +5000 любой карте в кругах Авангарда или Арьергарда, затем второй эффект; +/// * триггер на damage check пока применяет только второй эффект, +/// Stand поднимает первый повёрнутый Арьергард. /// public sealed class GameEngine { @@ -105,6 +107,7 @@ public sealed class GameEngine AttackCommand c => Attack(c.AttackerSlot, c.TargetSlot), GuardCommand c => Guard(c.PrintingId), PassGuardCommand => PassGuard(), + ResolveTriggerCommand c => ResolveTrigger(c), EndTurnCommand => EndTurn(), _ => CommandResult.Reject("Неизвестная команда."), }; @@ -307,7 +310,7 @@ public sealed class GameEngine if (State.ActivePlayer != 1 - battle.AttackerPlayer) return CommandResult.Reject("Защищается только обороняющийся."); - ResolveBattle(battle); + BeginResolution(battle); return CommandResult.Ok(); } @@ -368,8 +371,133 @@ public sealed class GameEngine // ---------- бой ---------- - private void ResolveBattle(BattleState battle) + private void BeginResolution(BattleState battle) { + if (battle.AttackerSlot >= 0) + { + FinishBattle(battle); + return; + } + + battle.ChecksRemaining = DriveChecks; + State.ActivePlayer = battle.AttackerPlayer; + State.Step = Step.Drive; + ContinueDriveChecks(battle); + } + + /// + /// Идёт по drive-чекам. На триггере останавливается и ждёт ResolveTriggerCommand: + /// игрок сам выбирает, кому дать +5000 и куда применить второй эффект. + /// + private void ContinueDriveChecks(BattleState battle) + { + var player = battle.AttackerPlayer; + + while (battle.ChecksRemaining > 0 && !State.IsFinished) + { + var card = TakeTop(player); + if (card is null) + { + FinishBattle(battle); + return; + } + + var def = State.Catalog.CardOf(card.PrintingId); + if (def.Trigger == TriggerType.None) + { + State.Players[player].Hand.Add(card); + _events.Add(new CardDrawnEvent(player)); + battle.ChecksRemaining--; + continue; + } + + State.Players[player].Trigger.Add(card); + battle.ChecksRemaining--; + _events.Add(new TriggerResolvedEvent(player, card.PrintingId, def.Trigger)); + + State.PendingTrigger = new PendingTrigger + { + Player = player, + PrintingId = card.PrintingId, + Trigger = def.Trigger, + Context = TriggerContext.DriveCheck, + GrantsPower = true, + }; + State.Step = Step.Trigger; + State.ActivePlayer = player; + return; + } + + FinishBattle(battle); + } + + private CommandResult ResolveTrigger(ResolveTriggerCommand command) + { + var pending = State.PendingTrigger; + if (pending is null || State.Step != Step.Trigger) + return CommandResult.Reject("Сейчас нет триггера для разрешения."); + + if (State.ActivePlayer != pending.Player) + return CommandResult.Reject("Триггер разрешает его владелец."); + + var player = pending.Player; + + if (pending.GrantsPower) + { + var powerTarget = State.GetUnit(player, command.PowerTarget); + if (powerTarget is null) + return CommandResult.Reject("Нет цели для +5000 силы."); + } + + if (pending.Trigger is TriggerType.Critical or TriggerType.Stand) + { + var effectUnit = State.GetUnit(player, command.EffectTarget); + if (effectUnit is null) + return CommandResult.Reject("Нет цели для второго эффекта."); + + if (pending.Trigger == TriggerType.Stand && command.EffectTarget < 0) + return CommandResult.Reject("Поднять можно только карту в кругу Арьергарда."); + } + + if (pending.GrantsPower) + State.GetUnit(player, command.PowerTarget)!.TemporaryPower += TriggerPowerBonus; + + switch (pending.Trigger) + { + case TriggerType.Critical: + State.GetUnit(player, command.EffectTarget)!.TemporaryCritical += 1; + break; + case TriggerType.Stand: + State.GetUnit(player, command.EffectTarget)!.Rested = false; + break; + case TriggerType.Draw: + Draw(player, 1); + break; + case TriggerType.Heal: + Heal(player); + break; + } + + State.PendingTrigger = null; + _events.Add(new TriggerAppliedEvent(player, command.PowerTarget, command.EffectTarget)); + + if (pending.Context == TriggerContext.DriveCheck && State.Battle is { } battle) + { + State.Step = Step.Drive; + ContinueDriveChecks(battle); + } + + return CommandResult.Ok(); + } + + private void FinishBattle(BattleState battle) + { + if (State.IsFinished) + { + ClearBattle(battle); + return; + } + var defenderIndex = 1 - battle.AttackerPlayer; var attacker = State.GetUnit(battle.AttackerPlayer, battle.AttackerSlot); var target = State.GetUnit(defenderIndex, battle.TargetSlot); @@ -380,20 +508,11 @@ public sealed class GameEngine return; } - if (battle.AttackerSlot < 0) - DriveCheck(battle); - - if (State.IsFinished) - { - ClearBattle(battle); - return; - } - var attackerDef = State.Catalog.CardOf(attacker.PrintingId); var targetDef = State.Catalog.CardOf(target.PrintingId); - var attackPower = attackerDef.Power + battle.PowerBonus; - var defensePower = targetDef.Power + battle.GuardShield; + var attackPower = attackerDef.Power + attacker.TemporaryPower; + var defensePower = targetDef.Power + target.TemporaryPower + battle.GuardShield; var hit = attackPower > defensePower; _events.Add(new BattleResolvedEvent(hit)); @@ -402,7 +521,7 @@ public sealed class GameEngine { if (battle.TargetSlot < 0) { - var damage = attackerDef.Critical + battle.CriticalBonus; + var damage = attackerDef.Critical + attacker.TemporaryCritical; DealDamage(defenderIndex, damage); } else @@ -425,54 +544,18 @@ public sealed class GameEngine defender.Drop.Add(guard); } + foreach (var unit in AllUnits(0).Concat(AllUnits(1))) + { + unit.TemporaryPower = 0; + unit.TemporaryCritical = 0; + } + State.Battle = null; + State.PendingTrigger = null; State.Step = Step.None; State.ActivePlayer = battle.AttackerPlayer; } - private void DriveCheck(BattleState battle) - { - var player = battle.AttackerPlayer; - - for (var i = 0; i < DriveChecks; i++) - { - if (State.IsFinished) - return; - - var card = TakeTop(player); - if (card is null) - return; - - var def = State.Catalog.CardOf(card.PrintingId); - if (def.Trigger == TriggerType.None) - { - State.Players[player].Hand.Add(card); - _events.Add(new CardDrawnEvent(player)); - continue; - } - - State.Players[player].Trigger.Add(card); - battle.PowerBonus += TriggerPowerBonus; - _events.Add(new TriggerResolvedEvent(player, card.PrintingId, def.Trigger)); - - switch (def.Trigger) - { - case TriggerType.Critical: - battle.CriticalBonus += 1; - break; - case TriggerType.Draw: - Draw(player, 1); - break; - case TriggerType.Stand: - StandFirstRested(player); - break; - case TriggerType.Heal: - Heal(player); - break; - } - } - } - private void DealDamage(int player, int amount) { for (var i = 0; i < amount; i++) diff --git a/server/src/Vanguard.Rules/Engine/GameEvents.cs b/server/src/Vanguard.Rules/Engine/GameEvents.cs index 994f121..0a80ba9 100644 --- a/server/src/Vanguard.Rules/Engine/GameEvents.cs +++ b/server/src/Vanguard.Rules/Engine/GameEvents.cs @@ -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 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; public sealed record BattleResolvedEvent(bool Hit) : GameEvent; public sealed record DamageTakenEvent(int Player, int Amount, int Total) : GameEvent; public sealed record HealedEvent(int Player) : GameEvent; diff --git a/server/src/Vanguard.Rules/Engine/GameModels.cs b/server/src/Vanguard.Rules/Engine/GameModels.cs index e2df5d2..dcc851a 100644 --- a/server/src/Vanguard.Rules/Engine/GameModels.cs +++ b/server/src/Vanguard.Rules/Engine/GameModels.cs @@ -1,3 +1,5 @@ +using Vanguard.Rules.Cards; + namespace Vanguard.Rules.Engine; public enum Phase @@ -15,6 +17,26 @@ public enum Step { None, Guard, + Drive, + Trigger, +} + +public enum TriggerContext +{ + DriveCheck, + DamageCheck, +} + +/// Триггер, который ждёт выбора целей от владельца. +public sealed class PendingTrigger +{ + public required int Player { get; init; } + public required string PrintingId { get; init; } + public required TriggerType Trigger { get; init; } + public required TriggerContext Context { get; init; } + + /// На drive check триггер сначала даёт +5000 силы, на damage check — нет. + public required bool GrantsPower { get; init; } } /// Экземпляр карты в партии. Один принт может встречаться много раз. @@ -28,6 +50,12 @@ public sealed class CardInstance /// Открыта ли карта. Для урона — лицом вверх после Counter Blast. public bool FaceUp { get; set; } + + /// Временный бонус силы до конца текущего боя (триггеры, эффекты). + public int TemporaryPower { get; set; } + + /// Временный бонус крита до конца текущего боя. + public int TemporaryCritical { 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 01eb855..1ded7ac 100644 --- a/server/src/Vanguard.Rules/Engine/GameState.cs +++ b/server/src/Vanguard.Rules/Engine/GameState.cs @@ -12,10 +12,9 @@ public sealed class BattleState /// Индекс цели: -1 — Авангард противника. public required int TargetSlot { get; init; } - public int PowerBonus { get; set; } - public int CriticalBonus { get; set; } - public List GuardCards { get; } = new(); public int GuardShield { get; set; } + public int ChecksRemaining { get; set; } + public List GuardCards { get; } = new(); } public sealed class GameState @@ -40,6 +39,7 @@ public sealed class GameState public int NextInstanceId { get; internal set; } = 1; public BattleState? Battle { get; internal set; } + public PendingTrigger? PendingTrigger { get; internal set; } public bool IsFinished { get; internal set; } public int? Winner { get; internal set; } diff --git a/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs b/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs index 1c0649d..f7d6f03 100644 --- a/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs +++ b/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs @@ -12,6 +12,9 @@ public class GameEngineTests private const string Marron = "BT01/042EN"; // grade 1, 8000 private const string Gallatin = "BT01/021EN"; // grade 2, 10000 private const string Alfred = "BT01/001EN"; // grade 3 + private const string Nike = "BT01/056EN"; // Critical + private const string Flogal = "BT01/046EN"; // Stand + private const string Govannon = "BT01/045EN"; // Draw [Fact] public void Start_Places_Starting_Vanguard_And_Draws_Five() @@ -115,6 +118,49 @@ public class GameEngineTests Assert.Contains(engine.Events, e => e is GameFinishedEvent); } + [Fact] + public void Critical_Trigger_Grants_Power_And_Crit_To_Chosen_Unit() + { + var engine = ReadyToAttack(); + StackDeck(engine.State.Players[0], Nike, Gallatin); + + Assert.True(engine.Execute(new AttackCommand(-1, -1)).Accepted); + Assert.True(engine.Execute(new PassGuardCommand()).Accepted); + + Assert.NotNull(engine.State.PendingTrigger); + Assert.Equal(Step.Trigger, engine.State.Step); + + Assert.True(engine.Execute(new ResolveTriggerCommand(-1, -1)).Accepted); + + Assert.Null(engine.State.PendingTrigger); + Assert.Equal(2, engine.State.Players[1].Damage.Count); + } + + [Fact] + public void Stand_Trigger_Stands_Chosen_RearGuard() + { + var engine = ReadyToAttackWithRearGuard(); + StackDeck(engine.State.Players[0], Flogal, Gallatin); + + Assert.True(engine.Execute(new AttackCommand(-1, -1)).Accepted); + Assert.True(engine.Execute(new PassGuardCommand()).Accepted); + Assert.NotNull(engine.State.PendingTrigger); + + var rearGuard = engine.State.Players[0].RearGuards[0]!; + Assert.True(rearGuard.Rested); + + Assert.True(engine.Execute(new ResolveTriggerCommand(-1, 0)).Accepted); + Assert.False(rearGuard.Rested); + } + + [Fact] + public void ResolveTrigger_Rejects_When_Nothing_Is_Pending() + { + var engine = StartedEngine(); + + Assert.False(engine.Execute(new ResolveTriggerCommand(-1, -1)).Accepted); + } + [Fact] public void Same_Seed_And_Commands_Produce_Same_Events() { @@ -165,9 +211,20 @@ public class GameEngineTests return engine; } + private GameEngine ReadyToAttackWithRearGuard() + { + var engine = StartedEngine(); + Give(engine, 0, Marron); + engine.Execute(new RideCommand(Marron)); + Give(engine, 0, Gallatin); + engine.Execute(new CallCommand(Gallatin, 0)); + engine.Execute(new EnterBattleCommand()); + return engine; + } + private List BuildDeck() { - var mustInclude = new[] { Barcgal, Marron, Gallatin, Alfred }; + var mustInclude = new[] { Barcgal, Marron, Gallatin, Alfred, Nike, Flogal, Govannon }; var names = mustInclude .Select(_catalog.CardOf) .Select(c => c.NameId)