diff --git a/IMPLEMENTATION_CHECKLIST.md b/IMPLEMENTATION_CHECKLIST.md index 8bf8778..79cf671 100644 --- a/IMPLEMENTATION_CHECKLIST.md +++ b/IMPLEMENTATION_CHECKLIST.md @@ -118,7 +118,8 @@ - [x] Check Timing: сбор сработавших, порядок (активный игрок первым, выбор игрока), пауза на опциональные способности. - [x] Выбор целей: `PendingChoice` + `ChooseCommand`, используется Blaster Blade для уничтожения Арьергарда противника. - [ ] Стоимости: CB, SB, discard, rest self, retire self, put to soul, return to deck. -- [ ] Цели: авто по фильтру и выбор игроком, «до N» / «ровно N». +- [x] Выбор цели игроком (`PendingChoice` / `ChooseCommand`); «до N» / «ровно N» ещё нет. +- [x] Поиск в колоде без просмотра: 0 совпадений — уведомление и перемешивание, 1 — сразу в руку, несколько — выбор; разные арты считаются разными картами. - [~] Слои модификаторов силы/крита: усиления от триггеров живут до конца хода, постоянных слоёв нет. - [x] Валидация интентов: `Rejected(reason)` без изменения состояния. - [ ] Список легальных интентов на каждый момент (для UI и античита). diff --git a/server/src/Vanguard.Rules/Engine/Abilities/Abilities.cs b/server/src/Vanguard.Rules/Engine/Abilities/Abilities.cs index 05ee3d7..eb1b832 100644 --- a/server/src/Vanguard.Rules/Engine/Abilities/Abilities.cs +++ b/server/src/Vanguard.Rules/Engine/Abilities/Abilities.cs @@ -61,6 +61,10 @@ public sealed class ActivatedAbility public required Func CanActivate { get; init; } public bool RestsSelf { get; init; } public bool RequiresDiscard { get; init; } + + /// Стоимость: уничтожить саму карту после оплаты. + public bool RetiresSelf { get; init; } + public required Action Resolve { get; init; } } diff --git a/server/src/Vanguard.Rules/Engine/Abilities/CardScripts.cs b/server/src/Vanguard.Rules/Engine/Abilities/CardScripts.cs index df0106a..2f09b7e 100644 --- a/server/src/Vanguard.Rules/Engine/Abilities/CardScripts.cs +++ b/server/src/Vanguard.Rules/Engine/Abilities/CardScripts.cs @@ -15,9 +15,39 @@ public static class CardScripts ["lake-maiden-lien"] = new DrawOneScript("lake-maiden-lien"), ["dragon-monk-gojo"] = new DrawOneScript("dragon-monk-gojo"), ["screamin-and-dancin-announcer-shout"] = new DrawOneScript("screamin-and-dancin-announcer-shout"), + ["lizard-soldier-conroe"] = new ConroeScript(), }; } +/// +/// Lizard Soldier, Conroe: [ACT](RC): Counter Blast 1 и уничтожить эту карту → +/// найти в колоде Kagero 1 ранга или ниже и добавить в руку. +/// +public sealed class ConroeScript : CardScript +{ + public override string NameId => "lizard-soldier-conroe"; + + public override IEnumerable Activated + { + get + { + yield return new ActivatedAbility + { + CanActivate = ctx => ctx.Engine.CanCounterBlast(ctx.Owner, 1), + RetiresSelf = true, + Resolve = ctx => + { + ctx.Engine.CounterBlast(ctx.Owner, 1); + ctx.Engine.SearchDeckToHand( + ctx.Owner, + "Найдите Kagero 1 ранга или ниже", + d => d.Clan == Clan.Kagero && d.Grade <= 1); + }, + }; + } + } +} + /// /// «[ACT]: повернуть эту карту и сбросить карту из руки → взять карту». /// Общий скрипт для Lake Maiden, Lien, Dragon Monk, Gojo и Shout. diff --git a/server/src/Vanguard.Rules/Engine/GameEngine.cs b/server/src/Vanguard.Rules/Engine/GameEngine.cs index e31d309..784995b 100644 --- a/server/src/Vanguard.Rules/Engine/GameEngine.cs +++ b/server/src/Vanguard.Rules/Engine/GameEngine.cs @@ -226,6 +226,17 @@ public sealed class GameEngine p.Drop.Add(discard); } + if (ability.RetiresSelf) + { + var slot = Array.IndexOf(p.RearGuards, unit); + if (slot < 0) + return CommandResult.Reject("Уничтожить себя можно только из круга Арьергарда."); + + p.RearGuards[slot] = null; + p.Drop.Add(unit); + _events.Add(new UnitRetiredEvent(player, slot)); + } + ability.Resolve(context); _events.Add(new AbilityResolvedEvent(player, unit.PrintingId, true)); @@ -461,6 +472,79 @@ public sealed class GameEngine _events.Add(new UnitRetiredEvent(player, slot)); } + // ---------- поиск в колоде ---------- + + /// + /// Серверный поиск: игрок не видит колоду. Найдена одна карта — сразу передаётся + /// в onFound; несколько — предлагается выбор (разные арты считаются разными картами); + /// ни одной — уведомление. После поиска колода всегда перемешивается. + /// + public void SearchDeck( + int player, + string prompt, + Func predicate, + Action onFound) + { + var deck = State.Players[player].Deck; + var matches = deck + .Where(c => predicate(State.Catalog.CardOf(c.PrintingId))) + .ToList(); + + if (matches.Count == 0) + { + _events.Add(new SearchFailedEvent(player, prompt)); + ShuffleDeck(player); + return; + } + + if (matches.Count == 1) + { + CompleteSearch(player, matches[0], onFound); + return; + } + + AskCardChoice(player, prompt, matches, chosen => CompleteSearch(player, chosen, onFound)); + } + + private void CompleteSearch(int player, CardInstance card, Action onFound) + { + State.Players[player].Deck.Remove(card); + onFound(card); + _events.Add(new CardSearchedEvent(player, card.PrintingId)); + ShuffleDeck(player); + } + + /// Поиск с добавлением карты в руку. + public void SearchDeckToHand(int player, string prompt, Func predicate) => + SearchDeck(player, prompt, predicate, card => State.Players[player].Hand.Add(card)); + + /// + /// Останавливает разрешение и просит выбрать карту из найденных. + /// Варианты различаются номером принта, поэтому два Blaster Blade с разными артами — два варианта. + /// + public void AskCardChoice( + int player, + string prompt, + IReadOnlyList cards, + Action onChosen) + { + var options = cards + .Select((card, index) => new ChoiceOption( + index, + $"{State.Catalog.CardOf(card.PrintingId).NameEn} ({card.PrintingId})")) + .ToList(); + + State.PendingChoice = new PendingChoice + { + Player = player, + Prompt = prompt, + Options = options, + Resolve = index => onChosen(cards[index]), + }; + State.Step = Step.Choice; + State.ActivePlayer = player; + } + public Clan VanguardClan(int player) { var vanguard = State.Players[player].Vanguard; diff --git a/server/src/Vanguard.Rules/Engine/GameEvents.cs b/server/src/Vanguard.Rules/Engine/GameEvents.cs index 059f39e..1e421c2 100644 --- a/server/src/Vanguard.Rules/Engine/GameEvents.cs +++ b/server/src/Vanguard.Rules/Engine/GameEvents.cs @@ -25,6 +25,8 @@ 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; public sealed record UnitRetiredEvent(int Player, int Slot) : GameEvent; +public sealed record CardSearchedEvent(int Player, string PrintingId) : GameEvent; +public sealed record SearchFailedEvent(int Player, string Query) : 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/tests/Vanguard.Rules.Tests/GameEngineTests.cs b/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs index dc73f9d..cba85c3 100644 --- a/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs +++ b/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs @@ -726,6 +726,77 @@ public class GameEngineTests Assert.False(engine.Execute(new ActivateAbilityCommand(lien.Id, Marron)).Accepted); } + [Fact] + public void Search_Without_Matches_Only_Shuffles() + { + var engine = StartedEngine(); + var handBefore = engine.State.Players[0].Hand.Count; + + engine.SearchDeckToHand(0, "Карта X", d => d.NameId == "no-such-card"); + + Assert.Contains(engine.Events, e => e is SearchFailedEvent); + Assert.Null(engine.State.PendingChoice); + Assert.Equal(handBefore, engine.State.Players[0].Hand.Count); + } + + [Fact] + public void Search_With_Single_Match_Puts_Card_Into_Hand() + { + var engine = StartedEngine(); + KeepOnly(engine, 0, "wingal", 1); + + engine.SearchDeckToHand(0, "Wingal", d => d.NameId == "wingal"); + + Assert.Null(engine.State.PendingChoice); + Assert.Contains(engine.State.Players[0].Hand, c => c.PrintingId == Wingal); + Assert.DoesNotContain(engine.State.Players[0].Deck, c => c.PrintingId == Wingal); + } + + [Fact] + public void Search_With_Two_Arts_Offers_Two_Options() + { + var engine = StartedEngine(); + KeepOnly(engine, 0, "blaster-blade", 1); + + var parallel = new CardInstance { Id = 9001, PrintingId = "BT01/S02EN" }; + engine.State.Players[0].Deck.Add(parallel); + + engine.SearchDeckToHand(0, "Blaster Blade", d => d.NameId == "blaster-blade"); + + var choice = engine.State.PendingChoice; + Assert.NotNull(choice); + Assert.Equal(2, choice!.Options.Count); + + Assert.True(engine.Execute(new ChooseCommand(1)).Accepted); + Assert.Contains(engine.State.Players[0].Hand, c => c.PrintingId == "BT01/S02EN"); + } + + [Fact] + public void Conroe_Retires_Itself_Pays_Counter_Blast_And_Searches() + { + var engine = InMainPhase(); + Give(engine, 0, Conroe); + engine.Execute(new CallCommand(Conroe, 0)); + + var conroe = engine.State.Players[0].RearGuards[0]!; + conroe.Rested = false; + GiveDamage(engine, 0, 1); + + Assert.True(engine.Execute(new ActivateAbilityCommand(conroe.Id)).Accepted); + + Assert.Null(engine.State.Players[0].RearGuards[0]); + Assert.Contains(engine.State.Players[0].Drop, c => c.PrintingId == Conroe); + Assert.Equal(1, engine.State.Players[0].Damage.Count(c => c.FaceUp)); + + if (engine.State.PendingChoice is not null) + Assert.True(engine.Execute(new ChooseCommand(0)).Accepted); + + var added = engine.State.Players[0].Hand[^1]; + var def = _catalog.CardOf(added.PrintingId); + Assert.Equal(Clan.Kagero, def.Clan); + Assert.True(def.Grade <= 1); + } + [Fact] public void ResolveTrigger_Rejects_When_Nothing_Is_Pending() { @@ -860,7 +931,7 @@ public class GameEngineTests { (Barcgal, 4), (Marron, 4), (Gallatin, 4), (Alfred, 4), (Nike, 2), (Flogal, 2), (Govannon, 2), - (Iseult, 2), (Nehalem, 2), (Overlord, 2), (Conroe, 2), + (Iseult, 2), (Nehalem, 2), (Overlord, 2), (Conroe, 4), (BlasterBlade, 2), (Wingal, 2), (Lien, 2), }; @@ -925,6 +996,17 @@ public class GameEngineTests player.Deck.Insert(0, cards[i]); } + private static void KeepOnly(GameEngine engine, int player, string nameId, int keep) + { + var p = engine.State.Players[player]; + var matches = p.Deck + .Where(c => engine.State.Catalog.CardOf(c.PrintingId).NameId == nameId) + .ToList(); + + foreach (var card in matches.Skip(keep)) + p.Deck.Remove(card); + } + private static void GiveDamage(GameEngine engine, int player, int amount) { var p = engine.State.Players[player];