diff --git a/IMPLEMENTATION_CHECKLIST.md b/IMPLEMENTATION_CHECKLIST.md
index bea63bd..63f9e8f 100644
--- a/IMPLEMENTATION_CHECKLIST.md
+++ b/IMPLEMENTATION_CHECKLIST.md
@@ -114,7 +114,8 @@
- [ ] Discard, Shuffle как отдельные интенты.
- [x] Число проверок принадлежит атакующей карте: 0–2 ранг — одна, 3+ — две; Арьергард по умолчанию без проверок; `DriveChecksOverride` меняет число (в том числе снимает двойную).
- [x] Результат боя: `Hit` / `Guarded` как событие.
-- [x] Событийная шина: `Triggered` (OnRideOver, OnPlace, OnBoost), `Continuous` с пересчётом при запросе, `Activated` (повернуть себя + сброс карты).
+- [x] Событийная шина: `Triggered` (OnRideOver, OnPlace, OnBoost, OnPhaseStart, OnAttackDeclared, OnAttackHit), `Continuous` с пересчётом при запросе, `Activated` (повернуть себя + сброс карты).
+- [ ] Событие OnDamageTaken и триггеры на получение урона.
- [x] Check Timing: сбор сработавших, порядок (активный игрок первым, выбор игрока), пауза на опциональные способности.
- [x] Выбор целей: `PendingChoice` + `ChooseCommand`, используется Blaster Blade для уничтожения Арьергарда противника.
- [ ] Стоимости: CB, SB, discard, rest self, retire self, put to soul, return to deck.
diff --git a/server/src/Vanguard.Rules/Engine/Abilities/CardScripts.cs b/server/src/Vanguard.Rules/Engine/Abilities/CardScripts.cs
index 87494f2..d685fcd 100644
--- a/server/src/Vanguard.Rules/Engine/Abilities/CardScripts.cs
+++ b/server/src/Vanguard.Rules/Engine/Abilities/CardScripts.cs
@@ -17,9 +17,73 @@ public static class CardScripts
["screamin-and-dancin-announcer-shout"] = new DrawOneScript("screamin-and-dancin-announcer-shout"),
["lizard-soldier-conroe"] = new ConroeScript(),
["battle-sister-cocoa"] = new CocoaScript(),
+ ["tyrant-deathrex"] = new DeathrexScript(),
+ ["demon-eater"] = new DemonEaterScript(),
+ ["maiden-of-libra"] = new MaidenOfLibraScript(),
};
}
+/// Tyrant, Deathrex: когда эта карта атакует, получает +5000 до конца боя.
+public sealed class DeathrexScript : CardScript
+{
+ public override string NameId => "tyrant-deathrex";
+
+ public override IEnumerable Abilities
+ {
+ get
+ {
+ yield return new AbilityDefinition
+ {
+ Trigger = AbilityTrigger.OnAttackDeclared,
+ Resolve = ctx => ctx.Engine.AddBattlePower(5000),
+ };
+ }
+ }
+}
+
+/// Demon Eater: в начале Основной фазы Soul Charge 1 и +2000 до конца хода.
+public sealed class DemonEaterScript : CardScript
+{
+ public override string NameId => "demon-eater";
+
+ public override IEnumerable Abilities
+ {
+ get
+ {
+ yield return new AbilityDefinition
+ {
+ Trigger = AbilityTrigger.OnPhaseStart,
+ Resolve = ctx =>
+ {
+ ctx.Engine.SoulCharge(ctx.Owner, 1);
+ ctx.Source.TemporaryPower += 2000;
+ },
+ };
+ }
+ }
+}
+
+/// Maiden of Libra: когда атака попадает, Counter Blast 2 и взять карту.
+public sealed class MaidenOfLibraScript : CardScript
+{
+ public override string NameId => "maiden-of-libra";
+
+ public override IEnumerable Abilities
+ {
+ get
+ {
+ yield return new AbilityDefinition
+ {
+ Trigger = AbilityTrigger.OnAttackHit,
+ Optional = true,
+ CanPay = ctx => ctx.Engine.CanCounterBlast(ctx.Owner, 2),
+ Pay = ctx => ctx.Engine.CounterBlast(ctx.Owner, 2),
+ Resolve = ctx => ctx.Engine.DrawCards(ctx.Owner, 1),
+ };
+ }
+ }
+}
+
///
/// Battle Sister, Cocoa: при размещении на VC/RC, если Авангард Oracle Think Tank,
/// посмотреть верхнюю карту и оставить её на верху или отправить в низ.
diff --git a/server/src/Vanguard.Rules/Engine/GameEngine.cs b/server/src/Vanguard.Rules/Engine/GameEngine.cs
index af0ff8f..43b4720 100644
--- a/server/src/Vanguard.Rules/Engine/GameEngine.cs
+++ b/server/src/Vanguard.Rules/Engine/GameEngine.cs
@@ -112,6 +112,58 @@ public sealed class GameEngine
State.ActivePlayer = triggered[0].Owner;
}
+ /// Собирает срабатывания со всех карт на поле, активный игрок первым.
+ private void TriggerAll(AbilityTrigger trigger)
+ {
+ var triggered = new List();
+
+ for (var owner = 0; owner < 2; owner++)
+ {
+ foreach (var unit in AllUnits(owner))
+ {
+ var nameId = State.Catalog.CardOf(unit.PrintingId).NameId;
+ if (!_scripts.TryGetValue(nameId, out var script))
+ continue;
+
+ var context = new AbilityContext
+ {
+ Engine = this,
+ Source = unit,
+ Owner = owner,
+ Phase = State.Phase,
+ };
+
+ triggered.AddRange(script.Abilities
+ .Where(a => a.Trigger == trigger && (a.Condition is null || a.Condition(context)))
+ .Select(a => new TriggeredAbility { Definition = a, Context = context }));
+ }
+ }
+
+ if (triggered.Count == 0)
+ return;
+
+ triggered = triggered
+ .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;
+ }
+
+ private void EnterMainPhase()
+ {
+ State.Phase = Phase.Main;
+ _events.Add(new PhaseChangedEvent(State.ActivePlayer, Phase.Main));
+ TriggerAll(AbilityTrigger.OnPhaseStart);
+ }
+
private void OfferAbility(TriggeredAbility ability)
{
var definition = ability.Definition;
@@ -169,6 +221,12 @@ public sealed class GameEngine
}
State.Step = Step.None;
+
+ if (State.Continuation is { } continuation)
+ {
+ State.Continuation = null;
+ continuation();
+ }
}
private CommandResult Activate(int instanceId, string? discardPrintingId)
@@ -404,6 +462,23 @@ public sealed class GameEngine
public bool CanSoulBlast(int player, int amount) =>
State.Players[player].Soul.Count >= amount;
+ public void SoulCharge(int player, int amount)
+ {
+ var p = State.Players[player];
+ for (var i = 0; i < amount; i++)
+ {
+ if (p.Deck.Count == 0)
+ {
+ Defeat(player);
+ return;
+ }
+
+ var card = p.Deck[0];
+ p.Deck.RemoveAt(0);
+ p.Soul.Add(card);
+ }
+ }
+
public void SoulBlast(int player, int amount)
{
var soul = State.Players[player].Soul;
@@ -721,8 +796,25 @@ public sealed class GameEngine
// ---------- команды ----------
- public CommandResult Execute(GameCommand command) => command switch
+ public CommandResult Execute(GameCommand command)
{
+ // пока идёт разрешение способности или триггера, посторонние команды запрещены
+ if (State.Step is Step.Trigger or Step.Ability or Step.Choice)
+ {
+ var allowed = command switch
+ {
+ ResolveTriggerCommand => State.Step == Step.Trigger,
+ ResolveAbilityCommand or SelectTriggerCommand => State.Step == Step.Ability,
+ ChooseCommand => State.Step == Step.Choice,
+ _ => false,
+ };
+
+ if (!allowed)
+ return CommandResult.Reject("Сначала завершите текущее разрешение.");
+ }
+
+ return command switch
+ {
MulliganCommand c => Mulligan(c),
FinishMulliganCommand => FinishMulligan(),
RideCommand c => Ride(c.PrintingId),
@@ -741,7 +833,8 @@ public sealed class GameEngine
ActivateAbilityCommand c => Activate(c.InstanceId, c.DiscardPrintingId),
EndTurnCommand => EndTurn(),
_ => CommandResult.Reject("Неизвестная команда."),
- };
+ };
+ }
private CommandResult Mulligan(MulliganCommand command)
{
@@ -808,14 +901,17 @@ public sealed class GameEngine
player.Vanguard = card;
_events.Add(new UnitRodeEvent(State.ActivePlayer, card.PrintingId));
- State.Phase = Phase.Main;
- _events.Add(new PhaseChangedEvent(State.ActivePlayer, Phase.Main));
TriggerFromCard(State.ActivePlayer, card, AbilityTrigger.OnPlace, null);
if (State.Step == Step.None)
TriggerFromCard(State.ActivePlayer, riddenOver, AbilityTrigger.OnRideOver, card);
+ if (State.Step == Step.None)
+ EnterMainPhase();
+ else
+ State.Continuation = EnterMainPhase;
+
return CommandResult.Ok();
}
@@ -824,8 +920,7 @@ public sealed class GameEngine
if (State.Phase != Phase.Ride)
return CommandResult.Reject("Пропустить смену можно только в фазе Смены.");
- State.Phase = Phase.Main;
- _events.Add(new PhaseChangedEvent(State.ActivePlayer, Phase.Main));
+ EnterMainPhase();
return CommandResult.Ok();
}
@@ -964,12 +1059,22 @@ public sealed class GameEngine
TriggerFromCard(attackerPlayer, booster, AbilityTrigger.OnBoost, attacker);
}
- State.Step = Step.Guard;
-
- // на шаге защиты ход ввода переходит обороняющемуся
- State.ActivePlayer = 1 - attackerPlayer;
-
+ var defenderIndex = 1 - attackerPlayer;
_events.Add(new AttackDeclaredEvent(attackerPlayer, attackerSlot, targetSlot));
+
+ void BeginGuard()
+ {
+ State.Step = Step.Guard;
+ State.ActivePlayer = defenderIndex;
+ }
+
+ TriggerFromCard(attackerPlayer, attacker, AbilityTrigger.OnAttackDeclared, null);
+
+ if (State.Step is Step.Ability or Step.Choice)
+ State.Continuation = BeginGuard;
+ else
+ BeginGuard();
+
return CommandResult.Ok();
}
@@ -1326,7 +1431,25 @@ public sealed class GameEngine
else if (battle.TargetSlot < 0)
{
var damage = attackerDef.Critical + attacker.TemporaryCritical;
- DealDamage(defenderIndex, damage);
+
+ void ResolveDamage()
+ {
+ 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;
+ return;
+ }
+
+ ResolveDamage();
+ return;
}
else
{
diff --git a/server/src/Vanguard.Rules/Engine/GameState.cs b/server/src/Vanguard.Rules/Engine/GameState.cs
index c9762ec..e162032 100644
--- a/server/src/Vanguard.Rules/Engine/GameState.cs
+++ b/server/src/Vanguard.Rules/Engine/GameState.cs
@@ -63,6 +63,9 @@ public sealed class GameState
/// Ожидание выбора цели или карты.
public PendingChoice? PendingChoice { get; internal set; }
+ /// Продолжение разрешения, которое надо выполнить после способностей.
+ public Action? Continuation { 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 4e6be97..189fdd6 100644
--- a/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs
+++ b/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs
@@ -24,6 +24,8 @@ public class GameEngineTests
private const string Lien = "BT01/043EN"; // activated: rest + discard → draw
private const string Cocoa = "BT01/007EN"; // OTT, смотрит верхнюю карту
private const string Libra = "BT01/017EN"; // OTT, grade 2
+ private const string Deathrex = "BT01/033EN"; // Tachikaze, 10000
+ private const string DemonEater = "BT01/036EN"; // Dark Irregulars, 10000
[Fact]
public void Start_Places_Starting_Vanguard_And_Draws_Five()
@@ -872,6 +874,54 @@ public class GameEngineTests
Assert.Equal("blaster-blade", _catalog.CardOf(vanguard.PrintingId).NameId);
}
+ [Fact]
+ public void Deathrex_Gains_5000_When_Attacking()
+ {
+ var engine = StartedEngine();
+ engine.Execute(new SkipRideCommand());
+ SetVanguard(engine, 0, Deathrex);
+ engine.Execute(new EnterBattleCommand());
+
+ Assert.True(engine.Execute(new AttackCommand(-1, -1)).Accepted);
+
+ Assert.Equal(5000, engine.State.Battle!.AbilityPower);
+ }
+
+ [Fact]
+ public void DemonEater_Charges_Soul_At_Main_Phase_Start()
+ {
+ var engine = StartedEngine();
+ SetVanguard(engine, 0, DemonEater);
+ engine.Execute(new SkipRideCommand());
+
+ var vanguard = engine.State.Players[0].Vanguard!;
+ Assert.Single(engine.State.Players[0].Soul);
+ Assert.Equal(12000, engine.EffectivePower(vanguard));
+ }
+
+ [Fact]
+ public void MaidenOfLibra_Draws_On_Hit_When_Cost_Paid()
+ {
+ 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.NotNull(engine.State.PendingAbility);
+ var handBefore = engine.State.Players[0].Hand.Count;
+
+ Assert.True(engine.Execute(new ResolveAbilityCommand(true)).Accepted);
+
+ Assert.Equal(2, engine.State.Players[0].Damage.Count(c => c.FaceUp));
+ Assert.Equal(handBefore + 1, engine.State.Players[0].Hand.Count);
+ Assert.Single(engine.State.Players[1].Damage);
+ }
+
[Fact]
public void ResolveTrigger_Rejects_When_Nothing_Is_Pending()
{
@@ -1010,6 +1060,7 @@ public class GameEngineTests
(BlasterBlade, 2), (Wingal, 2),
(Lien, 2),
(Cocoa, 2), (Libra, 2),
+ (Deathrex, 2), (DemonEater, 2),
};
private List BuildDeck()