diff --git a/server/src/Vanguard.Rules/Engine/GameEngine.cs b/server/src/Vanguard.Rules/Engine/GameEngine.cs index 43b4720..3bfdd8e 100644 --- a/server/src/Vanguard.Rules/Engine/GameEngine.cs +++ b/server/src/Vanguard.Rules/Engine/GameEngine.cs @@ -18,8 +18,9 @@ namespace Vanguard.Rules.Engine; /// из заднего ряда — только если эффект разрешил (InterceptFromBackRow); /// * число проверок принадлежит атакующей карте: 0–2 ранг — одна, 3+ — две; /// Арьергард по умолчанию без проверок, но эффект может их выдать; -/// * способности карт: Triggered, Continuous и Activated; выбор цели идёт через -/// PendingChoice (ChooseCommand), порядок триггеров выбирает активный игрок; +/// * эффекты попадания срабатывают после нанесения урона и помещения карты в зону урона; +/// * эффекты не вкладываются друг в друга: триггеры, поднятые во время эффекта, +/// копятся в очереди и разрешаются после него; /// * непрерывные эффекты пересчитываются при каждом запросе (EffectivePower); /// * триггер требует выбора целей через ResolveTriggerCommand: /// +5000 любой карте в кругах Авангарда или Арьергарда, затем второй эффект; @@ -61,6 +62,9 @@ public sealed class GameEngine public IReadOnlyList Events => _events; public IReadOnlyDictionary Scripts => _scripts; + /// Глубина разрешения эффекта: пока > 0, новые триггеры только ставятся в очередь. + private int _resolvingDepth; + /// /// Сколько проверок делает атакующая карта. Эффект может изменить число; /// Арьергард по умолчанию проверок не делает. @@ -101,6 +105,22 @@ public sealed class GameEngine if (triggered.Count == 0) return; + RaiseBatch(triggered); + } + + private void RaiseBatch(List triggered) + { + if (triggered.Count == 0) + return; + + // внутри другого эффекта триггеры только копятся + if (_resolvingDepth > 0) + { + State.PendingQueue ??= new List(); + State.PendingQueue.AddRange(triggered); + return; + } + if (triggered.Count == 1) { OfferAbility(triggered[0]); @@ -146,15 +166,7 @@ public sealed class GameEngine .OrderBy(t => t.Owner == State.ActivePlayer ? 0 : 1) .ToList(); - if (triggered.Count == 1) - { - OfferAbility(triggered[0]); - return; - } - - State.PendingOrder = triggered; - State.Step = Step.Ability; - State.ActivePlayer = triggered[0].Owner; + RaiseBatch(triggered); } private void EnterMainPhase() @@ -189,8 +201,16 @@ public sealed class GameEngine { if (accepted) { - ability.Definition.Pay?.Invoke(ability.Context); - ability.Definition.Resolve(ability.Context); + _resolvingDepth++; + try + { + ability.Definition.Pay?.Invoke(ability.Context); + ability.Definition.Resolve(ability.Context); + } + finally + { + _resolvingDepth--; + } } _events.Add(new AbilityResolvedEvent(ability.Owner, ability.Context.Source.PrintingId, accepted)); @@ -204,6 +224,17 @@ public sealed class GameEngine private void ContinueOrder() { + // сначала доигрываем триггеры, накопившиеся во время эффекта + if (State.PendingQueue is { Count: > 0 } queued + && State.PendingOrder is null + && State.PendingAbility is null) + { + var batch = queued.ToList(); + State.PendingQueue = null; + RaiseBatch(batch); + return; + } + if (State.PendingOrder is { Count: > 0 } order) { if (order.Count == 1) @@ -317,7 +348,15 @@ public sealed class GameEngine State.PendingChoice = null; State.Step = Step.None; - choice.Resolve(option.Slot); + _resolvingDepth++; + try + { + choice.Resolve(option.Slot); + } + finally + { + _resolvingDepth--; + } if (State.Step != Step.Choice) ContinueOrder(); @@ -1432,20 +1471,31 @@ public sealed class GameEngine { var damage = attackerDef.Critical + attacker.TemporaryCritical; + void AfterDamage() + { + TriggerFromCard(battle.AttackerPlayer, attacker, AbilityTrigger.OnAttackHit, null); + + if (State.Step is Step.Ability or Step.Choice) + { + State.Continuation = () => ClearBattle(battle); + return; + } + + ClearBattle(battle); + } + void ResolveDamage() { DealDamage(defenderIndex, damage); - if (State.PendingTrigger is null) - ClearBattle(battle); - } + // урон ещё разрешается (damage-триггер) — hit-эффекты после него + if (State.PendingTrigger is not null) + { + State.Continuation = AfterDamage; + return; + } - TriggerFromCard(battle.AttackerPlayer, attacker, AbilityTrigger.OnAttackHit, null); - - if (State.Step is Step.Ability or Step.Choice) - { - State.Continuation = ResolveDamage; - return; + AfterDamage(); } ResolveDamage(); @@ -1550,6 +1600,13 @@ public sealed class GameEngine if (State.PendingTrigger is not null) return; + if (State.Continuation is { } continuation) + { + State.Continuation = null; + continuation(); + return; + } + State.Step = Step.None; if (State.Battle is { } battle) diff --git a/server/src/Vanguard.Rules/Engine/GameState.cs b/server/src/Vanguard.Rules/Engine/GameState.cs index e162032..6cc5c10 100644 --- a/server/src/Vanguard.Rules/Engine/GameState.cs +++ b/server/src/Vanguard.Rules/Engine/GameState.cs @@ -66,6 +66,12 @@ public sealed class GameState /// Продолжение разрешения, которое надо выполнить после способностей. public Action? Continuation { get; internal set; } + /// + /// Триггеры, сработавшие во время другого эффекта. Разрешаются после него, + /// чтобы цепочка шла строго друг за другом. + /// + public List? PendingQueue { 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 189fdd6..2ad56f4 100644 --- a/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs +++ b/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs @@ -922,6 +922,24 @@ public class GameEngineTests Assert.Single(engine.State.Players[1].Damage); } + [Fact] + public void Attack_Hit_Effects_Run_After_Damage() + { + var engine = StartedEngine(); + engine.Execute(new SkipRideCommand()); + SetVanguard(engine, 0, Libra); + GiveDamage(engine, 0, 2); + StackDeck(engine.State.Players[0], Gallatin, Gallatin); + engine.Execute(new EnterBattleCommand()); + + Assert.True(engine.Execute(new AttackCommand(-1, -1)).Accepted); + Assert.True(engine.Execute(new PassGuardCommand()).Accepted); + + // урон уже нанесён, и только теперь спрашивают про эффект попадания + Assert.Single(engine.State.Players[1].Damage); + Assert.NotNull(engine.State.PendingAbility); + } + [Fact] public void ResolveTrigger_Rejects_When_Nothing_Is_Pending() {