Target selection and Activated abilities

PendingChoice/ChooseCommand let effects ask the player to pick a unit. Added Counter Blast and Soul Blast cost helpers. Blaster Blade implemented for both zones with its CB2 cost and rear-guard target. Activated abilities pay rest-self and discard; Lake Maiden Lien, Gojo and Shout draw a card. Seven tests.
This commit is contained in:
MpakobecEx 2026-10-21 13:14:16 +05:00
parent 5262b6b93c
commit a1e7c250bf
8 changed files with 479 additions and 14 deletions

View File

@ -114,9 +114,9 @@
- [ ] Discard, Shuffle как отдельные интенты.
- [x] Число проверок принадлежит атакующей карте: 0–2 ранг — одна, 3+ — две; Арьергард по умолчанию без проверок; `DriveChecksOverride` меняет число (в том числе снимает двойную).
- [x] Результат боя: `Hit` / `Guarded` как событие.
- [x] Событийная шина: `Triggered` (OnRideOver, OnBoost), `Continuous` с пересчётом при запросе; `Activated` ещё нет.
- [x] Событийная шина: `Triggered` (OnRideOver, OnPlace, OnBoost), `Continuous` с пересчётом при запросе, `Activated` (повернуть себя + сброс карты).
- [x] Check Timing: сбор сработавших, порядок (активный игрок первым, выбор игрока), пауза на опциональные способности.
- [ ] `Activated`-способности и выбор целей игроком.
- [x] Выбор целей: `PendingChoice` + `ChooseCommand`, используется Blaster Blade для уничтожения Арьергарда противника.
- [ ] Стоимости: CB, SB, discard, rest self, retire self, put to soul, return to deck.
- [ ] Цели: авто по фильтру и выбор игроком, «до N» / «ровно N».
- [~] Слои модификаторов силы/крита: усиления от триггеров живут до конца хода, постоянных слоёв нет.

View File

@ -5,6 +5,7 @@ namespace Vanguard.Rules.Engine.Abilities;
public enum AbilityTrigger
{
OnRideOver,
OnPlace,
OnBoost,
OnPhaseStart,
OnAttackDeclared,
@ -33,6 +34,12 @@ public sealed class AbilityDefinition
public Func<AbilityContext, bool>? Condition { get; init; }
public required Action<AbilityContext> Resolve { get; init; }
/// <summary>Проверка, что стоимость можно оплатить.</summary>
public Func<AbilityContext, bool>? CanPay { get; init; }
/// <summary>Оплата стоимости перед разрешением.</summary>
public Action<AbilityContext>? Pay { get; init; }
/// <summary>«Вы можете» — движок спрашивает игрока перед разрешением.</summary>
public bool Optional { get; init; }
}
@ -45,11 +52,24 @@ public sealed class ContinuousAbility
public Func<AbilityContext, int>? CriticalBonus { get; init; }
}
/// <summary>
/// Активируемая способность: игрок сам объявляет её в Основной фазе.
/// Стоимость (повернуть себя, сбросить карту) оплачивает движок по описанию.
/// </summary>
public sealed class ActivatedAbility
{
public required Func<AbilityContext, bool> CanActivate { get; init; }
public bool RestsSelf { get; init; }
public bool RequiresDiscard { get; init; }
public required Action<AbilityContext> Resolve { get; init; }
}
public abstract class CardScript
{
public abstract string NameId { get; }
public virtual IEnumerable<AbilityDefinition> Abilities => Array.Empty<AbilityDefinition>();
public virtual IEnumerable<ContinuousAbility> Continuous => Array.Empty<ContinuousAbility>();
public virtual IEnumerable<ActivatedAbility> Activated => Array.Empty<ActivatedAbility>();
}
/// <summary>Сработавшая способность, ожидающая выбора порядка или решения игрока.</summary>

View File

@ -11,9 +11,101 @@ public static class CardScripts
["barcgal"] = new BarcgalScript(),
["wingal"] = new WingalScript(),
["king-of-knights-alfred"] = new AlfredScript(),
["blaster-blade"] = new BlasterBladeScript(),
["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"),
};
}
/// <summary>
/// «[ACT]: повернуть эту карту и сбросить карту из руки → взять карту».
/// Общий скрипт для Lake Maiden, Lien, Dragon Monk, Gojo и Shout.
/// </summary>
public sealed class DrawOneScript : CardScript
{
public DrawOneScript(string nameId) => NameId = nameId;
public override string NameId { get; }
public override IEnumerable<ActivatedAbility> Activated
{
get
{
yield return new ActivatedAbility
{
CanActivate = _ => true,
RestsSelf = true,
RequiresDiscard = true,
Resolve = ctx => ctx.Engine.DrawCards(ctx.Owner, 1),
};
}
}
}
/// <summary>
/// Blaster Blade: при размещении оплатить Counter Blast 2 и уничтожить Арьергард противника.
/// На Авангарде — любой, на Арьергарде — только 2 ранга или выше и при Авангарде Royal Paladin.
/// </summary>
public sealed class BlasterBladeScript : CardScript
{
public override string NameId => "blaster-blade";
public override IEnumerable<AbilityDefinition> Abilities
{
get
{
yield return new AbilityDefinition
{
Trigger = AbilityTrigger.OnPlace,
Optional = true,
Condition = ctx => ctx.Engine.IsVanguardOf(ctx.Source, ctx.Owner),
CanPay = ctx => ctx.Engine.CanCounterBlast(ctx.Owner, 2),
Pay = ctx => ctx.Engine.CounterBlast(ctx.Owner, 2),
Resolve = ctx =>
{
var opponent = 1 - ctx.Owner;
var slots = ctx.Engine.RearGuardSlots(opponent, _ => true);
if (slots.Count == 0)
return;
ctx.Engine.AskUnitChoice(
ctx.Owner,
opponent,
"Выберите Арьергард противника",
slots,
slot => ctx.Engine.RetireRearGuard(opponent, slot));
},
};
yield return new AbilityDefinition
{
Trigger = AbilityTrigger.OnPlace,
Optional = true,
Condition = ctx =>
!ctx.Engine.IsVanguardOf(ctx.Source, ctx.Owner) &&
ctx.Engine.VanguardClan(ctx.Owner) == Clan.RoyalPaladin,
CanPay = ctx => ctx.Engine.CanCounterBlast(ctx.Owner, 2),
Pay = ctx => ctx.Engine.CounterBlast(ctx.Owner, 2),
Resolve = ctx =>
{
var opponent = 1 - ctx.Owner;
var slots = ctx.Engine.RearGuardSlots(opponent, d => d.Grade >= 2);
if (slots.Count == 0)
return;
ctx.Engine.AskUnitChoice(
ctx.Owner,
opponent,
"Выберите Арьергард противника 2 ранга или выше",
slots,
slot => ctx.Engine.RetireRearGuard(opponent, slot));
},
};
}
}
}
/// <summary>
/// Barcgal: когда другой Royal Paladin сменяет эту карту, вы можете призвать её в круг Арьергарда.
/// </summary>

View File

@ -16,6 +16,8 @@ public sealed record PassGuardCommand : GameCommand;
public sealed record ResolveTriggerCommand(int PowerTarget, int EffectTarget) : GameCommand;
public sealed record SelectTriggerCommand(int Index) : GameCommand;
public sealed record ResolveAbilityCommand(bool Accept) : GameCommand;
public sealed record ChooseCommand(int Index) : GameCommand;
public sealed record ActivateAbilityCommand(int InstanceId, string? DiscardPrintingId = null) : GameCommand;
public sealed record EndTurnCommand : GameCommand;
public sealed record CommandResult(bool Accepted, string? Reason)

View File

@ -18,9 +18,8 @@ namespace Vanguard.Rules.Engine;
/// из заднего ряда — только если эффект разрешил (InterceptFromBackRow);
/// * число проверок принадлежит атакующей карте: 0–2 ранг — одна, 3+ — две;
/// Арьергард по умолчанию без проверок, но эффект может их выдать;
/// * способности карт подключаются скриптами (CardScript): Triggered и Continuous;
/// Check Timing собирает сработавшие, активный игрок выбирает порядок,
/// опциональные способности ждут ResolveAbilityCommand;
/// * способности карт: Triggered, Continuous и Activated; выбор цели идёт через
/// PendingChoice (ChooseCommand), порядок триггеров выбирает активный игрок;
/// * непрерывные эффекты пересчитываются при каждом запросе (EffectivePower);
/// * триггер требует выбора целей через ResolveTriggerCommand:
/// +5000 любой карте в кругах Авангарда или Арьергарда, затем второй эффект;
@ -115,9 +114,15 @@ public sealed class GameEngine
private void OfferAbility(TriggeredAbility ability)
{
_events.Add(new AbilityTriggeredEvent(ability.Owner, ability.Context.Source.PrintingId, ability.Definition.Trigger));
var definition = ability.Definition;
if (ability.Definition.Optional)
// если стоимость оплатить нельзя, способность не предлагается
if (definition.CanPay is not null && !definition.CanPay(ability.Context))
return;
_events.Add(new AbilityTriggeredEvent(ability.Owner, ability.Context.Source.PrintingId, definition.Trigger));
if (definition.Optional)
{
State.PendingAbility = ability;
State.Step = Step.Ability;
@ -131,26 +136,126 @@ public sealed class GameEngine
private void ResolveAbility(TriggeredAbility ability, bool accepted)
{
if (accepted)
{
ability.Definition.Pay?.Invoke(ability.Context);
ability.Definition.Resolve(ability.Context);
}
_events.Add(new AbilityResolvedEvent(ability.Owner, ability.Context.Source.PrintingId, accepted));
// способность попросила выбрать цель — ждём ChooseCommand
if (State.Step == Step.Choice)
return;
ContinueOrder();
}
private void ContinueOrder()
{
if (State.PendingOrder is { Count: > 0 } order)
{
var next = order[0];
order.RemoveAt(0);
if (order.Count == 0)
if (order.Count == 1)
{
var next = order[0];
State.PendingOrder = null;
State.ActivePlayer = next.Owner;
OfferAbility(next);
return;
}
State.ActivePlayer = next.Owner;
OfferAbility(next);
State.Step = Step.Ability;
State.ActivePlayer = order[0].Owner;
return;
}
State.Step = Step.None;
}
private CommandResult Activate(int instanceId, string? discardPrintingId)
{
if (State.Phase != Phase.Main)
return CommandResult.Reject("Активируемые способности доступны только в Основной фазе.");
if (State.Step != Step.None || State.PendingAbility is not null || State.PendingChoice is not null)
return CommandResult.Reject("Сейчас нельзя активировать способности.");
var player = State.ActivePlayer;
var p = State.Players[player];
var unit = AllUnits(player).FirstOrDefault(u => u.Id == instanceId);
if (unit is null)
return CommandResult.Reject("Карта не на поле.");
var nameId = State.Catalog.CardOf(unit.PrintingId).NameId;
if (!_scripts.TryGetValue(nameId, out var script))
return CommandResult.Reject("У карты нет способностей.");
var context = new AbilityContext
{
Engine = this,
Source = unit,
Owner = player,
Phase = State.Phase,
};
var ability = script.Activated.FirstOrDefault(a => a.CanActivate(context));
if (ability is null)
return CommandResult.Reject("Нет доступной способности.");
if (ability.RestsSelf && unit.Rested)
return CommandResult.Reject("Карта уже повёрнута.");
CardInstance? discard = null;
if (ability.RequiresDiscard)
{
if (discardPrintingId is null)
return CommandResult.Reject("Нужно выбрать карту для сброса.");
var index = p.Hand.FindIndex(c => c.PrintingId == discardPrintingId);
if (index < 0)
return CommandResult.Reject("Карты для сброса нет в руке.");
discard = p.Hand[index];
}
if (ability.RestsSelf)
unit.Rested = true;
if (discard is not null)
{
p.Hand.Remove(discard);
p.Drop.Add(discard);
}
ability.Resolve(context);
_events.Add(new AbilityResolvedEvent(player, unit.PrintingId, true));
return CommandResult.Ok();
}
private CommandResult Choose(int index)
{
var choice = State.PendingChoice;
if (choice is null || State.Step != Step.Choice)
return CommandResult.Reject("Сейчас нет выбора.");
if (State.ActivePlayer != choice.Player)
return CommandResult.Reject("Сейчас выбирает другой игрок.");
if (index < 0 || index >= choice.Options.Count)
return CommandResult.Reject("Некорректный вариант выбора.");
var option = choice.Options[index];
State.PendingChoice = null;
State.Step = Step.None;
choice.Resolve(option.Slot);
if (State.Step != Step.Choice)
ContinueOrder();
return CommandResult.Ok();
}
private CommandResult SelectTrigger(int index)
{
var order = State.PendingOrder;
@ -261,6 +366,107 @@ public sealed class GameEngine
battle.AbilityPower += amount;
}
// ---------- стоимости ----------
public int FaceDownDamage(int player) =>
State.Players[player].Damage.Count(c => !c.FaceUp);
public bool CanCounterBlast(int player, int amount) =>
FaceDownDamage(player) >= amount;
public void CounterBlast(int player, int amount)
{
var flipped = 0;
foreach (var card in State.Players[player].Damage)
{
if (flipped == amount)
break;
if (card.FaceUp)
continue;
card.FaceUp = true;
flipped++;
}
}
public bool CanSoulBlast(int player, int amount) =>
State.Players[player].Soul.Count >= amount;
public void SoulBlast(int player, int amount)
{
var soul = State.Players[player].Soul;
for (var i = 0; i < amount && soul.Count > 0; i++)
{
var card = soul[0];
soul.RemoveAt(0);
State.Players[player].Drop.Add(card);
}
}
// ---------- выбор целей ----------
/// <summary>Круги Арьергарда игрока, подходящие под фильтр.</summary>
public IReadOnlyList<int> RearGuardSlots(int player, Func<CardDefinition, bool> predicate)
{
var slots = new List<int>();
for (var slot = 0; slot < PlayerState.RearGuardSlots; slot++)
{
var unit = State.Players[player].RearGuards[slot];
if (unit is null)
continue;
if (predicate(State.Catalog.CardOf(unit.PrintingId)))
slots.Add(slot);
}
return slots;
}
/// <summary>Останавливает разрешение и просит игрока выбрать карту на поле.</summary>
public void AskUnitChoice(
int chooser,
int targetPlayer,
string prompt,
IReadOnlyList<int> slots,
Action<int> onChosen)
{
var options = slots
.Select(slot =>
{
var unit = State.GetUnit(targetPlayer, slot)!;
return new ChoiceOption(slot, State.Catalog.CardOf(unit.PrintingId).NameEn);
})
.ToList();
State.PendingChoice = new PendingChoice
{
Player = chooser,
Prompt = prompt,
Options = options,
Resolve = onChosen,
};
State.Step = Step.Choice;
State.ActivePlayer = chooser;
}
public void RetireRearGuard(int player, int slot)
{
var unit = State.Players[player].RearGuards[slot];
if (unit is null)
return;
State.Players[player].RearGuards[slot] = null;
State.Players[player].Drop.Add(unit);
_events.Add(new UnitRetiredEvent(player, slot));
}
public Clan VanguardClan(int player)
{
var vanguard = State.Players[player].Vanguard;
return vanguard is null ? Clan.RoyalPaladin : State.Catalog.CardOf(vanguard.PrintingId).Clan;
}
// ---------- старт партии ----------
public void Start()
@ -332,6 +538,8 @@ public sealed class GameEngine
ResolveTriggerCommand c => ResolveTrigger(c),
SelectTriggerCommand c => SelectTrigger(c.Index),
ResolveAbilityCommand c => ResolvePendingAbility(c.Accept),
ChooseCommand c => Choose(c.Index),
ActivateAbilityCommand c => Activate(c.InstanceId, c.DiscardPrintingId),
EndTurnCommand => EndTurn(),
_ => CommandResult.Reject("Неизвестная команда."),
};
@ -404,7 +612,10 @@ public sealed class GameEngine
State.Phase = Phase.Main;
_events.Add(new PhaseChangedEvent(State.ActivePlayer, Phase.Main));
TriggerFromCard(State.ActivePlayer, riddenOver, AbilityTrigger.OnRideOver, card);
TriggerFromCard(State.ActivePlayer, card, AbilityTrigger.OnPlace, null);
if (State.Step == Step.None)
TriggerFromCard(State.ActivePlayer, riddenOver, AbilityTrigger.OnRideOver, card);
return CommandResult.Ok();
}
@ -441,6 +652,9 @@ public sealed class GameEngine
player.RearGuards[slot] = card;
_events.Add(new UnitCalledEvent(State.ActivePlayer, slot, card.PrintingId));
TriggerFromCard(State.ActivePlayer, card, AbilityTrigger.OnPlace, null);
return CommandResult.Ok();
}
@ -1050,6 +1264,8 @@ public sealed class GameEngine
return card;
}
public void DrawCards(int player, int count) => Draw(player, count);
private void Draw(int player, int count)
{
for (var i = 0; i < count; i++)

View File

@ -20,6 +20,22 @@ public enum Step
Drive,
Trigger,
Ability,
Choice,
}
/// <summary>Вариант выбора, который предлагается игроку.</summary>
public sealed record ChoiceOption(int Slot, string Label);
/// <summary>
/// Ожидание выбора игрока. Resolve получает индекс выбранного варианта.
/// Пока выбор не сделан, движок не продолжает разрешение способности.
/// </summary>
public sealed class PendingChoice
{
public required int Player { get; init; }
public required string Prompt { get; init; }
public required IReadOnlyList<ChoiceOption> Options { get; init; }
public required Action<int> Resolve { get; init; }
}
public enum TriggerContext

View File

@ -60,6 +60,9 @@ public sealed class GameState
/// <summary>Сработавшие способности ждут выбора порядка.</summary>
public List<TriggeredAbility>? PendingOrder { get; internal set; }
/// <summary>Ожидание выбора цели или карты.</summary>
public PendingChoice? PendingChoice { get; internal set; }
public bool IsFinished { get; internal set; }
public int? Winner { get; internal set; }

View File

@ -21,6 +21,7 @@ public class GameEngineTests
private const string Conroe = "BT01/016EN"; // Kagero, grade 0 без триггера
private const string BlasterBlade = "BT01/002EN";
private const string Wingal = "BT01/044EN";
private const string Lien = "BT01/043EN"; // activated: rest + discard → draw
[Fact]
public void Start_Places_Starting_Vanguard_And_Draws_Five()
@ -624,6 +625,107 @@ public class GameEngineTests
Assert.DoesNotContain(engine.Events, e => e is UnitRetiredEvent);
}
[Fact]
public void BlasterBlade_On_Vanguard_Retires_Chosen_RearGuard()
{
var engine = StartedEngine();
SetVanguard(engine, 0, Marron);
GiveDamage(engine, 0, 2);
var opponent = engine.State.Players[1];
opponent.RearGuards[0] = TakeFromDeck(opponent, Gallatin);
Give(engine, 0, BlasterBlade);
Assert.True(engine.Execute(new RideCommand(BlasterBlade)).Accepted);
Assert.NotNull(engine.State.PendingAbility);
Assert.True(engine.Execute(new ResolveAbilityCommand(true)).Accepted);
Assert.NotNull(engine.State.PendingChoice);
Assert.Single(engine.State.PendingChoice!.Options);
Assert.True(engine.Execute(new ChooseCommand(0)).Accepted);
Assert.Null(opponent.RearGuards[0]);
Assert.Equal(2, engine.State.Players[0].Damage.Count(c => c.FaceUp));
}
[Fact]
public void BlasterBlade_Is_Not_Offered_Without_Counter_Blast()
{
var engine = StartedEngine();
SetVanguard(engine, 0, Marron);
Give(engine, 0, BlasterBlade);
Assert.True(engine.Execute(new RideCommand(BlasterBlade)).Accepted);
Assert.Null(engine.State.PendingAbility);
}
[Fact]
public void BlasterBlade_On_RearGuard_Requires_RoyalPaladin_Vanguard()
{
var kagero = StartedEngine();
kagero.Execute(new SkipRideCommand());
Give(kagero, 0, BlasterBlade);
kagero.Execute(new CallCommand(BlasterBlade, 0));
Assert.Null(kagero.State.PendingAbility);
var royal = StartedEngine();
SetVanguard(royal, 0, Marron);
royal.Execute(new SkipRideCommand());
GiveDamage(royal, 0, 2);
Give(royal, 0, BlasterBlade);
royal.Execute(new CallCommand(BlasterBlade, 0));
Assert.NotNull(royal.State.PendingAbility);
}
[Fact]
public void Activated_Ability_Rests_Self_Discards_And_Draws()
{
var engine = InMainPhase();
Give(engine, 0, Lien);
engine.Execute(new CallCommand(Lien, 0));
var lien = engine.State.Players[0].RearGuards[0]!;
lien.Rested = false;
Give(engine, 0, Marron);
var handBefore = engine.State.Players[0].Hand.Count;
Assert.True(engine.Execute(new ActivateAbilityCommand(lien.Id, Marron)).Accepted);
Assert.True(lien.Rested);
Assert.Contains(engine.State.Players[0].Drop, c => c.PrintingId == Marron);
Assert.Equal(handBefore, engine.State.Players[0].Hand.Count); // -1 сброс, +1 добор
}
[Fact]
public void Activated_Ability_Requires_Discard()
{
var engine = InMainPhase();
Give(engine, 0, Lien);
engine.Execute(new CallCommand(Lien, 0));
var lien = engine.State.Players[0].RearGuards[0]!;
lien.Rested = false;
Assert.False(engine.Execute(new ActivateAbilityCommand(lien.Id)).Accepted);
}
[Fact]
public void Activated_Ability_Rejects_Rested_Card()
{
var engine = InMainPhase();
Give(engine, 0, Lien);
engine.Execute(new CallCommand(Lien, 0));
var lien = engine.State.Players[0].RearGuards[0]!;
Give(engine, 0, Marron);
Assert.False(engine.Execute(new ActivateAbilityCommand(lien.Id, Marron)).Accepted);
}
[Fact]
public void ResolveTrigger_Rejects_When_Nothing_Is_Pending()
{
@ -760,6 +862,7 @@ public class GameEngineTests
(Nike, 2), (Flogal, 2), (Govannon, 2),
(Iseult, 2), (Nehalem, 2), (Overlord, 2), (Conroe, 2),
(BlasterBlade, 2), (Wingal, 2),
(Lien, 2),
};
private List<string> BuildDeck()
@ -822,6 +925,19 @@ public class GameEngineTests
player.Deck.Insert(0, cards[i]);
}
private static void GiveDamage(GameEngine engine, int player, int amount)
{
var p = engine.State.Players[player];
for (var i = 0; i < amount; i++)
{
Assert.NotEmpty(p.Deck);
var card = p.Deck[0];
p.Deck.RemoveAt(0);
card.FaceUp = false;
p.Damage.Add(card);
}
}
private static void SetVanguard(GameEngine engine, int player, string printingId)
{
var p = engine.State.Players[player];