From 6538a470da0a3ca24f10632f1ba65792df6fba97 Mon Sep 17 00:00:00 2001 From: MpakobecEx Date: Wed, 21 Oct 2026 13:26:59 +0500 Subject: [PATCH] Hit effects resolve after damage; effects never nest OnAttackHit now fires after the damage check and after the card enters the damage zone, with a continuation when damage triggers pause. Added a resolution queue: triggers raised while an effect is resolving are stored and played back strictly one after another (depth counter on ResolveAbility and Choose). New test asserts damage exists before the hit effect is offered. --- .../src/Vanguard.Rules/Engine/GameEngine.cs | 103 ++++++++++++++---- server/src/Vanguard.Rules/Engine/GameState.cs | 6 + .../Vanguard.Rules.Tests/GameEngineTests.cs | 18 +++ 3 files changed, 104 insertions(+), 23 deletions(-) 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() {