Card choice from hand as an effect cost

AskHandCardChoice pauses on the hand and returns the chosen card, so abilities can pay discard costs interactively. Knight of Rose, Morgana implemented: on attack, optionally discard a card for +4000 until end of turn. Test covers the full pause chain and the return to the guard step.
This commit is contained in:
MpakobecEx 2026-10-21 13:29:16 +05:00
parent 6538a470da
commit 6f5bd4e287
4 changed files with 87 additions and 0 deletions

View File

@ -120,6 +120,7 @@
- [x] Выбор целей: `PendingChoice` + `ChooseCommand`, используется Blaster Blade для уничтожения Арьергарда противника.
- [ ] Стоимости: CB, SB, discard, rest self, retire self, put to soul, return to deck.
- [x] Выбор цели игроком (`PendingChoice` / `ChooseCommand`); «до N» / «ровно N» ещё нет.
- [x] Выбор карты из руки как стоимости эффекта (Morgana: сброс → +4000).
- [x] Поиск в колоде без просмотра: 0 совпадений — уведомление и перемешивание, 1 — сразу в руку, несколько — выбор; разные арты считаются разными картами.
- [x] Поиск с призывом в круг Арьергарда (Alfred) и с райдом (API, Llew).
- [x] Просмотр верхней карты с решением «оставить на верху / положить в низ» (Cocoa).

View File

@ -20,9 +20,42 @@ public static class CardScripts
["tyrant-deathrex"] = new DeathrexScript(),
["demon-eater"] = new DemonEaterScript(),
["maiden-of-libra"] = new MaidenOfLibraScript(),
["knight-of-rose-morgana"] = new MorganaScript(),
};
}
/// <summary>
/// Knight of Rose, Morgana: при атаке можно сбросить карту из руки и получить +4000 до конца хода.
/// </summary>
public sealed class MorganaScript : CardScript
{
public override string NameId => "knight-of-rose-morgana";
public override IEnumerable<AbilityDefinition> Abilities
{
get
{
yield return new AbilityDefinition
{
Trigger = AbilityTrigger.OnAttackDeclared,
Optional = true,
CanPay = ctx => ctx.State.Players[ctx.Owner].Hand.Count > 0,
Resolve = ctx => ctx.Engine.AskHandCardChoice(
ctx.Owner,
"Сбросьте карту из руки",
null,
card =>
{
var p = ctx.State.Players[ctx.Owner];
p.Hand.Remove(card);
p.Drop.Add(card);
ctx.Source.TemporaryPower += 4000;
}),
};
}
}
}
/// <summary>Tyrant, Deathrex: когда эта карта атакует, получает +5000 до конца боя.</summary>
public sealed class DeathrexScript : CardScript
{

View File

@ -692,6 +692,33 @@ public sealed class GameEngine
public bool HasEmptyRearGuard(int player) =>
State.Players[player].RearGuards.Any(u => u is null);
/// <summary>Выбор карты из руки (стоимость, сброс, цель эффекта).</summary>
public void AskHandCardChoice(
int player,
string prompt,
Func<CardInstance, bool>? predicate,
Action<CardInstance> onChosen)
{
var cards = State.Players[player].Hand
.Where(c => predicate is null || predicate(c))
.ToList();
if (cards.Count == 0)
return;
State.PendingChoice = new PendingChoice
{
Player = player,
Prompt = prompt,
Options = cards
.Select((card, index) => new ChoiceOption(index, State.Catalog.CardOf(card.PrintingId).NameEn))
.ToList(),
Resolve = index => onChosen(cards[index]),
};
State.Step = Step.Choice;
State.ActivePlayer = player;
}
public void AskEmptyRearGuardChoice(int player, string prompt, Action<int> onChosen)
{
var slots = Enumerable.Range(0, PlayerState.RearGuardSlots)

View File

@ -26,6 +26,7 @@ public class GameEngineTests
private const string Libra = "BT01/017EN"; // OTT, grade 2
private const string Deathrex = "BT01/033EN"; // Tachikaze, 10000
private const string DemonEater = "BT01/036EN"; // Dark Irregulars, 10000
private const string Morgana = "TD01/011EN"; // Royal Paladin, grade 1, discard +4000
[Fact]
public void Start_Places_Starting_Vanguard_And_Draws_Five()
@ -940,6 +941,30 @@ public class GameEngineTests
Assert.NotNull(engine.State.PendingAbility);
}
[Fact]
public void Morgana_Discards_To_Gain_4000_When_Attacking()
{
var engine = StartedEngine();
engine.Execute(new SkipRideCommand());
SetVanguard(engine, 0, Morgana);
engine.Execute(new EnterBattleCommand());
var morgana = engine.State.Players[0].Vanguard!;
var handBefore = engine.State.Players[0].Hand.Count;
Assert.True(engine.Execute(new AttackCommand(-1, -1)).Accepted);
Assert.NotNull(engine.State.PendingAbility);
Assert.True(engine.Execute(new ResolveAbilityCommand(true)).Accepted);
Assert.NotNull(engine.State.PendingChoice);
Assert.True(engine.Execute(new ChooseCommand(0)).Accepted);
Assert.Equal(4000, morgana.TemporaryPower);
Assert.Equal(handBefore - 1, engine.State.Players[0].Hand.Count);
Assert.Equal(Step.Guard, engine.State.Step);
}
[Fact]
public void ResolveTrigger_Rejects_When_Nothing_Is_Pending()
{
@ -1079,6 +1104,7 @@ public class GameEngineTests
(Lien, 2),
(Cocoa, 2), (Libra, 2),
(Deathrex, 2), (DemonEater, 2),
(Morgana, 2),
};
private List<string> BuildDeck()