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:
parent
4333bb5bd0
commit
6538a470da
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in New Issue