Ability bus, Check Timing and the first card scripts
CardScript model with Triggered and Continuous abilities. Check Timing collects triggered abilities, orders them (turn player first, player picks the order via SelectTriggerCommand), and pauses on optional abilities (ResolveAbilityCommand). Continuous effects are recomputed on demand in EffectivePower. Scripts: Barcgal (optional call from soul), Wingal (+4000 when boosting Blaster Blade), Alfred (+2000 per Royal Paladin rear-guard). Six tests.
This commit is contained in:
parent
bf2ea337f8
commit
b7473f73d7
|
|
@ -114,8 +114,9 @@
|
||||||
- [ ] Discard, Shuffle как отдельные интенты.
|
- [ ] Discard, Shuffle как отдельные интенты.
|
||||||
- [x] Число проверок принадлежит атакующей карте: 0–2 ранг — одна, 3+ — две; Арьергард по умолчанию без проверок; `DriveChecksOverride` меняет число (в том числе снимает двойную).
|
- [x] Число проверок принадлежит атакующей карте: 0–2 ранг — одна, 3+ — две; Арьергард по умолчанию без проверок; `DriveChecksOverride` меняет число (в том числе снимает двойную).
|
||||||
- [x] Результат боя: `Hit` / `Guarded` как событие.
|
- [x] Результат боя: `Hit` / `Guarded` как событие.
|
||||||
- [ ] Событийная шина и три обработчика: Continuous / Activated / Triggered.
|
- [x] Событийная шина: `Triggered` (OnRideOver, OnBoost), `Continuous` с пересчётом при запросе; `Activated` ещё нет.
|
||||||
- [ ] Check Timing: сбор → сортировка → разрешение по одному → повтор.
|
- [x] Check Timing: сбор сработавших, порядок (активный игрок первым, выбор игрока), пауза на опциональные способности.
|
||||||
|
- [ ] `Activated`-способности и выбор целей игроком.
|
||||||
- [ ] Стоимости: 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».
|
||||||
- [~] Слои модификаторов силы/крита: усиления от триггеров живут до конца хода, постоянных слоёв нет.
|
- [~] Слои модификаторов силы/крита: усиления от триггеров живут до конца хода, постоянных слоёв нет.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
using Vanguard.Rules.Cards;
|
||||||
|
|
||||||
|
namespace Vanguard.Rules.Engine.Abilities;
|
||||||
|
|
||||||
|
public enum AbilityTrigger
|
||||||
|
{
|
||||||
|
OnRideOver,
|
||||||
|
OnBoost,
|
||||||
|
OnPhaseStart,
|
||||||
|
OnAttackDeclared,
|
||||||
|
OnAttackHit,
|
||||||
|
OnDamageTaken,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Контекст сработавшей способности.</summary>
|
||||||
|
public sealed class AbilityContext
|
||||||
|
{
|
||||||
|
public required GameEngine Engine { get; init; }
|
||||||
|
public required CardInstance Source { get; init; }
|
||||||
|
public required int Owner { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Второй участник события: новая карта при смене, усиленная карта при поддержке.</summary>
|
||||||
|
public CardInstance? Other { get; init; }
|
||||||
|
|
||||||
|
public Phase Phase { get; init; }
|
||||||
|
public GameState State => Engine.State;
|
||||||
|
public CardCatalog Catalog => Engine.State.Catalog;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class AbilityDefinition
|
||||||
|
{
|
||||||
|
public required AbilityTrigger Trigger { get; init; }
|
||||||
|
public Func<AbilityContext, bool>? Condition { get; init; }
|
||||||
|
public required Action<AbilityContext> Resolve { get; init; }
|
||||||
|
|
||||||
|
/// <summary>«Вы можете» — движок спрашивает игрока перед разрешением.</summary>
|
||||||
|
public bool Optional { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Непрерывный эффект: пересчитывается при каждом запросе.</summary>
|
||||||
|
public sealed class ContinuousAbility
|
||||||
|
{
|
||||||
|
public required Func<AbilityContext, bool> Applies { get; init; }
|
||||||
|
public Func<AbilityContext, int>? PowerBonus { get; init; }
|
||||||
|
public Func<AbilityContext, int>? CriticalBonus { 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>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Сработавшая способность, ожидающая выбора порядка или решения игрока.</summary>
|
||||||
|
public sealed class TriggeredAbility
|
||||||
|
{
|
||||||
|
public required AbilityDefinition Definition { get; init; }
|
||||||
|
public required AbilityContext Context { get; init; }
|
||||||
|
public int Owner => Context.Owner;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
using Vanguard.Rules.Cards;
|
||||||
|
|
||||||
|
namespace Vanguard.Rules.Engine.Abilities;
|
||||||
|
|
||||||
|
/// <summary>Скрипты карт первого сета, подключённые к движку.</summary>
|
||||||
|
public static class CardScripts
|
||||||
|
{
|
||||||
|
public static IReadOnlyDictionary<string, CardScript> Default { get; } =
|
||||||
|
new Dictionary<string, CardScript>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
["barcgal"] = new BarcgalScript(),
|
||||||
|
["wingal"] = new WingalScript(),
|
||||||
|
["king-of-knights-alfred"] = new AlfredScript(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Barcgal: когда другой Royal Paladin сменяет эту карту, вы можете призвать её в круг Арьергарда.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class BarcgalScript : CardScript
|
||||||
|
{
|
||||||
|
public override string NameId => "barcgal";
|
||||||
|
|
||||||
|
public override IEnumerable<AbilityDefinition> Abilities
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
yield return new AbilityDefinition
|
||||||
|
{
|
||||||
|
Trigger = AbilityTrigger.OnRideOver,
|
||||||
|
Optional = true,
|
||||||
|
Condition = ctx =>
|
||||||
|
ctx.Other is not null &&
|
||||||
|
ctx.Catalog.CardOf(ctx.Other.PrintingId).Clan == Clan.RoyalPaladin,
|
||||||
|
Resolve = ctx => ctx.Engine.CallFromSoul(ctx.Source, ctx.Owner),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wingal: когда эта карта поддерживает Blaster Blade, усиленная карта получает +4000 до конца боя.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class WingalScript : CardScript
|
||||||
|
{
|
||||||
|
private const string BlasterBlade = "blaster-blade";
|
||||||
|
|
||||||
|
public override string NameId => "wingal";
|
||||||
|
|
||||||
|
public override IEnumerable<AbilityDefinition> Abilities
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
yield return new AbilityDefinition
|
||||||
|
{
|
||||||
|
Trigger = AbilityTrigger.OnBoost,
|
||||||
|
Condition = ctx =>
|
||||||
|
ctx.Other is not null &&
|
||||||
|
ctx.Catalog.CardOf(ctx.Other.PrintingId).NameId == BlasterBlade,
|
||||||
|
Resolve = ctx => ctx.Engine.AddBattlePower(4000),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// King of Knights, Alfred: в свой ход на круге Авангарда получает +2000
|
||||||
|
/// за каждый свой Арьергард Royal Paladin.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AlfredScript : CardScript
|
||||||
|
{
|
||||||
|
public override string NameId => "king-of-knights-alfred";
|
||||||
|
|
||||||
|
public override IEnumerable<ContinuousAbility> Continuous
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
yield return new ContinuousAbility
|
||||||
|
{
|
||||||
|
Applies = ctx =>
|
||||||
|
ctx.Engine.IsVanguardOf(ctx.Source, ctx.Owner) &&
|
||||||
|
ctx.State.ActivePlayer == ctx.Owner,
|
||||||
|
PowerBonus = ctx => 2000 * ctx.Engine.CountRearGuards(ctx.Owner, Clan.RoyalPaladin),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,8 @@ public sealed record GuardCommand(string PrintingId, string? DiscardPrintingId =
|
||||||
public sealed record InterceptCommand(int Slot) : GameCommand;
|
public sealed record InterceptCommand(int Slot) : GameCommand;
|
||||||
public sealed record PassGuardCommand : GameCommand;
|
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 ResolveAbilityCommand(bool Accept) : 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)
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
using Vanguard.Rules.Cards;
|
using Vanguard.Rules.Cards;
|
||||||
using Vanguard.Rules.Decks;
|
using Vanguard.Rules.Decks;
|
||||||
|
using Vanguard.Rules.Engine.Abilities;
|
||||||
|
|
||||||
namespace Vanguard.Rules.Engine;
|
namespace Vanguard.Rules.Engine;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Ядро партии. Не знает ни о сети, ни о БД: на вход команды, на выход события.
|
/// Ядро партии. Не знает ни о сети, ни о БД: на вход команды, на выход события.
|
||||||
///
|
///
|
||||||
/// Упрощения первого среза (карты со способностями подключаются следующим шагом):
|
/// Что реализовано и какие есть упрощения:
|
||||||
/// * идеальная защита (Sentinel): щит 0, но при оплате стоимости (сброс карты того же клана)
|
/// * идеальная защита (Sentinel): щит 0, но при оплате стоимости (сброс карты того же клана)
|
||||||
/// атака полностью отменяется; без оплаты не даёт ничего;
|
/// атака полностью отменяется; без оплаты не даёт ничего;
|
||||||
/// * поддержка работает как отдельная способность: карты 0 и 1 ранга могут передать
|
/// * поддержка работает как отдельная способность: карты 0 и 1 ранга могут передать
|
||||||
|
|
@ -17,6 +18,10 @@ namespace Vanguard.Rules.Engine;
|
||||||
/// из заднего ряда — только если эффект разрешил (InterceptFromBackRow);
|
/// из заднего ряда — только если эффект разрешил (InterceptFromBackRow);
|
||||||
/// * число проверок принадлежит атакующей карте: 0–2 ранг — одна, 3+ — две;
|
/// * число проверок принадлежит атакующей карте: 0–2 ранг — одна, 3+ — две;
|
||||||
/// Арьергард по умолчанию без проверок, но эффект может их выдать;
|
/// Арьергард по умолчанию без проверок, но эффект может их выдать;
|
||||||
|
/// * способности карт подключаются скриптами (CardScript): Triggered и Continuous;
|
||||||
|
/// Check Timing собирает сработавшие, активный игрок выбирает порядок,
|
||||||
|
/// опциональные способности ждут ResolveAbilityCommand;
|
||||||
|
/// * непрерывные эффекты пересчитываются при каждом запросе (EffectivePower);
|
||||||
/// * триггер требует выбора целей через ResolveTriggerCommand:
|
/// * триггер требует выбора целей через ResolveTriggerCommand:
|
||||||
/// +5000 любой карте в кругах Авангарда или Арьергарда, затем второй эффект;
|
/// +5000 любой карте в кругах Авангарда или Арьергарда, затем второй эффект;
|
||||||
/// правило одинаково для drive check и damage check;
|
/// правило одинаково для drive check и damage check;
|
||||||
|
|
@ -30,8 +35,14 @@ public sealed class GameEngine
|
||||||
public const int TriggerPowerBonus = 5000;
|
public const int TriggerPowerBonus = 5000;
|
||||||
|
|
||||||
private readonly List<GameEvent> _events = new();
|
private readonly List<GameEvent> _events = new();
|
||||||
|
private readonly IReadOnlyDictionary<string, CardScript> _scripts;
|
||||||
|
|
||||||
public GameEngine(CardCatalog catalog, ulong seed, IReadOnlyList<string> deck0, IReadOnlyList<string> deck1)
|
public GameEngine(
|
||||||
|
CardCatalog catalog,
|
||||||
|
ulong seed,
|
||||||
|
IReadOnlyList<string> deck0,
|
||||||
|
IReadOnlyList<string> deck1,
|
||||||
|
IReadOnlyDictionary<string, CardScript>? scripts = null)
|
||||||
{
|
{
|
||||||
var violations = DeckValidator.Validate(catalog, deck0.ToList())
|
var violations = DeckValidator.Validate(catalog, deck0.ToList())
|
||||||
.Concat(DeckValidator.Validate(catalog, deck1.ToList()))
|
.Concat(DeckValidator.Validate(catalog, deck1.ToList()))
|
||||||
|
|
@ -41,6 +52,7 @@ public sealed class GameEngine
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
"Некорректная колода: " + string.Join("; ", violations.Select(v => v.Message)));
|
"Некорректная колода: " + string.Join("; ", violations.Select(v => v.Message)));
|
||||||
|
|
||||||
|
_scripts = scripts ?? CardScripts.Default;
|
||||||
State = new GameState(catalog, seed, "Player 1", "Player 2");
|
State = new GameState(catalog, seed, "Player 1", "Player 2");
|
||||||
FillDeck(0, deck0);
|
FillDeck(0, deck0);
|
||||||
FillDeck(1, deck1);
|
FillDeck(1, deck1);
|
||||||
|
|
@ -48,6 +60,7 @@ public sealed class GameEngine
|
||||||
|
|
||||||
public GameState State { get; }
|
public GameState State { get; }
|
||||||
public IReadOnlyList<GameEvent> Events => _events;
|
public IReadOnlyList<GameEvent> Events => _events;
|
||||||
|
public IReadOnlyDictionary<string, CardScript> Scripts => _scripts;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Сколько проверок делает атакующая карта. Эффект может изменить число;
|
/// Сколько проверок делает атакующая карта. Эффект может изменить число;
|
||||||
|
|
@ -61,6 +74,193 @@ public sealed class GameEngine
|
||||||
return slot < 0 ? State.Catalog.CardOf(attacker.PrintingId).DefaultDriveChecks : 0;
|
return slot < 0 ? State.Catalog.CardOf(attacker.PrintingId).DefaultDriveChecks : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- способности ----------
|
||||||
|
|
||||||
|
private void TriggerFromCard(int owner, CardInstance? source, AbilityTrigger trigger, CardInstance? other)
|
||||||
|
{
|
||||||
|
if (source is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var nameId = State.Catalog.CardOf(source.PrintingId).NameId;
|
||||||
|
if (!_scripts.TryGetValue(nameId, out var script))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var context = new AbilityContext
|
||||||
|
{
|
||||||
|
Engine = this,
|
||||||
|
Source = source,
|
||||||
|
Owner = owner,
|
||||||
|
Other = other,
|
||||||
|
Phase = State.Phase,
|
||||||
|
};
|
||||||
|
|
||||||
|
var triggered = script.Abilities
|
||||||
|
.Where(a => a.Trigger == trigger && (a.Condition is null || a.Condition(context)))
|
||||||
|
.Select(a => new TriggeredAbility { Definition = a, Context = context })
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (triggered.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (triggered.Count == 1)
|
||||||
|
{
|
||||||
|
OfferAbility(triggered[0]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
State.PendingOrder = triggered;
|
||||||
|
State.Step = Step.Ability;
|
||||||
|
State.ActivePlayer = triggered[0].Owner;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OfferAbility(TriggeredAbility ability)
|
||||||
|
{
|
||||||
|
_events.Add(new AbilityTriggeredEvent(ability.Owner, ability.Context.Source.PrintingId, ability.Definition.Trigger));
|
||||||
|
|
||||||
|
if (ability.Definition.Optional)
|
||||||
|
{
|
||||||
|
State.PendingAbility = ability;
|
||||||
|
State.Step = Step.Ability;
|
||||||
|
State.ActivePlayer = ability.Owner;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ResolveAbility(ability, accepted: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ResolveAbility(TriggeredAbility ability, bool accepted)
|
||||||
|
{
|
||||||
|
if (accepted)
|
||||||
|
ability.Definition.Resolve(ability.Context);
|
||||||
|
|
||||||
|
_events.Add(new AbilityResolvedEvent(ability.Owner, ability.Context.Source.PrintingId, accepted));
|
||||||
|
|
||||||
|
if (State.PendingOrder is { Count: > 0 } order)
|
||||||
|
{
|
||||||
|
var next = order[0];
|
||||||
|
order.RemoveAt(0);
|
||||||
|
|
||||||
|
if (order.Count == 0)
|
||||||
|
State.PendingOrder = null;
|
||||||
|
|
||||||
|
State.ActivePlayer = next.Owner;
|
||||||
|
OfferAbility(next);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
State.Step = Step.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
private CommandResult SelectTrigger(int index)
|
||||||
|
{
|
||||||
|
var order = State.PendingOrder;
|
||||||
|
if (order is null || State.Step != Step.Ability)
|
||||||
|
return CommandResult.Reject("Сейчас нет выбора порядка триггеров.");
|
||||||
|
|
||||||
|
if (index < 0 || index >= order.Count)
|
||||||
|
return CommandResult.Reject("Некорректный индекс.");
|
||||||
|
|
||||||
|
if (order[index].Owner != State.ActivePlayer)
|
||||||
|
return CommandResult.Reject("Сейчас выбирает другой игрок.");
|
||||||
|
|
||||||
|
var chosen = order[index];
|
||||||
|
order.RemoveAt(index);
|
||||||
|
|
||||||
|
if (order.Count == 0)
|
||||||
|
State.PendingOrder = null;
|
||||||
|
|
||||||
|
OfferAbility(chosen);
|
||||||
|
return CommandResult.Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
private CommandResult ResolvePendingAbility(bool accept)
|
||||||
|
{
|
||||||
|
var ability = State.PendingAbility;
|
||||||
|
if (ability is null || State.Step != Step.Ability)
|
||||||
|
return CommandResult.Reject("Сейчас нет способности, ожидающей решения.");
|
||||||
|
|
||||||
|
State.PendingAbility = null;
|
||||||
|
ResolveAbility(ability, accept);
|
||||||
|
return CommandResult.Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Сила карты с учётом непрерывных эффектов. Пересчитывается при каждом запросе.</summary>
|
||||||
|
public int EffectivePower(CardInstance unit)
|
||||||
|
{
|
||||||
|
var total = State.Catalog.CardOf(unit.PrintingId).Power + unit.TemporaryPower;
|
||||||
|
return total + ContinuousPower(unit);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int ContinuousPower(CardInstance unit)
|
||||||
|
{
|
||||||
|
var owner = OwnerOf(unit);
|
||||||
|
if (owner < 0)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
var nameId = State.Catalog.CardOf(unit.PrintingId).NameId;
|
||||||
|
if (!_scripts.TryGetValue(nameId, out var script))
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
var context = new AbilityContext
|
||||||
|
{
|
||||||
|
Engine = this,
|
||||||
|
Source = unit,
|
||||||
|
Owner = owner,
|
||||||
|
Phase = State.Phase,
|
||||||
|
};
|
||||||
|
|
||||||
|
return script.Continuous
|
||||||
|
.Where(c => c.Applies(context))
|
||||||
|
.Sum(c => c.PowerBonus?.Invoke(context) ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int OwnerOf(CardInstance unit)
|
||||||
|
{
|
||||||
|
for (var i = 0; i < 2; i++)
|
||||||
|
{
|
||||||
|
if (ReferenceEquals(State.Players[i].Vanguard, unit))
|
||||||
|
return i;
|
||||||
|
|
||||||
|
if (State.Players[i].RearGuards.Any(u => ReferenceEquals(u, unit)))
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsVanguardOf(CardInstance unit, int player) =>
|
||||||
|
ReferenceEquals(State.Players[player].Vanguard, unit);
|
||||||
|
|
||||||
|
public int CountRearGuards(int player, Clan clan) =>
|
||||||
|
State.Players[player].RearGuards.Count(u =>
|
||||||
|
u is not null && State.Catalog.CardOf(u.PrintingId).Clan == clan);
|
||||||
|
|
||||||
|
/// <summary>Призыв карты из души — используется способностями вроде Barcgal.</summary>
|
||||||
|
public bool CallFromSoul(CardInstance card, int player)
|
||||||
|
{
|
||||||
|
var p = State.Players[player];
|
||||||
|
if (!p.Soul.Contains(card))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var slot = Array.FindIndex(p.RearGuards, u => u is null);
|
||||||
|
if (slot < 0)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
p.Soul.Remove(card);
|
||||||
|
card.Rested = true;
|
||||||
|
p.RearGuards[slot] = card;
|
||||||
|
|
||||||
|
_events.Add(new UnitCalledEvent(player, slot, card.PrintingId));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Бонус к силе текущего боя от способности карты.</summary>
|
||||||
|
public void AddBattlePower(int amount)
|
||||||
|
{
|
||||||
|
if (State.Battle is { } battle)
|
||||||
|
battle.AbilityPower += amount;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- старт партии ----------
|
// ---------- старт партии ----------
|
||||||
|
|
||||||
public void Start()
|
public void Start()
|
||||||
|
|
@ -130,6 +330,8 @@ public sealed class GameEngine
|
||||||
InterceptCommand c => Intercept(c.Slot),
|
InterceptCommand c => Intercept(c.Slot),
|
||||||
PassGuardCommand => PassGuard(),
|
PassGuardCommand => PassGuard(),
|
||||||
ResolveTriggerCommand c => ResolveTrigger(c),
|
ResolveTriggerCommand c => ResolveTrigger(c),
|
||||||
|
SelectTriggerCommand c => SelectTrigger(c.Index),
|
||||||
|
ResolveAbilityCommand c => ResolvePendingAbility(c.Accept),
|
||||||
EndTurnCommand => EndTurn(),
|
EndTurnCommand => EndTurn(),
|
||||||
_ => CommandResult.Reject("Неизвестная команда."),
|
_ => CommandResult.Reject("Неизвестная команда."),
|
||||||
};
|
};
|
||||||
|
|
@ -187,10 +389,11 @@ public sealed class GameEngine
|
||||||
if (def.Grade != current && def.Grade != current + 1)
|
if (def.Grade != current && def.Grade != current + 1)
|
||||||
return CommandResult.Reject($"Нельзя совершить смену с {current}-го ранга на {def.Grade}-й.");
|
return CommandResult.Reject($"Нельзя совершить смену с {current}-го ранга на {def.Grade}-й.");
|
||||||
|
|
||||||
if (player.Vanguard is not null)
|
var riddenOver = player.Vanguard;
|
||||||
|
if (riddenOver is not null)
|
||||||
{
|
{
|
||||||
player.Vanguard.Rested = false;
|
riddenOver.Rested = false;
|
||||||
player.Soul.Add(player.Vanguard);
|
player.Soul.Add(riddenOver);
|
||||||
}
|
}
|
||||||
|
|
||||||
player.Hand.RemoveAt(index);
|
player.Hand.RemoveAt(index);
|
||||||
|
|
@ -201,6 +404,8 @@ 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, riddenOver, AbilityTrigger.OnRideOver, card);
|
||||||
|
|
||||||
return CommandResult.Ok();
|
return CommandResult.Ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -342,6 +547,8 @@ public sealed class GameEngine
|
||||||
booster.Rested = true;
|
booster.Rested = true;
|
||||||
State.Battle.BoostPower += State.Catalog.CardOf(booster.PrintingId).Power;
|
State.Battle.BoostPower += State.Catalog.CardOf(booster.PrintingId).Power;
|
||||||
_events.Add(new BoostedEvent(attackerPlayer, boosterSlot, attackerSlot));
|
_events.Add(new BoostedEvent(attackerPlayer, boosterSlot, attackerSlot));
|
||||||
|
|
||||||
|
TriggerFromCard(attackerPlayer, booster, AbilityTrigger.OnBoost, attacker);
|
||||||
}
|
}
|
||||||
|
|
||||||
State.Step = Step.Guard;
|
State.Step = Step.Guard;
|
||||||
|
|
@ -688,10 +895,9 @@ public sealed class GameEngine
|
||||||
|
|
||||||
var attackerDef = State.Catalog.CardOf(attacker.PrintingId);
|
var attackerDef = State.Catalog.CardOf(attacker.PrintingId);
|
||||||
var defenseUnit = battle.Interceptor ?? target;
|
var defenseUnit = battle.Interceptor ?? target;
|
||||||
var defenseDef = State.Catalog.CardOf(defenseUnit.PrintingId);
|
|
||||||
|
|
||||||
var attackPower = attackerDef.Power + attacker.TemporaryPower + battle.BoostPower;
|
var attackPower = EffectivePower(attacker) + battle.BoostPower + battle.AbilityPower;
|
||||||
var defensePower = defenseDef.Power + defenseUnit.TemporaryPower + battle.GuardShield;
|
var defensePower = EffectivePower(defenseUnit) + battle.GuardShield;
|
||||||
var hit = !battle.Nullified && attackPower > defensePower;
|
var hit = !battle.Nullified && attackPower > defensePower;
|
||||||
|
|
||||||
_events.Add(new BattleResolvedEvent(hit));
|
_events.Add(new BattleResolvedEvent(hit));
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using Vanguard.Rules.Cards;
|
using Vanguard.Rules.Cards;
|
||||||
|
using Vanguard.Rules.Engine.Abilities;
|
||||||
|
|
||||||
namespace Vanguard.Rules.Engine;
|
namespace Vanguard.Rules.Engine;
|
||||||
|
|
||||||
|
|
@ -25,3 +26,5 @@ public sealed record DamageTakenEvent(int Player, int Amount, int Total) : GameE
|
||||||
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 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 AbilityResolvedEvent(int Player, string PrintingId, bool Accepted) : GameEvent;
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ public enum Step
|
||||||
Guard,
|
Guard,
|
||||||
Drive,
|
Drive,
|
||||||
Trigger,
|
Trigger,
|
||||||
|
Ability,
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum TriggerContext
|
public enum TriggerContext
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using Vanguard.Rules.Cards;
|
using Vanguard.Rules.Cards;
|
||||||
|
using Vanguard.Rules.Engine.Abilities;
|
||||||
|
|
||||||
namespace Vanguard.Rules.Engine;
|
namespace Vanguard.Rules.Engine;
|
||||||
|
|
||||||
|
|
@ -23,6 +24,9 @@ public sealed class BattleState
|
||||||
|
|
||||||
/// <summary>Сила карт поддержки, действует только в этом бою.</summary>
|
/// <summary>Сила карт поддержки, действует только в этом бою.</summary>
|
||||||
public int BoostPower { get; set; }
|
public int BoostPower { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Сила от способностей карт, действует только в этом бою.</summary>
|
||||||
|
public int AbilityPower { get; set; }
|
||||||
public List<CardInstance> GuardCards { get; } = new();
|
public List<CardInstance> GuardCards { get; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -50,6 +54,12 @@ public sealed class GameState
|
||||||
public BattleState? Battle { get; internal set; }
|
public BattleState? Battle { get; internal set; }
|
||||||
public PendingTrigger? PendingTrigger { get; internal set; }
|
public PendingTrigger? PendingTrigger { get; internal set; }
|
||||||
|
|
||||||
|
/// <summary>Способность ждёт решения «да / нет».</summary>
|
||||||
|
public TriggeredAbility? PendingAbility { get; internal set; }
|
||||||
|
|
||||||
|
/// <summary>Сработавшие способности ждут выбора порядка.</summary>
|
||||||
|
public List<TriggeredAbility>? PendingOrder { 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; }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,9 @@ public class GameEngineTests
|
||||||
private const string Iseult = "BT01/011EN"; // Sentinel, Royal Paladin
|
private const string Iseult = "BT01/011EN"; // Sentinel, Royal Paladin
|
||||||
private const string Nehalem = "BT01/022EN"; // Kagero
|
private const string Nehalem = "BT01/022EN"; // Kagero
|
||||||
private const string Overlord = "BT01/004EN"; // Kagero, 11000
|
private const string Overlord = "BT01/004EN"; // Kagero, 11000
|
||||||
|
private const string Conroe = "BT01/016EN"; // Kagero, grade 0 без триггера
|
||||||
|
private const string BlasterBlade = "BT01/002EN";
|
||||||
|
private const string Wingal = "BT01/044EN";
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Start_Places_Starting_Vanguard_And_Draws_Five()
|
public void Start_Places_Starting_Vanguard_And_Draws_Five()
|
||||||
|
|
@ -43,7 +46,7 @@ public class GameEngineTests
|
||||||
var result = engine.Execute(new RideCommand(Alfred));
|
var result = engine.Execute(new RideCommand(Alfred));
|
||||||
|
|
||||||
Assert.False(result.Accepted);
|
Assert.False(result.Accepted);
|
||||||
Assert.Equal(Barcgal, engine.State.Players[0].Vanguard!.PrintingId);
|
Assert.Equal(Conroe, engine.State.Players[0].Vanguard!.PrintingId);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -56,7 +59,7 @@ public class GameEngineTests
|
||||||
|
|
||||||
Assert.True(result.Accepted);
|
Assert.True(result.Accepted);
|
||||||
Assert.Equal(Marron, engine.State.Players[0].Vanguard!.PrintingId);
|
Assert.Equal(Marron, engine.State.Players[0].Vanguard!.PrintingId);
|
||||||
Assert.Contains(engine.State.Players[0].Soul, c => c.PrintingId == Barcgal);
|
Assert.Contains(engine.State.Players[0].Soul, c => c.PrintingId == Conroe);
|
||||||
Assert.Equal(Phase.Main, engine.State.Phase);
|
Assert.Equal(Phase.Main, engine.State.Phase);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -539,6 +542,72 @@ public class GameEngineTests
|
||||||
Assert.Single(engine.Events.OfType<DriveCheckedEvent>());
|
Assert.Single(engine.Events.OfType<DriveCheckedEvent>());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Barcgal_Offers_Optional_Call_When_Ridden_Over()
|
||||||
|
{
|
||||||
|
var engine = StartedEngine();
|
||||||
|
SetVanguard(engine, 0, Barcgal);
|
||||||
|
Give(engine, 0, Marron);
|
||||||
|
|
||||||
|
Assert.True(engine.Execute(new RideCommand(Marron)).Accepted);
|
||||||
|
Assert.NotNull(engine.State.PendingAbility);
|
||||||
|
Assert.Equal(Step.Ability, engine.State.Step);
|
||||||
|
|
||||||
|
Assert.True(engine.Execute(new ResolveAbilityCommand(true)).Accepted);
|
||||||
|
|
||||||
|
Assert.Null(engine.State.PendingAbility);
|
||||||
|
Assert.Contains(engine.State.Players[0].RearGuards, u => u?.PrintingId == Barcgal);
|
||||||
|
Assert.DoesNotContain(engine.State.Players[0].Soul, c => c.PrintingId == Barcgal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Barcgal_Decline_Keeps_Card_In_Soul()
|
||||||
|
{
|
||||||
|
var engine = StartedEngine();
|
||||||
|
SetVanguard(engine, 0, Barcgal);
|
||||||
|
Give(engine, 0, Marron);
|
||||||
|
|
||||||
|
engine.Execute(new RideCommand(Marron));
|
||||||
|
Assert.True(engine.Execute(new ResolveAbilityCommand(false)).Accepted);
|
||||||
|
|
||||||
|
Assert.Contains(engine.State.Players[0].Soul, c => c.PrintingId == Barcgal);
|
||||||
|
Assert.DoesNotContain(engine.State.Players[0].RearGuards, u => u?.PrintingId == Barcgal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Wingal_Gives_BlasterBlade_4000_During_Battle()
|
||||||
|
{
|
||||||
|
var engine = ReadyToAttackWithWingal(BlasterBlade);
|
||||||
|
|
||||||
|
Assert.True(engine.Execute(new AttackCommand(3, -1, 0)).Accepted);
|
||||||
|
Assert.Equal(4000, engine.State.Battle!.AbilityPower);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Wingal_Does_Not_Boost_Other_Units()
|
||||||
|
{
|
||||||
|
var engine = ReadyToAttackWithWingal(Gallatin);
|
||||||
|
|
||||||
|
Assert.True(engine.Execute(new AttackCommand(3, -1, 0)).Accepted);
|
||||||
|
Assert.Equal(0, engine.State.Battle!.AbilityPower);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Alfred_Gets_2000_Per_RoyalPaladin_RearGuard()
|
||||||
|
{
|
||||||
|
var engine = StartedEngine();
|
||||||
|
engine.Execute(new SkipRideCommand());
|
||||||
|
SetVanguard(engine, 0, Alfred);
|
||||||
|
|
||||||
|
Give(engine, 0, Marron);
|
||||||
|
engine.Execute(new CallCommand(Marron, 0));
|
||||||
|
Give(engine, 0, Marron);
|
||||||
|
engine.Execute(new CallCommand(Marron, 3));
|
||||||
|
|
||||||
|
var alfred = engine.State.Players[0].Vanguard!;
|
||||||
|
Assert.Equal(14000, engine.EffectivePower(alfred));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ResolveTrigger_Rejects_When_Nothing_Is_Pending()
|
public void ResolveTrigger_Rejects_When_Nothing_Is_Pending()
|
||||||
{
|
{
|
||||||
|
|
@ -578,6 +647,9 @@ public class GameEngineTests
|
||||||
var engine = NewEngine();
|
var engine = NewEngine();
|
||||||
engine.Start();
|
engine.Start();
|
||||||
engine.Execute(new FinishMulliganCommand());
|
engine.Execute(new FinishMulliganCommand());
|
||||||
|
|
||||||
|
// фиксируем стартовый Авангард, чтобы Barcgal не срабатывал в обычных тестах
|
||||||
|
SetVanguard(engine, 0, Conroe);
|
||||||
return engine;
|
return engine;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -597,6 +669,24 @@ public class GameEngineTests
|
||||||
return engine;
|
return engine;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private GameEngine ReadyToAttackWithWingal(string attackerPrintingId)
|
||||||
|
{
|
||||||
|
var engine = StartedEngine();
|
||||||
|
engine.Execute(new SkipRideCommand());
|
||||||
|
|
||||||
|
Give(engine, 0, attackerPrintingId);
|
||||||
|
engine.Execute(new CallCommand(attackerPrintingId, 3));
|
||||||
|
|
||||||
|
Give(engine, 0, Wingal);
|
||||||
|
engine.Execute(new CallCommand(Wingal, 0));
|
||||||
|
|
||||||
|
engine.State.Players[0].RearGuards[0]!.Rested = false;
|
||||||
|
engine.State.Players[0].RearGuards[3]!.Rested = false;
|
||||||
|
|
||||||
|
engine.Execute(new EnterBattleCommand());
|
||||||
|
return engine;
|
||||||
|
}
|
||||||
|
|
||||||
private GameEngine ReadyToAttackWithRearGuard()
|
private GameEngine ReadyToAttackWithRearGuard()
|
||||||
{
|
{
|
||||||
var engine = StartedEngine();
|
var engine = StartedEngine();
|
||||||
|
|
@ -648,17 +738,23 @@ public class GameEngineTests
|
||||||
return engine;
|
return engine;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static readonly (string PrintingId, int Count)[] Seeds =
|
||||||
|
{
|
||||||
|
(Barcgal, 4), (Marron, 4), (Gallatin, 4), (Alfred, 4),
|
||||||
|
(Nike, 2), (Flogal, 2), (Govannon, 2),
|
||||||
|
(Iseult, 2), (Nehalem, 2), (Overlord, 2), (Conroe, 2),
|
||||||
|
(BlasterBlade, 2), (Wingal, 2),
|
||||||
|
};
|
||||||
|
|
||||||
private List<string> BuildDeck()
|
private List<string> BuildDeck()
|
||||||
{
|
{
|
||||||
var mustInclude = new[] { Barcgal, Marron, Gallatin, Alfred, Nike, Flogal, Govannon, Iseult, Nehalem, Overlord };
|
var names = Seeds
|
||||||
var names = mustInclude
|
.Select(s => _catalog.CardOf(s.PrintingId).NameId)
|
||||||
.Select(_catalog.CardOf)
|
|
||||||
.Select(c => c.NameId)
|
|
||||||
.ToHashSet(StringComparer.Ordinal);
|
.ToHashSet(StringComparer.Ordinal);
|
||||||
|
|
||||||
var deck = new List<string>();
|
var deck = new List<string>();
|
||||||
foreach (var printing in mustInclude)
|
foreach (var (printingId, count) in Seeds)
|
||||||
deck.AddRange(Enumerable.Repeat(printing, 4));
|
deck.AddRange(Enumerable.Repeat(printingId, count));
|
||||||
|
|
||||||
foreach (var group in _catalog.Printings
|
foreach (var group in _catalog.Printings
|
||||||
.Where(p => !p.IsParallel)
|
.Where(p => !p.IsParallel)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue