Server-side deck search without exposing the deck

SearchDeck hides the deck and returns matches only: none -> SearchFailedEvent and shuffle, one -> straight to the effect, several -> PendingChoice. Different arts are separate results, labelled with the printing id. Wired into Conroe, which pays Counter Blast 1 and retires itself. Four tests.
This commit is contained in:
MpakobecEx 2026-10-21 13:18:57 +05:00
parent a1e7c250bf
commit fa507576d9
6 changed files with 205 additions and 2 deletions

View File

@ -118,7 +118,8 @@
- [x] Check Timing: сбор сработавших, порядок (активный игрок первым, выбор игрока), пауза на опциональные способности. - [x] Check Timing: сбор сработавших, порядок (активный игрок первым, выбор игрока), пауза на опциональные способности.
- [x] Выбор целей: `PendingChoice` + `ChooseCommand`, используется Blaster Blade для уничтожения Арьергарда противника. - [x] Выбор целей: `PendingChoice` + `ChooseCommand`, используется Blaster Blade для уничтожения Арьергарда противника.
- [ ] Стоимости: CB, SB, discard, rest self, retire self, put to soul, return to deck. - [ ] Стоимости: 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)` без изменения состояния. - [x] Валидация интентов: `Rejected(reason)` без изменения состояния.
- [ ] Список легальных интентов на каждый момент (для UI и античита). - [ ] Список легальных интентов на каждый момент (для UI и античита).

View File

@ -61,6 +61,10 @@ public sealed class ActivatedAbility
public required Func<AbilityContext, bool> CanActivate { get; init; } public required Func<AbilityContext, bool> CanActivate { get; init; }
public bool RestsSelf { get; init; } public bool RestsSelf { get; init; }
public bool RequiresDiscard { get; init; } public bool RequiresDiscard { get; init; }
/// <summary>Стоимость: уничтожить саму карту после оплаты.</summary>
public bool RetiresSelf { get; init; }
public required Action<AbilityContext> Resolve { get; init; } public required Action<AbilityContext> Resolve { get; init; }
} }

View File

@ -15,9 +15,39 @@ public static class CardScripts
["lake-maiden-lien"] = new DrawOneScript("lake-maiden-lien"), ["lake-maiden-lien"] = new DrawOneScript("lake-maiden-lien"),
["dragon-monk-gojo"] = new DrawOneScript("dragon-monk-gojo"), ["dragon-monk-gojo"] = new DrawOneScript("dragon-monk-gojo"),
["screamin-and-dancin-announcer-shout"] = new DrawOneScript("screamin-and-dancin-announcer-shout"), ["screamin-and-dancin-announcer-shout"] = new DrawOneScript("screamin-and-dancin-announcer-shout"),
["lizard-soldier-conroe"] = new ConroeScript(),
}; };
} }
/// <summary>
/// Lizard Soldier, Conroe: [ACT](RC): Counter Blast 1 и уничтожить эту карту →
/// найти в колоде Kagero 1 ранга или ниже и добавить в руку.
/// </summary>
public sealed class ConroeScript : CardScript
{
public override string NameId => "lizard-soldier-conroe";
public override IEnumerable<ActivatedAbility> 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);
},
};
}
}
}
/// <summary> /// <summary>
/// «[ACT]: повернуть эту карту и сбросить карту из руки → взять карту». /// «[ACT]: повернуть эту карту и сбросить карту из руки → взять карту».
/// Общий скрипт для Lake Maiden, Lien, Dragon Monk, Gojo и Shout. /// Общий скрипт для Lake Maiden, Lien, Dragon Monk, Gojo и Shout.

View File

@ -226,6 +226,17 @@ public sealed class GameEngine
p.Drop.Add(discard); 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); ability.Resolve(context);
_events.Add(new AbilityResolvedEvent(player, unit.PrintingId, true)); _events.Add(new AbilityResolvedEvent(player, unit.PrintingId, true));
@ -461,6 +472,79 @@ public sealed class GameEngine
_events.Add(new UnitRetiredEvent(player, slot)); _events.Add(new UnitRetiredEvent(player, slot));
} }
// ---------- поиск в колоде ----------
/// <summary>
/// Серверный поиск: игрок не видит колоду. Найдена одна карта — сразу передаётся
/// в onFound; несколько — предлагается выбор (разные арты считаются разными картами);
/// ни одной — уведомление. После поиска колода всегда перемешивается.
/// </summary>
public void SearchDeck(
int player,
string prompt,
Func<CardDefinition, bool> predicate,
Action<CardInstance> 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<CardInstance> onFound)
{
State.Players[player].Deck.Remove(card);
onFound(card);
_events.Add(new CardSearchedEvent(player, card.PrintingId));
ShuffleDeck(player);
}
/// <summary>Поиск с добавлением карты в руку.</summary>
public void SearchDeckToHand(int player, string prompt, Func<CardDefinition, bool> predicate) =>
SearchDeck(player, prompt, predicate, card => State.Players[player].Hand.Add(card));
/// <summary>
/// Останавливает разрешение и просит выбрать карту из найденных.
/// Варианты различаются номером принта, поэтому два Blaster Blade с разными артами — два варианта.
/// </summary>
public void AskCardChoice(
int player,
string prompt,
IReadOnlyList<CardInstance> cards,
Action<CardInstance> 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) public Clan VanguardClan(int player)
{ {
var vanguard = State.Players[player].Vanguard; var vanguard = State.Players[player].Vanguard;

View File

@ -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 DamageTakenEvent(int Player, int Amount, int Total) : GameEvent;
public sealed record HealedEvent(int Player) : GameEvent; public sealed record HealedEvent(int Player) : GameEvent;
public sealed record UnitRetiredEvent(int Player, int Slot) : 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 GameFinishedEvent(int Winner) : GameEvent;
public sealed record AbilityTriggeredEvent(int Player, string PrintingId, AbilityTrigger Trigger) : GameEvent; public sealed record AbilityTriggeredEvent(int Player, string PrintingId, AbilityTrigger Trigger) : GameEvent;
public sealed record AbilityResolvedEvent(int Player, string PrintingId, bool Accepted) : GameEvent; public sealed record AbilityResolvedEvent(int Player, string PrintingId, bool Accepted) : GameEvent;

View File

@ -726,6 +726,77 @@ public class GameEngineTests
Assert.False(engine.Execute(new ActivateAbilityCommand(lien.Id, Marron)).Accepted); 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] [Fact]
public void ResolveTrigger_Rejects_When_Nothing_Is_Pending() public void ResolveTrigger_Rejects_When_Nothing_Is_Pending()
{ {
@ -860,7 +931,7 @@ public class GameEngineTests
{ {
(Barcgal, 4), (Marron, 4), (Gallatin, 4), (Alfred, 4), (Barcgal, 4), (Marron, 4), (Gallatin, 4), (Alfred, 4),
(Nike, 2), (Flogal, 2), (Govannon, 2), (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), (BlasterBlade, 2), (Wingal, 2),
(Lien, 2), (Lien, 2),
}; };
@ -925,6 +996,17 @@ public class GameEngineTests
player.Deck.Insert(0, cards[i]); 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) private static void GiveDamage(GameEngine engine, int player, int amount)
{ {
var p = engine.State.Players[player]; var p = engine.State.Players[player];