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.
This commit is contained in:
MpakobecEx 2026-10-21 13:26:59 +05:00
parent 4333bb5bd0
commit 6538a470da
3 changed files with 104 additions and 23 deletions

View File

@ -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<GameEvent> Events => _events;
public IReadOnlyDictionary<string, CardScript> Scripts => _scripts;
/// <summary>Глубина разрешения эффекта: пока > 0, новые триггеры только ставятся в очередь.</summary>
private int _resolvingDepth;
/// <summary>
/// Сколько проверок делает атакующая карта. Эффект может изменить число;
/// Арьергард по умолчанию проверок не делает.
@ -101,6 +105,22 @@ public sealed class GameEngine
if (triggered.Count == 0)
return;
RaiseBatch(triggered);
}
private void RaiseBatch(List<TriggeredAbility> triggered)
{
if (triggered.Count == 0)
return;
// внутри другого эффекта триггеры только копятся
if (_resolvingDepth > 0)
{
State.PendingQueue ??= new List<TriggeredAbility>();
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()
@ -188,10 +200,18 @@ public sealed class GameEngine
private void ResolveAbility(TriggeredAbility ability, bool accepted)
{
if (accepted)
{
_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;
_resolvingDepth++;
try
{
choice.Resolve(option.Slot);
}
finally
{
_resolvingDepth--;
}
if (State.Step != Step.Choice)
ContinueOrder();
@ -1432,22 +1471,33 @@ public sealed class GameEngine
{
var damage = attackerDef.Critical + attacker.TemporaryCritical;
void ResolveDamage()
void AfterDamage()
{
DealDamage(defenderIndex, damage);
if (State.PendingTrigger is null)
ClearBattle(battle);
}
TriggerFromCard(battle.AttackerPlayer, attacker, AbilityTrigger.OnAttackHit, null);
if (State.Step is Step.Ability or Step.Choice)
{
State.Continuation = ResolveDamage;
State.Continuation = () => ClearBattle(battle);
return;
}
ClearBattle(battle);
}
void ResolveDamage()
{
DealDamage(defenderIndex, damage);
// урон ещё разрешается (damage-триггер) — hit-эффекты после него
if (State.PendingTrigger is not null)
{
State.Continuation = AfterDamage;
return;
}
AfterDamage();
}
ResolveDamage();
return;
}
@ -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)

View File

@ -66,6 +66,12 @@ public sealed class GameState
/// <summary>Продолжение разрешения, которое надо выполнить после способностей.</summary>
public Action? Continuation { get; internal set; }
/// <summary>
/// Триггеры, сработавшие во время другого эффекта. Разрешаются после него,
/// чтобы цепочка шла строго друг за другом.
/// </summary>
public List<TriggeredAbility>? PendingQueue { get; internal set; }
public bool IsFinished { get; internal set; }
public int? Winner { get; internal set; }

View File

@ -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()
{