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()