From 4333bb5bd09ec916d26d1b4922c43b00754b738f Mon Sep 17 00:00:00 2001 From: MpakobecEx Date: Wed, 21 Oct 2026 13:23:36 +0500 Subject: [PATCH] Wire phase-start, attack-declared and attack-hit triggers Added a command guard while an ability resolves, a continuation mechanism for mid-battle pauses, and TriggerAll for scanning the field. Cards: Demon Eater (soul charge at main phase start), Tyrant Deathrex (+5000 when attacking), Maiden of Libra (Counter Blast 2 and draw on hit). Three tests. --- IMPLEMENTATION_CHECKLIST.md | 3 +- .../Engine/Abilities/CardScripts.cs | 64 ++++++++ .../src/Vanguard.Rules/Engine/GameEngine.cs | 147 ++++++++++++++++-- server/src/Vanguard.Rules/Engine/GameState.cs | 3 + .../Vanguard.Rules.Tests/GameEngineTests.cs | 51 ++++++ 5 files changed, 255 insertions(+), 13 deletions(-) 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()