diff --git a/IMPLEMENTATION_CHECKLIST.md b/IMPLEMENTATION_CHECKLIST.md index 79cf671..bea63bd 100644 --- a/IMPLEMENTATION_CHECKLIST.md +++ b/IMPLEMENTATION_CHECKLIST.md @@ -120,6 +120,8 @@ - [ ] Стоимости: CB, SB, discard, rest self, retire self, put to soul, return to deck. - [x] Выбор цели игроком (`PendingChoice` / `ChooseCommand`); «до N» / «ровно N» ещё нет. - [x] Поиск в колоде без просмотра: 0 совпадений — уведомление и перемешивание, 1 — сразу в руку, несколько — выбор; разные арты считаются разными картами. +- [x] Поиск с призывом в круг Арьергарда (Alfred) и с райдом (API, Llew). +- [x] Просмотр верхней карты с решением «оставить на верху / положить в низ» (Cocoa). - [~] Слои модификаторов силы/крита: усиления от триггеров живут до конца хода, постоянных слоёв нет. - [x] Валидация интентов: `Rejected(reason)` без изменения состояния. - [ ] Список легальных интентов на каждый момент (для UI и античита). diff --git a/server/src/Vanguard.Rules/Engine/Abilities/CardScripts.cs b/server/src/Vanguard.Rules/Engine/Abilities/CardScripts.cs index 2f09b7e..87494f2 100644 --- a/server/src/Vanguard.Rules/Engine/Abilities/CardScripts.cs +++ b/server/src/Vanguard.Rules/Engine/Abilities/CardScripts.cs @@ -16,9 +16,32 @@ public static class CardScripts ["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(), + ["battle-sister-cocoa"] = new CocoaScript(), }; } +/// +/// Battle Sister, Cocoa: при размещении на VC/RC, если Авангард Oracle Think Tank, +/// посмотреть верхнюю карту и оставить её на верху или отправить в низ. +/// +public sealed class CocoaScript : CardScript +{ + public override string NameId => "battle-sister-cocoa"; + + public override IEnumerable Abilities + { + get + { + yield return new AbilityDefinition + { + Trigger = AbilityTrigger.OnPlace, + Condition = ctx => ctx.Engine.VanguardClan(ctx.Owner) == Clan.OracleThinkTank, + Resolve = ctx => ctx.Engine.LookTopAndReorder(ctx.Owner, "Верхняя карта колоды"), + }; + } + } +} + /// /// Lizard Soldier, Conroe: [ACT](RC): Counter Blast 1 и уничтожить эту карту → /// найти в колоде Kagero 1 ранга или ниже и добавить в руку. @@ -206,4 +229,25 @@ public sealed class AlfredScript : CardScript }; } } + + public override IEnumerable Activated + { + get + { + yield return new ActivatedAbility + { + CanActivate = ctx => + ctx.Engine.CanCounterBlast(ctx.Owner, 3) && + ctx.Engine.HasEmptyRearGuard(ctx.Owner), + Resolve = ctx => + { + ctx.Engine.CounterBlast(ctx.Owner, 3); + ctx.Engine.SearchDeckToCall( + ctx.Owner, + "Найдите Royal Paladin 2 ранга или ниже", + d => d.Clan == Clan.RoyalPaladin && d.Grade <= 2); + }, + }; + } + } } diff --git a/server/src/Vanguard.Rules/Engine/GameEngine.cs b/server/src/Vanguard.Rules/Engine/GameEngine.cs index 784995b..af0ff8f 100644 --- a/server/src/Vanguard.Rules/Engine/GameEngine.cs +++ b/server/src/Vanguard.Rules/Engine/GameEngine.cs @@ -516,7 +516,122 @@ public sealed class GameEngine /// Поиск с добавлением карты в руку. public void SearchDeckToHand(int player, string prompt, Func predicate) => - SearchDeck(player, prompt, predicate, card => State.Players[player].Hand.Add(card)); + SearchDeck(player, prompt, predicate, card => + { + _events.Add(new CardRevealedEvent(player, card.PrintingId)); + State.Players[player].Hand.Add(card); + }); + + /// Поиск с призывом в выбранный круг Арьергарда. + public void SearchDeckToCall(int player, string prompt, Func predicate) + { + SearchDeck(player, prompt, predicate, card => + { + _events.Add(new CardRevealedEvent(player, card.PrintingId)); + AskEmptyRearGuardChoice(player, "Выберите круг Арьергарда", slot => CallFromDeck(card, player, slot)); + }); + } + + /// Поиск с райдом найденной карты (superior ride). + public void SearchDeckToRide(int player, string prompt, Func predicate) + { + SearchDeck(player, prompt, predicate, card => + { + _events.Add(new CardRevealedEvent(player, card.PrintingId)); + RideFromDeck(card, player); + }); + } + + /// Посмотреть верхнюю карту и оставить её на верху или отправить в низ. + public void LookTopAndReorder(int player, string prompt) + { + var deck = State.Players[player].Deck; + if (deck.Count == 0) + return; + + var top = deck[0]; + + State.PendingChoice = new PendingChoice + { + Player = player, + Prompt = $"{prompt}: {State.Catalog.CardOf(top.PrintingId).NameEn}", + Options = new List + { + new(0, "Оставить на верху"), + new(1, "Положить в низ"), + }, + Resolve = index => + { + if (index == 1) + { + deck.RemoveAt(0); + deck.Add(top); + } + + _events.Add(new CardRevealedEvent(player, top.PrintingId)); + }, + }; + State.Step = Step.Choice; + State.ActivePlayer = player; + } + + public bool HasEmptyRearGuard(int player) => + State.Players[player].RearGuards.Any(u => u is null); + + public void AskEmptyRearGuardChoice(int player, string prompt, Action onChosen) + { + var slots = Enumerable.Range(0, PlayerState.RearGuardSlots) + .Where(s => State.Players[player].RearGuards[s] is null) + .ToList(); + + if (slots.Count == 0) + return; + + State.PendingChoice = new PendingChoice + { + Player = player, + Prompt = prompt, + Options = slots.Select(s => new ChoiceOption(s, $"Круг {s + 1}")).ToList(), + Resolve = onChosen, + }; + State.Step = Step.Choice; + State.ActivePlayer = player; + } + + public void CallFromDeck(CardInstance card, int player, int slot) + { + var p = State.Players[player]; + if (p.RearGuards[slot] is not null) + return; + + card.Rested = true; + p.RearGuards[slot] = card; + _events.Add(new UnitCalledEvent(player, slot, card.PrintingId)); + + TriggerFromCard(player, card, AbilityTrigger.OnPlace, null); + } + + /// Райд картой не из руки (superior ride). + public void RideFromDeck(CardInstance card, int player) + { + var p = State.Players[player]; + var riddenOver = p.Vanguard; + + if (riddenOver is not null) + { + riddenOver.Rested = false; + p.Soul.Add(riddenOver); + } + + card.Rested = false; + p.Vanguard = card; + _events.Add(new UnitRodeEvent(player, card.PrintingId)); + + TriggerFromCard(player, card, AbilityTrigger.OnPlace, null); + + if (State.Step == Step.None) + TriggerFromCard(player, riddenOver, AbilityTrigger.OnRideOver, card); + } /// /// Останавливает разрешение и просит выбрать карту из найденных. diff --git a/server/src/Vanguard.Rules/Engine/GameEvents.cs b/server/src/Vanguard.Rules/Engine/GameEvents.cs index 1e421c2..185f2f1 100644 --- a/server/src/Vanguard.Rules/Engine/GameEvents.cs +++ b/server/src/Vanguard.Rules/Engine/GameEvents.cs @@ -27,6 +27,7 @@ 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 CardRevealedEvent(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/tests/Vanguard.Rules.Tests/GameEngineTests.cs b/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs index cba85c3..4e6be97 100644 --- a/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs +++ b/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs @@ -22,6 +22,8 @@ public class GameEngineTests private const string BlasterBlade = "BT01/002EN"; private const string Wingal = "BT01/044EN"; 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 [Fact] public void Start_Places_Starting_Vanguard_And_Draws_Five() @@ -797,6 +799,79 @@ public class GameEngineTests Assert.True(def.Grade <= 1); } + [Fact] + public void Cocoa_Can_Keep_The_Top_Card_On_Top() + { + var engine = InMainPhase(); + SetVanguard(engine, 0, Libra); + Give(engine, 0, Cocoa); + engine.Execute(new CallCommand(Cocoa, 0)); + + var choice = engine.State.PendingChoice; + Assert.NotNull(choice); + Assert.Equal(2, choice!.Options.Count); + + var topBefore = engine.State.Players[0].Deck[0].PrintingId; + Assert.True(engine.Execute(new ChooseCommand(0)).Accepted); + + Assert.Equal(topBefore, engine.State.Players[0].Deck[0].PrintingId); + } + + [Fact] + public void Cocoa_Can_Put_The_Top_Card_On_Bottom() + { + var engine = InMainPhase(); + SetVanguard(engine, 0, Libra); + Give(engine, 0, Cocoa); + engine.Execute(new CallCommand(Cocoa, 0)); + + var topBefore = engine.State.Players[0].Deck[0].PrintingId; + Assert.True(engine.Execute(new ChooseCommand(1)).Accepted); + + Assert.Equal(topBefore, engine.State.Players[0].Deck[^1].PrintingId); + } + + [Fact] + public void Alfred_Searches_And_Calls_A_RoyalPaladin() + { + var engine = StartedEngine(); + engine.Execute(new SkipRideCommand()); + SetVanguard(engine, 0, Alfred); + GiveDamage(engine, 0, 3); + + var alfred = engine.State.Players[0].Vanguard!; + Assert.True(engine.Execute(new ActivateAbilityCommand(alfred.Id)).Accepted); + + Assert.Equal(3, engine.State.Players[0].Damage.Count(c => c.FaceUp)); + + if (engine.State.PendingChoice is not null) + Assert.True(engine.Execute(new ChooseCommand(0)).Accepted); + + if (engine.State.PendingChoice is not null) + Assert.True(engine.Execute(new ChooseCommand(0)).Accepted); + + var called = engine.State.Players[0].RearGuards.FirstOrDefault(u => u is not null); + Assert.NotNull(called); + + var def = _catalog.CardOf(called!.PrintingId); + Assert.Equal(Clan.RoyalPaladin, def.Clan); + Assert.True(def.Grade <= 2); + } + + [Fact] + public void Search_Can_Ride_The_Found_Card() + { + var engine = InMainPhase(); + + engine.SearchDeckToRide(0, "Blaster Blade", d => d.NameId == "blaster-blade"); + + if (engine.State.PendingChoice is not null) + Assert.True(engine.Execute(new ChooseCommand(0)).Accepted); + + var vanguard = engine.State.Players[0].Vanguard!; + Assert.Equal("blaster-blade", _catalog.CardOf(vanguard.PrintingId).NameId); + } + [Fact] public void ResolveTrigger_Rejects_When_Nothing_Is_Pending() { @@ -934,6 +1009,7 @@ public class GameEngineTests (Iseult, 2), (Nehalem, 2), (Overlord, 2), (Conroe, 4), (BlasterBlade, 2), (Wingal, 2), (Lien, 2), + (Cocoa, 2), (Libra, 2), }; private List BuildDeck()