diff --git a/IMPLEMENTATION_CHECKLIST.md b/IMPLEMENTATION_CHECKLIST.md
index 5576251..422a4e4 100644
--- a/IMPLEMENTATION_CHECKLIST.md
+++ b/IMPLEMENTATION_CHECKLIST.md
@@ -114,7 +114,7 @@
- [ ] Check Timing: сбор → сортировка → разрешение по одному → повтор.
- [ ] Стоимости: CB, SB, discard, rest self, retire self, put to soul, return to deck.
- [ ] Цели: авто по фильтру и выбор игроком, «до N» / «ровно N».
-- [ ] Слои модификаторов силы/крита с длительностью.
+- [~] Слои модификаторов силы/крита: временные бонусы до конца боя есть, постоянных слоёв нет.
- [x] Валидация интентов: `Rejected(reason)` без изменения состояния.
- [ ] Список легальных интентов на каждый момент (для UI и античита).
- [~] Детерминированный RNG: один поток на партию, состояние в `GameState`.
diff --git a/server/src/Vanguard.Rules/Engine/GameCommands.cs b/server/src/Vanguard.Rules/Engine/GameCommands.cs
index 728877a..685e32d 100644
--- a/server/src/Vanguard.Rules/Engine/GameCommands.cs
+++ b/server/src/Vanguard.Rules/Engine/GameCommands.cs
@@ -11,6 +11,7 @@ public sealed record EnterBattleCommand : GameCommand;
public sealed record AttackCommand(int AttackerSlot, int TargetSlot) : GameCommand;
public sealed record GuardCommand(string PrintingId) : GameCommand;
public sealed record PassGuardCommand : GameCommand;
+public sealed record ResolveTriggerCommand(int PowerTarget, int EffectTarget) : GameCommand;
public sealed record EndTurnCommand : GameCommand;
public sealed record CommandResult(bool Accepted, string? Reason)
diff --git a/server/src/Vanguard.Rules/Engine/GameEngine.cs b/server/src/Vanguard.Rules/Engine/GameEngine.cs
index 72145d5..69c87d1 100644
--- a/server/src/Vanguard.Rules/Engine/GameEngine.cs
+++ b/server/src/Vanguard.Rules/Engine/GameEngine.cs
@@ -9,8 +9,10 @@ namespace Vanguard.Rules.Engine;
/// Упрощения первого среза (карты со способностями подключаются следующим шагом):
/// * Sentinel добавляет 0 щита и не оплачивает стоимость — Perfect Guard пока не работает;
/// * Boost не реализован;
-/// * выбор цели триггера автоматический: усиление получает атакующая карта,
-/// Stand возвращает первый повёрнутый Арьергард.
+/// * триггер на drive check требует выбора целей через ResolveTriggerCommand:
+/// +5000 любой карте в кругах Авангарда или Арьергарда, затем второй эффект;
+/// * триггер на damage check пока применяет только второй эффект,
+/// Stand поднимает первый повёрнутый Арьергард.
///
public sealed class GameEngine
{
@@ -105,6 +107,7 @@ public sealed class GameEngine
AttackCommand c => Attack(c.AttackerSlot, c.TargetSlot),
GuardCommand c => Guard(c.PrintingId),
PassGuardCommand => PassGuard(),
+ ResolveTriggerCommand c => ResolveTrigger(c),
EndTurnCommand => EndTurn(),
_ => CommandResult.Reject("Неизвестная команда."),
};
@@ -307,7 +310,7 @@ public sealed class GameEngine
if (State.ActivePlayer != 1 - battle.AttackerPlayer)
return CommandResult.Reject("Защищается только обороняющийся.");
- ResolveBattle(battle);
+ BeginResolution(battle);
return CommandResult.Ok();
}
@@ -368,8 +371,133 @@ public sealed class GameEngine
// ---------- бой ----------
- private void ResolveBattle(BattleState battle)
+ private void BeginResolution(BattleState battle)
{
+ if (battle.AttackerSlot >= 0)
+ {
+ FinishBattle(battle);
+ return;
+ }
+
+ battle.ChecksRemaining = DriveChecks;
+ State.ActivePlayer = battle.AttackerPlayer;
+ State.Step = Step.Drive;
+ ContinueDriveChecks(battle);
+ }
+
+ ///
+ /// Идёт по drive-чекам. На триггере останавливается и ждёт ResolveTriggerCommand:
+ /// игрок сам выбирает, кому дать +5000 и куда применить второй эффект.
+ ///
+ private void ContinueDriveChecks(BattleState battle)
+ {
+ var player = battle.AttackerPlayer;
+
+ while (battle.ChecksRemaining > 0 && !State.IsFinished)
+ {
+ var card = TakeTop(player);
+ if (card is null)
+ {
+ FinishBattle(battle);
+ return;
+ }
+
+ var def = State.Catalog.CardOf(card.PrintingId);
+ if (def.Trigger == TriggerType.None)
+ {
+ State.Players[player].Hand.Add(card);
+ _events.Add(new CardDrawnEvent(player));
+ battle.ChecksRemaining--;
+ continue;
+ }
+
+ State.Players[player].Trigger.Add(card);
+ battle.ChecksRemaining--;
+ _events.Add(new TriggerResolvedEvent(player, card.PrintingId, def.Trigger));
+
+ State.PendingTrigger = new PendingTrigger
+ {
+ Player = player,
+ PrintingId = card.PrintingId,
+ Trigger = def.Trigger,
+ Context = TriggerContext.DriveCheck,
+ GrantsPower = true,
+ };
+ State.Step = Step.Trigger;
+ State.ActivePlayer = player;
+ return;
+ }
+
+ FinishBattle(battle);
+ }
+
+ private CommandResult ResolveTrigger(ResolveTriggerCommand command)
+ {
+ var pending = State.PendingTrigger;
+ if (pending is null || State.Step != Step.Trigger)
+ return CommandResult.Reject("Сейчас нет триггера для разрешения.");
+
+ if (State.ActivePlayer != pending.Player)
+ return CommandResult.Reject("Триггер разрешает его владелец.");
+
+ var player = pending.Player;
+
+ if (pending.GrantsPower)
+ {
+ var powerTarget = State.GetUnit(player, command.PowerTarget);
+ if (powerTarget is null)
+ return CommandResult.Reject("Нет цели для +5000 силы.");
+ }
+
+ if (pending.Trigger is TriggerType.Critical or TriggerType.Stand)
+ {
+ var effectUnit = State.GetUnit(player, command.EffectTarget);
+ if (effectUnit is null)
+ return CommandResult.Reject("Нет цели для второго эффекта.");
+
+ if (pending.Trigger == TriggerType.Stand && command.EffectTarget < 0)
+ return CommandResult.Reject("Поднять можно только карту в кругу Арьергарда.");
+ }
+
+ if (pending.GrantsPower)
+ State.GetUnit(player, command.PowerTarget)!.TemporaryPower += TriggerPowerBonus;
+
+ switch (pending.Trigger)
+ {
+ case TriggerType.Critical:
+ State.GetUnit(player, command.EffectTarget)!.TemporaryCritical += 1;
+ break;
+ case TriggerType.Stand:
+ State.GetUnit(player, command.EffectTarget)!.Rested = false;
+ break;
+ case TriggerType.Draw:
+ Draw(player, 1);
+ break;
+ case TriggerType.Heal:
+ Heal(player);
+ break;
+ }
+
+ State.PendingTrigger = null;
+ _events.Add(new TriggerAppliedEvent(player, command.PowerTarget, command.EffectTarget));
+
+ if (pending.Context == TriggerContext.DriveCheck && State.Battle is { } battle)
+ {
+ State.Step = Step.Drive;
+ ContinueDriveChecks(battle);
+ }
+
+ return CommandResult.Ok();
+ }
+
+ private void FinishBattle(BattleState battle)
+ {
+ if (State.IsFinished)
+ {
+ ClearBattle(battle);
+ return;
+ }
+
var defenderIndex = 1 - battle.AttackerPlayer;
var attacker = State.GetUnit(battle.AttackerPlayer, battle.AttackerSlot);
var target = State.GetUnit(defenderIndex, battle.TargetSlot);
@@ -380,20 +508,11 @@ public sealed class GameEngine
return;
}
- if (battle.AttackerSlot < 0)
- DriveCheck(battle);
-
- if (State.IsFinished)
- {
- ClearBattle(battle);
- return;
- }
-
var attackerDef = State.Catalog.CardOf(attacker.PrintingId);
var targetDef = State.Catalog.CardOf(target.PrintingId);
- var attackPower = attackerDef.Power + battle.PowerBonus;
- var defensePower = targetDef.Power + battle.GuardShield;
+ var attackPower = attackerDef.Power + attacker.TemporaryPower;
+ var defensePower = targetDef.Power + target.TemporaryPower + battle.GuardShield;
var hit = attackPower > defensePower;
_events.Add(new BattleResolvedEvent(hit));
@@ -402,7 +521,7 @@ public sealed class GameEngine
{
if (battle.TargetSlot < 0)
{
- var damage = attackerDef.Critical + battle.CriticalBonus;
+ var damage = attackerDef.Critical + attacker.TemporaryCritical;
DealDamage(defenderIndex, damage);
}
else
@@ -425,54 +544,18 @@ public sealed class GameEngine
defender.Drop.Add(guard);
}
+ foreach (var unit in AllUnits(0).Concat(AllUnits(1)))
+ {
+ unit.TemporaryPower = 0;
+ unit.TemporaryCritical = 0;
+ }
+
State.Battle = null;
+ State.PendingTrigger = null;
State.Step = Step.None;
State.ActivePlayer = battle.AttackerPlayer;
}
- private void DriveCheck(BattleState battle)
- {
- var player = battle.AttackerPlayer;
-
- for (var i = 0; i < DriveChecks; i++)
- {
- if (State.IsFinished)
- return;
-
- var card = TakeTop(player);
- if (card is null)
- return;
-
- var def = State.Catalog.CardOf(card.PrintingId);
- if (def.Trigger == TriggerType.None)
- {
- State.Players[player].Hand.Add(card);
- _events.Add(new CardDrawnEvent(player));
- continue;
- }
-
- State.Players[player].Trigger.Add(card);
- battle.PowerBonus += TriggerPowerBonus;
- _events.Add(new TriggerResolvedEvent(player, card.PrintingId, def.Trigger));
-
- switch (def.Trigger)
- {
- case TriggerType.Critical:
- battle.CriticalBonus += 1;
- break;
- case TriggerType.Draw:
- Draw(player, 1);
- break;
- case TriggerType.Stand:
- StandFirstRested(player);
- break;
- case TriggerType.Heal:
- Heal(player);
- break;
- }
- }
- }
-
private void DealDamage(int player, int amount)
{
for (var i = 0; i < amount; i++)
diff --git a/server/src/Vanguard.Rules/Engine/GameEvents.cs b/server/src/Vanguard.Rules/Engine/GameEvents.cs
index 994f121..0a80ba9 100644
--- a/server/src/Vanguard.Rules/Engine/GameEvents.cs
+++ b/server/src/Vanguard.Rules/Engine/GameEvents.cs
@@ -13,6 +13,7 @@ public sealed record UnitCalledEvent(int Player, int Slot, string PrintingId) :
public sealed record AttackDeclaredEvent(int Player, int AttackerSlot, int TargetSlot) : GameEvent;
public sealed record GuardAddedEvent(int Player, string PrintingId, int Shield) : GameEvent;
public sealed record TriggerResolvedEvent(int Player, string PrintingId, TriggerType Trigger) : GameEvent;
+public sealed record TriggerAppliedEvent(int Player, int PowerTarget, int EffectTarget) : GameEvent;
public sealed record BattleResolvedEvent(bool Hit) : GameEvent;
public sealed record DamageTakenEvent(int Player, int Amount, int Total) : GameEvent;
public sealed record HealedEvent(int Player) : GameEvent;
diff --git a/server/src/Vanguard.Rules/Engine/GameModels.cs b/server/src/Vanguard.Rules/Engine/GameModels.cs
index e2df5d2..dcc851a 100644
--- a/server/src/Vanguard.Rules/Engine/GameModels.cs
+++ b/server/src/Vanguard.Rules/Engine/GameModels.cs
@@ -1,3 +1,5 @@
+using Vanguard.Rules.Cards;
+
namespace Vanguard.Rules.Engine;
public enum Phase
@@ -15,6 +17,26 @@ public enum Step
{
None,
Guard,
+ Drive,
+ Trigger,
+}
+
+public enum TriggerContext
+{
+ DriveCheck,
+ DamageCheck,
+}
+
+/// Триггер, который ждёт выбора целей от владельца.
+public sealed class PendingTrigger
+{
+ public required int Player { get; init; }
+ public required string PrintingId { get; init; }
+ public required TriggerType Trigger { get; init; }
+ public required TriggerContext Context { get; init; }
+
+ /// На drive check триггер сначала даёт +5000 силы, на damage check — нет.
+ public required bool GrantsPower { get; init; }
}
/// Экземпляр карты в партии. Один принт может встречаться много раз.
@@ -28,6 +50,12 @@ public sealed class CardInstance
/// Открыта ли карта. Для урона — лицом вверх после Counter Blast.
public bool FaceUp { get; set; }
+
+ /// Временный бонус силы до конца текущего боя (триггеры, эффекты).
+ public int TemporaryPower { get; set; }
+
+ /// Временный бонус крита до конца текущего боя.
+ public int TemporaryCritical { get; set; }
}
public sealed class PlayerState
diff --git a/server/src/Vanguard.Rules/Engine/GameState.cs b/server/src/Vanguard.Rules/Engine/GameState.cs
index 01eb855..1ded7ac 100644
--- a/server/src/Vanguard.Rules/Engine/GameState.cs
+++ b/server/src/Vanguard.Rules/Engine/GameState.cs
@@ -12,10 +12,9 @@ public sealed class BattleState
/// Индекс цели: -1 — Авангард противника.
public required int TargetSlot { get; init; }
- public int PowerBonus { get; set; }
- public int CriticalBonus { get; set; }
- public List GuardCards { get; } = new();
public int GuardShield { get; set; }
+ public int ChecksRemaining { get; set; }
+ public List GuardCards { get; } = new();
}
public sealed class GameState
@@ -40,6 +39,7 @@ public sealed class GameState
public int NextInstanceId { get; internal set; } = 1;
public BattleState? Battle { get; internal set; }
+ public PendingTrigger? PendingTrigger { 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 1c0649d..f7d6f03 100644
--- a/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs
+++ b/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs
@@ -12,6 +12,9 @@ public class GameEngineTests
private const string Marron = "BT01/042EN"; // grade 1, 8000
private const string Gallatin = "BT01/021EN"; // grade 2, 10000
private const string Alfred = "BT01/001EN"; // grade 3
+ private const string Nike = "BT01/056EN"; // Critical
+ private const string Flogal = "BT01/046EN"; // Stand
+ private const string Govannon = "BT01/045EN"; // Draw
[Fact]
public void Start_Places_Starting_Vanguard_And_Draws_Five()
@@ -115,6 +118,49 @@ public class GameEngineTests
Assert.Contains(engine.Events, e => e is GameFinishedEvent);
}
+ [Fact]
+ public void Critical_Trigger_Grants_Power_And_Crit_To_Chosen_Unit()
+ {
+ var engine = ReadyToAttack();
+ StackDeck(engine.State.Players[0], Nike, Gallatin);
+
+ Assert.True(engine.Execute(new AttackCommand(-1, -1)).Accepted);
+ Assert.True(engine.Execute(new PassGuardCommand()).Accepted);
+
+ Assert.NotNull(engine.State.PendingTrigger);
+ Assert.Equal(Step.Trigger, engine.State.Step);
+
+ Assert.True(engine.Execute(new ResolveTriggerCommand(-1, -1)).Accepted);
+
+ Assert.Null(engine.State.PendingTrigger);
+ Assert.Equal(2, engine.State.Players[1].Damage.Count);
+ }
+
+ [Fact]
+ public void Stand_Trigger_Stands_Chosen_RearGuard()
+ {
+ var engine = ReadyToAttackWithRearGuard();
+ StackDeck(engine.State.Players[0], Flogal, Gallatin);
+
+ Assert.True(engine.Execute(new AttackCommand(-1, -1)).Accepted);
+ Assert.True(engine.Execute(new PassGuardCommand()).Accepted);
+ Assert.NotNull(engine.State.PendingTrigger);
+
+ var rearGuard = engine.State.Players[0].RearGuards[0]!;
+ Assert.True(rearGuard.Rested);
+
+ Assert.True(engine.Execute(new ResolveTriggerCommand(-1, 0)).Accepted);
+ Assert.False(rearGuard.Rested);
+ }
+
+ [Fact]
+ public void ResolveTrigger_Rejects_When_Nothing_Is_Pending()
+ {
+ var engine = StartedEngine();
+
+ Assert.False(engine.Execute(new ResolveTriggerCommand(-1, -1)).Accepted);
+ }
+
[Fact]
public void Same_Seed_And_Commands_Produce_Same_Events()
{
@@ -165,9 +211,20 @@ public class GameEngineTests
return engine;
}
+ private GameEngine ReadyToAttackWithRearGuard()
+ {
+ var engine = StartedEngine();
+ Give(engine, 0, Marron);
+ engine.Execute(new RideCommand(Marron));
+ Give(engine, 0, Gallatin);
+ engine.Execute(new CallCommand(Gallatin, 0));
+ engine.Execute(new EnterBattleCommand());
+ return engine;
+ }
+
private List BuildDeck()
{
- var mustInclude = new[] { Barcgal, Marron, Gallatin, Alfred };
+ var mustInclude = new[] { Barcgal, Marron, Gallatin, Alfred, Nike, Flogal, Govannon };
var names = mustInclude
.Select(_catalog.CardOf)
.Select(c => c.NameId)