Trigger buffs last until end of turn; trigger cards go through the trigger zone first

Order is now: reveal -> trigger zone -> resolve effects -> hand (drive check) or damage (damage check). Damage is added only after the trigger resolves, so heal compares the pre-damage count. Temporary power/crit are cleared at end of turn instead of end of battle.
This commit is contained in:
MpakobecEx 2026-10-21 00:30:28 +05:00
parent ebbfb46834
commit c2480bab63
4 changed files with 75 additions and 30 deletions

View File

@ -114,7 +114,7 @@
- [ ] Check Timing: сбор → сортировка → разрешение по одному → повтор. - [ ] Check Timing: сбор → сортировка → разрешение по одному → повтор.
- [ ] Стоимости: CB, SB, discard, rest self, retire self, put to soul, return to deck. - [ ] Стоимости: CB, SB, discard, rest self, retire self, put to soul, return to deck.
- [ ] Цели: авто по фильтру и выбор игроком, «до N» / «ровно N». - [ ] Цели: авто по фильтру и выбор игроком, «до N» / «ровно N».
- [~] Слои модификаторов силы/крита: временные бонусы до конца боя есть, постоянных слоёв нет. - [~] Слои модификаторов силы/крита: усиления от триггеров живут до конца хода, постоянных слоёв нет.
- [x] Валидация интентов: `Rejected(reason)` без изменения состояния. - [x] Валидация интентов: `Rejected(reason)` без изменения состояния.
- [ ] Список легальных интентов на каждый момент (для UI и античита). - [ ] Список легальных интентов на каждый момент (для UI и античита).
- [~] Детерминированный RNG: один поток на партию, состояние в `GameState`. - [~] Детерминированный RNG: один поток на партию, состояние в `GameState`.

View File

@ -11,7 +11,9 @@ namespace Vanguard.Rules.Engine;
/// * Boost не реализован; /// * Boost не реализован;
/// * триггер требует выбора целей через ResolveTriggerCommand: /// * триггер требует выбора целей через ResolveTriggerCommand:
/// +5000 любой карте в кругах Авангарда или Арьергарда, затем второй эффект; /// +5000 любой карте в кругах Авангарда или Арьергарда, затем второй эффект;
/// правило одинаково для drive check и damage check. /// правило одинаково для drive check и damage check;
/// * порядок проверки: карта → зона триггера → эффекты → рука (drive) или урон (damage);
/// * усиления от триггеров живут до конца хода.
/// </summary> /// </summary>
public sealed class GameEngine public sealed class GameEngine
{ {
@ -324,6 +326,8 @@ public sealed class GameEngine
State.Phase = Phase.End; State.Phase = Phase.End;
_events.Add(new PhaseChangedEvent(State.ActivePlayer, Phase.End)); _events.Add(new PhaseChangedEvent(State.ActivePlayer, Phase.End));
ClearTurnModifiers();
if (State.IsFinished) if (State.IsFinished)
return CommandResult.Ok(); return CommandResult.Ok();
@ -368,7 +372,15 @@ public sealed class GameEngine
} }
} }
// ---------- бой ---------- /// <summary>Усиления от триггеров живут до конца хода, а не до конца боя.</summary>
private void ClearTurnModifiers()
{
foreach (var unit in AllUnits(0).Concat(AllUnits(1)))
{
unit.TemporaryPower = 0;
unit.TemporaryCritical = 0;
}
}
private void BeginResolution(BattleState battle) private void BeginResolution(BattleState battle)
{ {
@ -418,6 +430,7 @@ public sealed class GameEngine
{ {
Player = player, Player = player,
PrintingId = card.PrintingId, PrintingId = card.PrintingId,
Card = card,
Trigger = def.Trigger, Trigger = def.Trigger,
Context = TriggerContext.DriveCheck, Context = TriggerContext.DriveCheck,
GrantsPower = true, GrantsPower = true,
@ -480,16 +493,23 @@ public sealed class GameEngine
State.PendingTrigger = null; State.PendingTrigger = null;
_events.Add(new TriggerAppliedEvent(player, command.PowerTarget, command.EffectTarget)); _events.Add(new TriggerAppliedEvent(player, command.PowerTarget, command.EffectTarget));
if (pending.Context == TriggerContext.DriveCheck && State.Battle is { } battle) if (pending.Context == TriggerContext.DriveCheck)
{
// эффекты сработали — теперь карта уходит из зоны триггера в руку
var p = State.Players[player];
p.Trigger.Remove(pending.Card);
p.Hand.Add(pending.Card);
if (State.Battle is { } battle)
{ {
State.Step = Step.Drive; State.Step = Step.Drive;
ContinueDriveChecks(battle); ContinueDriveChecks(battle);
}
return CommandResult.Ok(); return CommandResult.Ok();
} }
if (pending.Context == TriggerContext.DamageCheck)
FinishDamage(pending); FinishDamage(pending);
return CommandResult.Ok(); return CommandResult.Ok();
} }
@ -550,12 +570,6 @@ public sealed class GameEngine
defender.Drop.Add(guard); defender.Drop.Add(guard);
} }
foreach (var unit in AllUnits(0).Concat(AllUnits(1)))
{
unit.TemporaryPower = 0;
unit.TemporaryCritical = 0;
}
State.Battle = null; State.Battle = null;
State.PendingTrigger = null; State.PendingTrigger = null;
State.Step = Step.None; State.Step = Step.None;
@ -573,18 +587,18 @@ public sealed class GameEngine
if (card is null) if (card is null)
return; return;
card.FaceUp = false;
State.Players[player].Damage.Add(card);
_events.Add(new DamageTakenEvent(player, 1, State.Players[player].Damage.Count));
var def = State.Catalog.CardOf(card.PrintingId); var def = State.Catalog.CardOf(card.PrintingId);
if (def.Trigger != TriggerType.None) if (def.Trigger != TriggerType.None)
{ {
// карта попадает в зону триггера, эффекты срабатывают до урона
State.Players[player].Trigger.Add(card);
_events.Add(new TriggerResolvedEvent(player, card.PrintingId, def.Trigger)); _events.Add(new TriggerResolvedEvent(player, card.PrintingId, def.Trigger));
State.PendingTrigger = new PendingTrigger State.PendingTrigger = new PendingTrigger
{ {
Player = player, Player = player,
PrintingId = card.PrintingId, PrintingId = card.PrintingId,
Card = card,
Trigger = def.Trigger, Trigger = def.Trigger,
Context = TriggerContext.DamageCheck, Context = TriggerContext.DamageCheck,
GrantsPower = true, GrantsPower = true,
@ -595,12 +609,22 @@ public sealed class GameEngine
return; return;
} }
if (State.Players[player].Damage.Count >= MaxDamage) PutIntoDamage(player, card);
{
Defeat(player); if (State.IsFinished)
return; return;
} }
} }
private void PutIntoDamage(int player, CardInstance card)
{
card.FaceUp = false;
var p = State.Players[player];
p.Damage.Add(card);
_events.Add(new DamageTakenEvent(player, 1, p.Damage.Count));
if (p.Damage.Count >= MaxDamage)
Defeat(player);
} }
/// <summary>Продолжает урон после разрешения триггера damage check.</summary> /// <summary>Продолжает урон после разрешения триггера damage check.</summary>
@ -609,16 +633,15 @@ public sealed class GameEngine
if (State.IsFinished) if (State.IsFinished)
return; return;
var player = pending.Player; var p = State.Players[pending.Player];
p.Trigger.Remove(pending.Card);
PutIntoDamage(pending.Player, pending.Card);
if (State.Players[player].Damage.Count >= MaxDamage) if (State.IsFinished)
{
Defeat(player);
return; return;
}
if (pending.RemainingDamage > 0) if (pending.RemainingDamage > 0)
DealDamage(player, pending.RemainingDamage); DealDamage(pending.Player, pending.RemainingDamage);
if (State.PendingTrigger is not null) if (State.PendingTrigger is not null)
return; return;

View File

@ -32,6 +32,7 @@ public sealed class PendingTrigger
{ {
public required int Player { get; init; } public required int Player { get; init; }
public required string PrintingId { get; init; } public required string PrintingId { get; init; }
public required CardInstance Card { get; init; }
public required TriggerType Trigger { get; init; } public required TriggerType Trigger { get; init; }
public required TriggerContext Context { get; init; } public required TriggerContext Context { get; init; }

View File

@ -166,17 +166,38 @@ public class GameEngineTests
var pending = engine.State.PendingTrigger; var pending = engine.State.PendingTrigger;
Assert.NotNull(pending); Assert.NotNull(pending);
Assert.Equal(TriggerContext.DamageCheck, pending!.Context); Assert.Equal(TriggerContext.DamageCheck, pending!.Context);
Assert.Single(engine.State.Players[1].Damage);
// до разрешения карта лежит в зоне триггера, а не в уроне
Assert.Empty(engine.State.Players[1].Damage);
Assert.Contains(engine.State.Players[1].Trigger, c => c.PrintingId == Nike);
Assert.True(engine.Execute(new ResolveTriggerCommand(-1, -1)).Accepted); Assert.True(engine.Execute(new ResolveTriggerCommand(-1, -1)).Accepted);
Assert.Null(engine.State.PendingTrigger); Assert.Null(engine.State.PendingTrigger);
Assert.Null(engine.State.Battle); Assert.Null(engine.State.Battle);
Assert.Single(engine.State.Players[1].Damage); Assert.Single(engine.State.Players[1].Damage);
Assert.Empty(engine.State.Players[1].Trigger);
Assert.Contains(engine.State.Players[1].Damage, c => c.PrintingId == Nike); Assert.Contains(engine.State.Players[1].Damage, c => c.PrintingId == Nike);
Assert.Contains(engine.Events, e => e is TriggerAppliedEvent { Player: 1 }); Assert.Contains(engine.Events, e => e is TriggerAppliedEvent { Player: 1 });
} }
[Fact]
public void Trigger_Power_Lasts_Until_End_Of_Turn()
{
var engine = ReadyToAttack();
StackDeck(engine.State.Players[0], Nike, Gallatin);
engine.Execute(new AttackCommand(-1, -1));
engine.Execute(new PassGuardCommand());
engine.Execute(new ResolveTriggerCommand(-1, -1));
var vanguard = engine.State.Players[0].Vanguard!;
Assert.Equal(GameEngine.TriggerPowerBonus, vanguard.TemporaryPower);
Assert.True(engine.Execute(new EndTurnCommand()).Accepted);
Assert.Equal(0, vanguard.TemporaryPower);
}
[Fact] [Fact]
public void ResolveTrigger_Rejects_When_Nothing_Is_Pending() public void ResolveTrigger_Rejects_When_Nothing_Is_Pending()
{ {