diff --git a/IMPLEMENTATION_CHECKLIST.md b/IMPLEMENTATION_CHECKLIST.md index 9b8ea8a..9a2da44 100644 --- a/IMPLEMENTATION_CHECKLIST.md +++ b/IMPLEMENTATION_CHECKLIST.md @@ -114,7 +114,7 @@ - [ ] Discard, Shuffle как отдельные интенты. - [x] Число проверок принадлежит атакующей карте: 0–2 ранг — одна, 3+ — две; Арьергард по умолчанию без проверок; `DriveChecksOverride` меняет число (в том числе снимает двойную). - [x] Результат боя: `Hit` / `Guarded` как событие. -- [x] Событийная шина: `Triggered` (OnRideOver, OnPlace, OnBoost, OnBoosted, OnPhaseStart, OnAttackDeclared, OnAttackHit), `Continuous` с пересчётом при запросе, `Activated` (повернуть себя + сброс карты). +- [x] Событийная шина: `Triggered` (OnRideOver, OnPlace, OnBoost, OnBoosted, OnDriveCheck, OnLeaveGuardian, OnPhaseStart, OnAttackDeclared, OnAttackHit), `Continuous` с пересчётом при запросе, `Activated` (повернуть себя + сброс карты). - [ ] Событие OnDamageTaken и триггеры на получение урона. - [x] Check Timing: сбор сработавших, порядок (активный игрок первым, выбор игрока), пауза на опциональные способности. - [x] Выбор целей: `PendingChoice` + `ChooseCommand`, используется Blaster Blade для уничтожения Арьергарда противника. @@ -151,7 +151,9 @@ - [x] RU/EN тексты для всех 94. - [x] Сила/щит для 14 карт, которых нет в BT01. - [ ] Дашборд покрытия: `scripted / tested / total`. - — сейчас заскриптовано **34** карты: 13 базовых + 21 в `CardScriptsBT01.cs`. + — сейчас заскриптовано **42** карты: 13 базовых + 29 в `CardScriptsBT01.cs`. +- [x] Возврат карты с поля в колоду: после боя (Juggernaut, Brakki) и в конце хода (Lozenge, Battleraizer). +- [x] Триггеры `OnDriveCheck` (Asura Kaiser) и `OnLeaveGuardian` (Rock the Wall, Redshoe Milly). - [ ] Wyvern Strike, Tejas: атака заднего ряда не выделена — движок разрешает атаковать любой круг Арьергарда. **Готово, когда:** 94/94 заскриптованы, 94/94 покрыты тестом, приёмка пройдена по чеклисту карты. diff --git a/server/src/Vanguard.Rules/Engine/Abilities/Abilities.cs b/server/src/Vanguard.Rules/Engine/Abilities/Abilities.cs index 54e16e0..0a2bf48 100644 --- a/server/src/Vanguard.Rules/Engine/Abilities/Abilities.cs +++ b/server/src/Vanguard.Rules/Engine/Abilities/Abilities.cs @@ -11,6 +11,8 @@ public enum AbilityTrigger OnAttackDeclared, OnAttackHit, OnBoosted, + OnDriveCheck, + OnLeaveGuardian, OnDamageTaken, } diff --git a/server/src/Vanguard.Rules/Engine/Abilities/CardScripts.cs b/server/src/Vanguard.Rules/Engine/Abilities/CardScripts.cs index 38cff3d..215d4b3 100644 --- a/server/src/Vanguard.Rules/Engine/Abilities/CardScripts.cs +++ b/server/src/Vanguard.Rules/Engine/Abilities/CardScripts.cs @@ -42,6 +42,14 @@ public static class CardScripts ["ceo-amaterasu"] = new CeoAmaterasuScript(), ["mr-invincible"] = new MrInvincibleScript(), ["brutal-jack"] = new BrutalJackScript(), + ["asura-kaiser"] = new AsuraKaiserScript(), + ["juggernaut-maximum"] = new ReturnToDeckOnAttackScript("juggernaut-maximum"), + ["highspeed-brakki"] = new ReturnToDeckOnAttackScript("highspeed-brakki"), + ["lozenge-magus"] = new ReturnToDeckOnBoostScript("lozenge-magus", Clan.OracleThinkTank), + ["battleraizer"] = new ReturnToDeckOnBoostScript("battleraizer", Clan.NovaGrappler), + ["rock-the-wall"] = new DropToSoulFromGuardianScript("rock-the-wall"), + ["redshoe-milly"] = new DropToSoulFromGuardianScript("redshoe-milly"), + ["guiding-zombie"] = new GuidingZombieScript(), }; } diff --git a/server/src/Vanguard.Rules/Engine/Abilities/CardScriptsBT01.cs b/server/src/Vanguard.Rules/Engine/Abilities/CardScriptsBT01.cs index 23af091..0b28505 100644 --- a/server/src/Vanguard.Rules/Engine/Abilities/CardScriptsBT01.cs +++ b/server/src/Vanguard.Rules/Engine/Abilities/CardScriptsBT01.cs @@ -619,6 +619,200 @@ public sealed class MrInvincibleScript : CardScript } } +/// +/// Asura Kaiser: −2000 без другого Nova Grappler; когда проверка открывает +/// Nova Grappler 3 ранга, можно поднять один свой Арьергард. +/// +public sealed class AsuraKaiserScript : CardScript +{ + public override string NameId => "asura-kaiser"; + + public override IEnumerable Continuous + { + get + { + yield return new ContinuousAbility + { + Applies = ctx => ctx.Engine.CountFieldUnits(ctx.Owner, Clan.NovaGrappler, ctx.Source) == 0, + PowerBonus = _ => -2000, + }; + } + } + + public override IEnumerable Abilities + { + get + { + yield return new AbilityDefinition + { + Trigger = AbilityTrigger.OnDriveCheck, + Condition = ctx => + ctx.Engine.IsVanguardOf(ctx.Source, ctx.Owner) && + ctx.Other is not null && + ctx.Catalog.CardOf(ctx.Other.PrintingId) is { Grade: 3, Clan: Clan.NovaGrappler }, + Resolve = ctx => + { + var slots = ctx.Engine.RearGuardSlots(ctx.Owner, _ => true); + if (slots.Count == 0) + return; + + ctx.Engine.AskUnitChoice( + ctx.Owner, + ctx.Owner, + "Выберите Арьергард, чтобы поднять", + slots, + slot => ctx.State.GetUnit(ctx.Owner, slot)!.Rested = false); + }, + }; + } + } +} + +/// +/// Juggernaut Maximum и Highspeed Brakki: на (RC) Soul Blast 1 → +5000 до конца боя, +/// в начале завершающего шага боя карта возвращается в колоду. +/// +public sealed class ReturnToDeckOnAttackScript : CardScript +{ + public ReturnToDeckOnAttackScript(string nameId) => NameId = nameId; + + public override string NameId { get; } + + public override IEnumerable Abilities + { + get + { + yield return new AbilityDefinition + { + Trigger = AbilityTrigger.OnAttackDeclared, + Optional = true, + Condition = ctx => !ctx.Engine.IsVanguardOf(ctx.Source, ctx.Owner), + CanPay = ctx => ctx.Engine.CanSoulBlast(ctx.Owner, 1), + Pay = ctx => ctx.Engine.SoulBlast(ctx.Owner, 1), + Resolve = ctx => + { + ctx.Engine.AddBattlePower(ctx.Source, 5000); + ctx.Source.ReturnToDeck = ReturnToDeckTiming.AfterBattle; + }, + }; + } + } +} + +/// +/// Lozenge Magus и Battleraizer: при смене можно призвать себя; при поддержке +/// усиленный получает +3000 до конца боя, а в начале Завершающей фазы карта +/// возвращается в колоду. +/// +public sealed class ReturnToDeckOnBoostScript : CardScript +{ + private readonly Clan _clan; + + public ReturnToDeckOnBoostScript(string nameId, Clan clan) + { + NameId = nameId; + _clan = clan; + } + + public override string NameId { get; } + + public override IEnumerable Abilities + { + get + { + yield return new AbilityDefinition + { + Trigger = AbilityTrigger.OnRideOver, + Optional = true, + Condition = ctx => + ctx.Other is not null && + ctx.Catalog.CardOf(ctx.Other.PrintingId).Clan == _clan, + Resolve = ctx => ctx.Engine.CallFromSoul(ctx.Source, ctx.Owner), + }; + + yield return new AbilityDefinition + { + Trigger = AbilityTrigger.OnBoost, + Condition = ctx => ctx.Other is not null, + Resolve = ctx => + { + ctx.Engine.AddBattlePower(ctx.Other!, 3000, ctx.Source); + ctx.Source.ReturnToDeck = ReturnToDeckTiming.EndOfTurn; + }, + }; + } + } +} + +/// +/// Rock the Wall и Redshoe Milly: попав в сброс из круга защиты, карта уходит в душу. +/// +public sealed class DropToSoulFromGuardianScript : CardScript +{ + public DropToSoulFromGuardianScript(string nameId) => NameId = nameId; + + public override string NameId { get; } + + public override IEnumerable Abilities + { + get + { + yield return new AbilityDefinition + { + Trigger = AbilityTrigger.OnLeaveGuardian, + Resolve = ctx => ctx.Engine.MoveDropToSoul(ctx.Owner, ctx.Source), + }; + } + } +} + +/// +/// Guiding Zombie: при смене можно призвать себя; на (RC) положить себя в душу +/// и отправить три верхние карты колоды в сброс. +/// +public sealed class GuidingZombieScript : CardScript +{ + public override string NameId => "guiding-zombie"; + + public override IEnumerable Abilities + { + get + { + yield return new AbilityDefinition + { + Trigger = AbilityTrigger.OnRideOver, + Optional = true, + Condition = ctx => + ctx.Other is not null && + ctx.Catalog.CardOf(ctx.Other.PrintingId).Clan == Clan.Granblue, + Resolve = ctx => ctx.Engine.CallFromSoul(ctx.Source, ctx.Owner), + }; + } + } + + public override IEnumerable Activated + { + get + { + yield return new ActivatedAbility + { + CanActivate = _ => true, + Resolve = ctx => + { + var p = ctx.State.Players[ctx.Owner]; + var slot = Array.IndexOf(p.RearGuards, ctx.Source); + if (slot < 0) + return; + + p.RearGuards[slot] = null; + p.Soul.Add(ctx.Source); + ctx.Engine.MillTop(ctx.Owner, 3); + }, + }; + } + } +} + /// /// Brutal Jack: Сдерживание; за Counter Blast 1 теряет его до конца хода; /// получает +5000 до конца боя, когда его поддерживает Nova Grappler. diff --git a/server/src/Vanguard.Rules/Engine/GameEngine.cs b/server/src/Vanguard.Rules/Engine/GameEngine.cs index 9c2544d..706be5c 100644 --- a/server/src/Vanguard.Rules/Engine/GameEngine.cs +++ b/server/src/Vanguard.Rules/Engine/GameEngine.cs @@ -815,6 +815,46 @@ public sealed class GameEngine }); } + /// Положить верхние карты колоды в сброс. + public void MillTop(int player, int count) + { + var p = State.Players[player]; + for (var i = 0; i < count && p.Deck.Count > 0; i++) + { + var card = p.Deck[0]; + p.Deck.RemoveAt(0); + p.Drop.Add(card); + } + } + + /// Переместить карту из сброса в душу (Rock the Wall, Redshoe Milly). + public bool MoveDropToSoul(int player, CardInstance card) + { + var p = State.Players[player]; + if (!p.Drop.Remove(card)) + return false; + + p.Soul.Add(card); + _events.Add(new CardMovedToSoulEvent(player, card.PrintingId)); + return true; + } + + /// Вернуть карту с поля в колоду и перемешать. Авангард так вернуть нельзя. + public bool ReturnUnitToDeck(int player, CardInstance unit) + { + var p = State.Players[player]; + var slot = Array.IndexOf(p.RearGuards, unit); + if (slot < 0) + return false; + + p.RearGuards[slot] = null; + unit.ReturnToDeck = null; + p.Deck.Add(unit); + ShuffleDeck(player); + _events.Add(new UnitReturnedToDeckEvent(player, unit.PrintingId)); + return true; + } + // ---------- поиск в колоде ---------- /// @@ -1521,6 +1561,16 @@ public sealed class GameEngine State.Phase = Phase.End; _events.Add(new PhaseChangedEvent(State.ActivePlayer, Phase.End)); + // «в начале Завершающей фазы вернуть в колоду» + foreach (var player in new[] { 0, 1 }) + { + foreach (var unit in AllUnits(player).ToList()) + { + if (unit.ReturnToDeck == ReturnToDeckTiming.EndOfTurn) + ReturnUnitToDeck(player, unit); + } + } + ClearTurnModifiers(); if (State.IsFinished) @@ -1638,6 +1688,16 @@ public sealed class GameEngine State.Players[player].Hand.Add(card); _events.Add(new CardDrawnEvent(player)); battle.ChecksRemaining--; + + var checkedAttacker = State.GetUnit(player, battle.AttackerSlot); + TriggerFromCard(player, checkedAttacker, AbilityTrigger.OnDriveCheck, card); + + if (State.Step is Step.Ability or Step.Choice) + { + State.Continuation = () => ContinueDriveChecks(battle); + return; + } + continue; } @@ -1833,11 +1893,25 @@ public sealed class GameEngine private void ClearBattle(BattleState battle) { - var defender = State.Players[1 - battle.AttackerPlayer]; + var defenderIndex = 1 - battle.AttackerPlayer; + var defender = State.Players[defenderIndex]; + + var leftGuardian = new List(); foreach (var guard in battle.GuardCards) { defender.Guardian.Remove(guard); defender.Drop.Add(guard); + leftGuardian.Add(guard); + } + + // карты, которые должны вернуться в колоду сразу после боя + foreach (var player in new[] { 0, 1 }) + { + foreach (var unit in AllUnits(player).ToList()) + { + if (unit.ReturnToDeck == ReturnToDeckTiming.AfterBattle) + ReturnUnitToDeck(player, unit); + } } foreach (var unit in AllUnits(0).Concat(AllUnits(1))) @@ -1851,6 +1925,10 @@ public sealed class GameEngine State.PendingTrigger = null; State.Step = Step.None; State.ActivePlayer = battle.AttackerPlayer; + + // бой уже закрыт: эффекты «попала в сброс из (GC)» срабатывают после + foreach (var guard in leftGuardian) + TriggerFromCard(defenderIndex, guard, AbilityTrigger.OnLeaveGuardian, null); } private void DealDamage(int player, int amount) diff --git a/server/src/Vanguard.Rules/Engine/GameEvents.cs b/server/src/Vanguard.Rules/Engine/GameEvents.cs index dc6f41b..6a38ba9 100644 --- a/server/src/Vanguard.Rules/Engine/GameEvents.cs +++ b/server/src/Vanguard.Rules/Engine/GameEvents.cs @@ -30,6 +30,8 @@ public sealed record SearchFailedEvent(int Player, string Query) : GameEvent; public sealed record CardRevealedEvent(int Player, string PrintingId) : GameEvent; public sealed record CardReturnedToDeckEvent(int Player, string PrintingId) : GameEvent; public sealed record CardDiscardedEvent(int Player, string PrintingId) : GameEvent; +public sealed record UnitReturnedToDeckEvent(int Player, string PrintingId) : GameEvent; +public sealed record CardMovedToSoulEvent(int Player, string PrintingId) : GameEvent; public sealed record GameFinishedEvent(int Winner) : GameEvent; public sealed record AbilityTriggeredEvent(int Player, string PrintingId, AbilityTrigger Trigger) : GameEvent; public sealed record AbilityResolvedEvent(int Player, string PrintingId, bool Accepted) : GameEvent; diff --git a/server/src/Vanguard.Rules/Engine/GameModels.cs b/server/src/Vanguard.Rules/Engine/GameModels.cs index 789889f..5f028da 100644 --- a/server/src/Vanguard.Rules/Engine/GameModels.cs +++ b/server/src/Vanguard.Rules/Engine/GameModels.cs @@ -47,6 +47,13 @@ public enum TriggerContext DamageCheck, } +/// Когда карта, находившаяся на поле, возвращается в колоду. +public enum ReturnToDeckTiming +{ + AfterBattle, + EndOfTurn, +} + /// Триггер, который ждёт выбора целей от владельца. public sealed class PendingTrigger { @@ -84,6 +91,9 @@ public sealed class CardInstance /// Запреты, выданные эффектами. public List Restrictions { get; } = new(); + /// Вернуть карту в колоду после боя или в конце хода (Juggernaut, Lozenge). + public ReturnToDeckTiming? ReturnToDeck { get; set; } + /// /// Переопределение Сдерживания, выданное эффектом. /// null — как напечатано на карте. diff --git a/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs b/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs index 096e849..468b1a9 100644 --- a/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs +++ b/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs @@ -32,6 +32,10 @@ public class GameEngineTests private const string HellSpider = "BT01/039EN"; // Megacolony, запрет вставать private const string BrutalJack = "BT01/029EN"; // Nova Grappler, Сдерживание private const string ToughBoy = "BT01/060EN"; // Nova Grappler, grade 1 + private const string Asura = "BT01/008EN"; // Nova Grappler, grade 3 + private const string Juggernaut = "BT01/020EN"; // Spike Brothers, возврат в колоду + private const string RockTheWall = "BT01/073EN"; + private const string GuidingZombie = "BT01/078EN"; [Fact] public void Start_Places_Starting_Vanguard_And_Draws_Five() @@ -1270,6 +1274,91 @@ public class GameEngineTests Assert.Empty(overlord.GrantedAbilities); } + [Fact] + public void Asura_Kaiser_Stands_RearGuard_On_Grade3_Drive_Check() + { + var engine = StartedEngine(); + engine.Execute(new SkipRideCommand()); + + var asura = MakeCard(Asura); + engine.State.Players[0].Vanguard = asura; + + var rear = MakeCard(ToughBoy); + engine.State.Players[0].RearGuards[0] = rear; + rear.Rested = true; + + // проверка открывает Mr. Invincible (Nova Grappler 3 ранга), затем обычную карту + engine.State.Players[0].Deck.Insert(0, MakeCard("BT01/028EN")); + engine.State.Players[0].Deck.Insert(1, MakeCard(Gallatin)); + engine.State.Players[1].Deck.Insert(0, MakeCard(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.PendingChoice); + Assert.True(engine.Execute(new ChooseCommand(0)).Accepted); + + Assert.False(rear.Rested); + } + + [Fact] + public void Juggernaut_Returns_To_Deck_After_Battle() + { + var engine = InMainPhase(); + var juggernaut = MakeCard(Juggernaut); + engine.State.Players[0].RearGuards[3] = juggernaut; + juggernaut.Rested = false; + engine.SoulCharge(0, 1); + + StackDeck(engine.State.Players[1], Gallatin); + + engine.Execute(new EnterBattleCommand()); + Assert.True(engine.Execute(new AttackCommand(3, -1)).Accepted); + Assert.NotNull(engine.State.PendingAbility); + Assert.True(engine.Execute(new ResolveAbilityCommand(true)).Accepted); + Assert.True(engine.Execute(new PassGuardCommand()).Accepted); + + Assert.Null(engine.State.Players[0].RearGuards[3]); + Assert.Contains(engine.State.Players[0].Deck, c => c.PrintingId == Juggernaut); + } + + [Fact] + public void Rock_The_Wall_Moves_To_Soul_After_Leaving_Guardian() + { + var engine = ReadyToAttack(); + StackDeck(engine.State.Players[0], Gallatin, Gallatin); + + engine.State.Players[1].Hand.Add(MakeCard(RockTheWall)); + + Assert.True(engine.Execute(new AttackCommand(-1, -1)).Accepted); + Assert.True(engine.Execute(new GuardCommand(RockTheWall)).Accepted); + Assert.True(engine.Execute(new PassGuardCommand()).Accepted); + + var defender = engine.State.Players[1]; + Assert.Contains(defender.Soul, c => c.PrintingId == RockTheWall); + Assert.DoesNotContain(defender.Drop, c => c.PrintingId == RockTheWall); + } + + [Fact] + public void Guiding_Zombie_Puts_Itself_Into_Soul_And_Mills_Three() + { + var engine = InMainPhase(); + var zombie = MakeCard(GuidingZombie); + engine.State.Players[0].Hand.Add(zombie); + + Assert.True(engine.Execute(new CallCommand(GuidingZombie, 0)).Accepted); + zombie.Rested = false; + + var deckBefore = engine.State.Players[0].Deck.Count; + + Assert.True(engine.Execute(new ActivateAbilityCommand(zombie.Id)).Accepted); + + Assert.Null(engine.State.Players[0].RearGuards[0]); + Assert.Contains(engine.State.Players[0].Soul, c => c.PrintingId == GuidingZombie); + Assert.Equal(3, deckBefore - engine.State.Players[0].Deck.Count); + } + [Fact] public void ResolveTrigger_Rejects_When_Nothing_Is_Pending() {