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:
parent
a1e7c250bf
commit
fa507576d9
|
|
@ -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 и античита).
|
||||
|
|
|
|||
|
|
@ -61,6 +61,10 @@ public sealed class ActivatedAbility
|
|||
public required Func<AbilityContext, bool> CanActivate { get; init; }
|
||||
public bool RestsSelf { get; init; }
|
||||
public bool RequiresDiscard { get; init; }
|
||||
|
||||
/// <summary>Стоимость: уничтожить саму карту после оплаты.</summary>
|
||||
public bool RetiresSelf { get; init; }
|
||||
|
||||
public required Action<AbilityContext> Resolve { get; init; }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// «[ACT]: повернуть эту карту и сбросить карту из руки → взять карту».
|
||||
/// Общий скрипт для Lake Maiden, Lien, Dragon Monk, Gojo и Shout.
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
||||
// ---------- поиск в колоде ----------
|
||||
|
||||
/// <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)
|
||||
{
|
||||
var vanguard = State.Players[player].Vanguard;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
Loading…
Reference in New Issue