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:
parent
5262b6b93c
commit
a1e7c250bf
|
|
@ -114,9 +114,9 @@
|
||||||
- [ ] Discard, Shuffle как отдельные интенты.
|
- [ ] Discard, Shuffle как отдельные интенты.
|
||||||
- [x] Число проверок принадлежит атакующей карте: 0–2 ранг — одна, 3+ — две; Арьергард по умолчанию без проверок; `DriveChecksOverride` меняет число (в том числе снимает двойную).
|
- [x] Число проверок принадлежит атакующей карте: 0–2 ранг — одна, 3+ — две; Арьергард по умолчанию без проверок; `DriveChecksOverride` меняет число (в том числе снимает двойную).
|
||||||
- [x] Результат боя: `Hit` / `Guarded` как событие.
|
- [x] Результат боя: `Hit` / `Guarded` как событие.
|
||||||
- [x] Событийная шина: `Triggered` (OnRideOver, OnBoost), `Continuous` с пересчётом при запросе; `Activated` ещё нет.
|
- [x] Событийная шина: `Triggered` (OnRideOver, OnPlace, OnBoost), `Continuous` с пересчётом при запросе, `Activated` (повернуть себя + сброс карты).
|
||||||
- [x] Check Timing: сбор сработавших, порядок (активный игрок первым, выбор игрока), пауза на опциональные способности.
|
- [x] Check Timing: сбор сработавших, порядок (активный игрок первым, выбор игрока), пауза на опциональные способности.
|
||||||
- [ ] `Activated`-способности и выбор целей игроком.
|
- [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».
|
- [ ] Цели: авто по фильтру и выбор игроком, «до N» / «ровно N».
|
||||||
- [~] Слои модификаторов силы/крита: усиления от триггеров живут до конца хода, постоянных слоёв нет.
|
- [~] Слои модификаторов силы/крита: усиления от триггеров живут до конца хода, постоянных слоёв нет.
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ namespace Vanguard.Rules.Engine.Abilities;
|
||||||
public enum AbilityTrigger
|
public enum AbilityTrigger
|
||||||
{
|
{
|
||||||
OnRideOver,
|
OnRideOver,
|
||||||
|
OnPlace,
|
||||||
OnBoost,
|
OnBoost,
|
||||||
OnPhaseStart,
|
OnPhaseStart,
|
||||||
OnAttackDeclared,
|
OnAttackDeclared,
|
||||||
|
|
@ -33,6 +34,12 @@ public sealed class AbilityDefinition
|
||||||
public Func<AbilityContext, bool>? Condition { get; init; }
|
public Func<AbilityContext, bool>? Condition { get; init; }
|
||||||
public required Action<AbilityContext> Resolve { 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>
|
/// <summary>«Вы можете» — движок спрашивает игрока перед разрешением.</summary>
|
||||||
public bool Optional { get; init; }
|
public bool Optional { get; init; }
|
||||||
}
|
}
|
||||||
|
|
@ -45,11 +52,24 @@ public sealed class ContinuousAbility
|
||||||
public Func<AbilityContext, int>? CriticalBonus { get; init; }
|
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 class CardScript
|
||||||
{
|
{
|
||||||
public abstract string NameId { get; }
|
public abstract string NameId { get; }
|
||||||
public virtual IEnumerable<AbilityDefinition> Abilities => Array.Empty<AbilityDefinition>();
|
public virtual IEnumerable<AbilityDefinition> Abilities => Array.Empty<AbilityDefinition>();
|
||||||
public virtual IEnumerable<ContinuousAbility> Continuous => Array.Empty<ContinuousAbility>();
|
public virtual IEnumerable<ContinuousAbility> Continuous => Array.Empty<ContinuousAbility>();
|
||||||
|
public virtual IEnumerable<ActivatedAbility> Activated => Array.Empty<ActivatedAbility>();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Сработавшая способность, ожидающая выбора порядка или решения игрока.</summary>
|
/// <summary>Сработавшая способность, ожидающая выбора порядка или решения игрока.</summary>
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,101 @@ public static class CardScripts
|
||||||
["barcgal"] = new BarcgalScript(),
|
["barcgal"] = new BarcgalScript(),
|
||||||
["wingal"] = new WingalScript(),
|
["wingal"] = new WingalScript(),
|
||||||
["king-of-knights-alfred"] = new AlfredScript(),
|
["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>
|
/// <summary>
|
||||||
/// Barcgal: когда другой Royal Paladin сменяет эту карту, вы можете призвать её в круг Арьергарда.
|
/// Barcgal: когда другой Royal Paladin сменяет эту карту, вы можете призвать её в круг Арьергарда.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ public sealed record PassGuardCommand : GameCommand;
|
||||||
public sealed record ResolveTriggerCommand(int PowerTarget, int EffectTarget) : GameCommand;
|
public sealed record ResolveTriggerCommand(int PowerTarget, int EffectTarget) : GameCommand;
|
||||||
public sealed record SelectTriggerCommand(int Index) : GameCommand;
|
public sealed record SelectTriggerCommand(int Index) : GameCommand;
|
||||||
public sealed record ResolveAbilityCommand(bool Accept) : 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 EndTurnCommand : GameCommand;
|
||||||
|
|
||||||
public sealed record CommandResult(bool Accepted, string? Reason)
|
public sealed record CommandResult(bool Accepted, string? Reason)
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,8 @@ namespace Vanguard.Rules.Engine;
|
||||||
/// из заднего ряда — только если эффект разрешил (InterceptFromBackRow);
|
/// из заднего ряда — только если эффект разрешил (InterceptFromBackRow);
|
||||||
/// * число проверок принадлежит атакующей карте: 0–2 ранг — одна, 3+ — две;
|
/// * число проверок принадлежит атакующей карте: 0–2 ранг — одна, 3+ — две;
|
||||||
/// Арьергард по умолчанию без проверок, но эффект может их выдать;
|
/// Арьергард по умолчанию без проверок, но эффект может их выдать;
|
||||||
/// * способности карт подключаются скриптами (CardScript): Triggered и Continuous;
|
/// * способности карт: Triggered, Continuous и Activated; выбор цели идёт через
|
||||||
/// Check Timing собирает сработавшие, активный игрок выбирает порядок,
|
/// PendingChoice (ChooseCommand), порядок триггеров выбирает активный игрок;
|
||||||
/// опциональные способности ждут ResolveAbilityCommand;
|
|
||||||
/// * непрерывные эффекты пересчитываются при каждом запросе (EffectivePower);
|
/// * непрерывные эффекты пересчитываются при каждом запросе (EffectivePower);
|
||||||
/// * триггер требует выбора целей через ResolveTriggerCommand:
|
/// * триггер требует выбора целей через ResolveTriggerCommand:
|
||||||
/// +5000 любой карте в кругах Авангарда или Арьергарда, затем второй эффект;
|
/// +5000 любой карте в кругах Авангарда или Арьергарда, затем второй эффект;
|
||||||
|
|
@ -115,9 +114,15 @@ public sealed class GameEngine
|
||||||
|
|
||||||
private void OfferAbility(TriggeredAbility ability)
|
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.PendingAbility = ability;
|
||||||
State.Step = Step.Ability;
|
State.Step = Step.Ability;
|
||||||
|
|
@ -131,26 +136,126 @@ public sealed class GameEngine
|
||||||
private void ResolveAbility(TriggeredAbility ability, bool accepted)
|
private void ResolveAbility(TriggeredAbility ability, bool accepted)
|
||||||
{
|
{
|
||||||
if (accepted)
|
if (accepted)
|
||||||
|
{
|
||||||
|
ability.Definition.Pay?.Invoke(ability.Context);
|
||||||
ability.Definition.Resolve(ability.Context);
|
ability.Definition.Resolve(ability.Context);
|
||||||
|
}
|
||||||
|
|
||||||
_events.Add(new AbilityResolvedEvent(ability.Owner, ability.Context.Source.PrintingId, accepted));
|
_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)
|
if (State.PendingOrder is { Count: > 0 } order)
|
||||||
|
{
|
||||||
|
if (order.Count == 1)
|
||||||
{
|
{
|
||||||
var next = order[0];
|
var next = order[0];
|
||||||
order.RemoveAt(0);
|
|
||||||
|
|
||||||
if (order.Count == 0)
|
|
||||||
State.PendingOrder = null;
|
State.PendingOrder = null;
|
||||||
|
|
||||||
State.ActivePlayer = next.Owner;
|
State.ActivePlayer = next.Owner;
|
||||||
OfferAbility(next);
|
OfferAbility(next);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
State.Step = Step.Ability;
|
||||||
|
State.ActivePlayer = order[0].Owner;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
State.Step = Step.None;
|
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)
|
private CommandResult SelectTrigger(int index)
|
||||||
{
|
{
|
||||||
var order = State.PendingOrder;
|
var order = State.PendingOrder;
|
||||||
|
|
@ -261,6 +366,107 @@ public sealed class GameEngine
|
||||||
battle.AbilityPower += amount;
|
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()
|
public void Start()
|
||||||
|
|
@ -332,6 +538,8 @@ public sealed class GameEngine
|
||||||
ResolveTriggerCommand c => ResolveTrigger(c),
|
ResolveTriggerCommand c => ResolveTrigger(c),
|
||||||
SelectTriggerCommand c => SelectTrigger(c.Index),
|
SelectTriggerCommand c => SelectTrigger(c.Index),
|
||||||
ResolveAbilityCommand c => ResolvePendingAbility(c.Accept),
|
ResolveAbilityCommand c => ResolvePendingAbility(c.Accept),
|
||||||
|
ChooseCommand c => Choose(c.Index),
|
||||||
|
ActivateAbilityCommand c => Activate(c.InstanceId, c.DiscardPrintingId),
|
||||||
EndTurnCommand => EndTurn(),
|
EndTurnCommand => EndTurn(),
|
||||||
_ => CommandResult.Reject("Неизвестная команда."),
|
_ => CommandResult.Reject("Неизвестная команда."),
|
||||||
};
|
};
|
||||||
|
|
@ -404,6 +612,9 @@ public sealed class GameEngine
|
||||||
State.Phase = Phase.Main;
|
State.Phase = Phase.Main;
|
||||||
_events.Add(new PhaseChangedEvent(State.ActivePlayer, Phase.Main));
|
_events.Add(new PhaseChangedEvent(State.ActivePlayer, Phase.Main));
|
||||||
|
|
||||||
|
TriggerFromCard(State.ActivePlayer, card, AbilityTrigger.OnPlace, null);
|
||||||
|
|
||||||
|
if (State.Step == Step.None)
|
||||||
TriggerFromCard(State.ActivePlayer, riddenOver, AbilityTrigger.OnRideOver, card);
|
TriggerFromCard(State.ActivePlayer, riddenOver, AbilityTrigger.OnRideOver, card);
|
||||||
|
|
||||||
return CommandResult.Ok();
|
return CommandResult.Ok();
|
||||||
|
|
@ -441,6 +652,9 @@ public sealed class GameEngine
|
||||||
player.RearGuards[slot] = card;
|
player.RearGuards[slot] = card;
|
||||||
|
|
||||||
_events.Add(new UnitCalledEvent(State.ActivePlayer, slot, card.PrintingId));
|
_events.Add(new UnitCalledEvent(State.ActivePlayer, slot, card.PrintingId));
|
||||||
|
|
||||||
|
TriggerFromCard(State.ActivePlayer, card, AbilityTrigger.OnPlace, null);
|
||||||
|
|
||||||
return CommandResult.Ok();
|
return CommandResult.Ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1050,6 +1264,8 @@ public sealed class GameEngine
|
||||||
return card;
|
return card;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void DrawCards(int player, int count) => Draw(player, count);
|
||||||
|
|
||||||
private void Draw(int player, int count)
|
private void Draw(int player, int count)
|
||||||
{
|
{
|
||||||
for (var i = 0; i < count; i++)
|
for (var i = 0; i < count; i++)
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,22 @@ public enum Step
|
||||||
Drive,
|
Drive,
|
||||||
Trigger,
|
Trigger,
|
||||||
Ability,
|
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
|
public enum TriggerContext
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,9 @@ public sealed class GameState
|
||||||
/// <summary>Сработавшие способности ждут выбора порядка.</summary>
|
/// <summary>Сработавшие способности ждут выбора порядка.</summary>
|
||||||
public List<TriggeredAbility>? PendingOrder { get; internal set; }
|
public List<TriggeredAbility>? PendingOrder { get; internal set; }
|
||||||
|
|
||||||
|
/// <summary>Ожидание выбора цели или карты.</summary>
|
||||||
|
public PendingChoice? PendingChoice { get; internal set; }
|
||||||
|
|
||||||
public bool IsFinished { get; internal set; }
|
public bool IsFinished { get; internal set; }
|
||||||
public int? Winner { get; internal set; }
|
public int? Winner { get; internal set; }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ public class GameEngineTests
|
||||||
private const string Conroe = "BT01/016EN"; // Kagero, grade 0 без триггера
|
private const string Conroe = "BT01/016EN"; // Kagero, grade 0 без триггера
|
||||||
private const string BlasterBlade = "BT01/002EN";
|
private const string BlasterBlade = "BT01/002EN";
|
||||||
private const string Wingal = "BT01/044EN";
|
private const string Wingal = "BT01/044EN";
|
||||||
|
private const string Lien = "BT01/043EN"; // activated: rest + discard → draw
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Start_Places_Starting_Vanguard_And_Draws_Five()
|
public void Start_Places_Starting_Vanguard_And_Draws_Five()
|
||||||
|
|
@ -624,6 +625,107 @@ public class GameEngineTests
|
||||||
Assert.DoesNotContain(engine.Events, e => e is UnitRetiredEvent);
|
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]
|
[Fact]
|
||||||
public void ResolveTrigger_Rejects_When_Nothing_Is_Pending()
|
public void ResolveTrigger_Rejects_When_Nothing_Is_Pending()
|
||||||
{
|
{
|
||||||
|
|
@ -760,6 +862,7 @@ public class GameEngineTests
|
||||||
(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, 2),
|
||||||
(BlasterBlade, 2), (Wingal, 2),
|
(BlasterBlade, 2), (Wingal, 2),
|
||||||
|
(Lien, 2),
|
||||||
};
|
};
|
||||||
|
|
||||||
private List<string> BuildDeck()
|
private List<string> BuildDeck()
|
||||||
|
|
@ -822,6 +925,19 @@ public class GameEngineTests
|
||||||
player.Deck.Insert(0, cards[i]);
|
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)
|
private static void SetVanguard(GameEngine engine, int player, string printingId)
|
||||||
{
|
{
|
||||||
var p = engine.State.Players[player];
|
var p = engine.State.Players[player];
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue