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 как отдельные интенты.
|
||||
- [x] Число проверок принадлежит атакующей карте: 0–2 ранг — одна, 3+ — две; Арьергард по умолчанию без проверок; `DriveChecksOverride` меняет число (в том числе снимает двойную).
|
||||
- [x] Результат боя: `Hit` / `Guarded` как событие.
|
||||
- [ ] Событийная шина и три обработчика: Continuous / Activated / Triggered.
|
||||
- [ ] Check Timing: сбор → сортировка → разрешение по одному → повтор.
|
||||
- [x] Событийная шина: `Triggered` (OnRideOver, OnBoost), `Continuous` с пересчётом при запросе; `Activated` ещё нет.
|
||||
- [x] Check Timing: сбор сработавших, порядок (активный игрок первым, выбор игрока), пауза на опциональные способности.
|
||||
- [ ] `Activated`-способности и выбор целей игроком.
|
||||
- [ ] Стоимости: CB, SB, discard, rest self, retire self, put to soul, return to deck.
|
||||
- [ ] Цели: авто по фильтру и выбор игроком, «до 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 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 EndTurnCommand : GameCommand;
|
||||
|
||||
public sealed record CommandResult(bool Accepted, string? Reason)
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
using Vanguard.Rules.Cards;
|
||||
using Vanguard.Rules.Decks;
|
||||
using Vanguard.Rules.Engine.Abilities;
|
||||
|
||||
namespace Vanguard.Rules.Engine;
|
||||
|
||||
/// <summary>
|
||||
/// Ядро партии. Не знает ни о сети, ни о БД: на вход команды, на выход события.
|
||||
///
|
||||
/// Упрощения первого среза (карты со способностями подключаются следующим шагом):
|
||||
/// Что реализовано и какие есть упрощения:
|
||||
/// * идеальная защита (Sentinel): щит 0, но при оплате стоимости (сброс карты того же клана)
|
||||
/// атака полностью отменяется; без оплаты не даёт ничего;
|
||||
/// * поддержка работает как отдельная способность: карты 0 и 1 ранга могут передать
|
||||
|
|
@ -17,6 +18,10 @@ namespace Vanguard.Rules.Engine;
|
|||
/// из заднего ряда — только если эффект разрешил (InterceptFromBackRow);
|
||||
/// * число проверок принадлежит атакующей карте: 0–2 ранг — одна, 3+ — две;
|
||||
/// Арьергард по умолчанию без проверок, но эффект может их выдать;
|
||||
/// * способности карт подключаются скриптами (CardScript): Triggered и Continuous;
|
||||
/// Check Timing собирает сработавшие, активный игрок выбирает порядок,
|
||||
/// опциональные способности ждут ResolveAbilityCommand;
|
||||
/// * непрерывные эффекты пересчитываются при каждом запросе (EffectivePower);
|
||||
/// * триггер требует выбора целей через ResolveTriggerCommand:
|
||||
/// +5000 любой карте в кругах Авангарда или Арьергарда, затем второй эффект;
|
||||
/// правило одинаково для drive check и damage check;
|
||||
|
|
@ -30,8 +35,14 @@ public sealed class GameEngine
|
|||
public const int TriggerPowerBonus = 5000;
|
||||
|
||||
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())
|
||||
.Concat(DeckValidator.Validate(catalog, deck1.ToList()))
|
||||
|
|
@ -41,6 +52,7 @@ public sealed class GameEngine
|
|||
throw new InvalidOperationException(
|
||||
"Некорректная колода: " + string.Join("; ", violations.Select(v => v.Message)));
|
||||
|
||||
_scripts = scripts ?? CardScripts.Default;
|
||||
State = new GameState(catalog, seed, "Player 1", "Player 2");
|
||||
FillDeck(0, deck0);
|
||||
FillDeck(1, deck1);
|
||||
|
|
@ -48,6 +60,7 @@ public sealed class GameEngine
|
|||
|
||||
public GameState State { get; }
|
||||
public IReadOnlyList<GameEvent> Events => _events;
|
||||
public IReadOnlyDictionary<string, CardScript> Scripts => _scripts;
|
||||
|
||||
/// <summary>
|
||||
/// Сколько проверок делает атакующая карта. Эффект может изменить число;
|
||||
|
|
@ -61,6 +74,193 @@ public sealed class GameEngine
|
|||
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()
|
||||
|
|
@ -130,6 +330,8 @@ public sealed class GameEngine
|
|||
InterceptCommand c => Intercept(c.Slot),
|
||||
PassGuardCommand => PassGuard(),
|
||||
ResolveTriggerCommand c => ResolveTrigger(c),
|
||||
SelectTriggerCommand c => SelectTrigger(c.Index),
|
||||
ResolveAbilityCommand c => ResolvePendingAbility(c.Accept),
|
||||
EndTurnCommand => EndTurn(),
|
||||
_ => CommandResult.Reject("Неизвестная команда."),
|
||||
};
|
||||
|
|
@ -187,10 +389,11 @@ public sealed class GameEngine
|
|||
if (def.Grade != current && def.Grade != current + 1)
|
||||
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;
|
||||
player.Soul.Add(player.Vanguard);
|
||||
riddenOver.Rested = false;
|
||||
player.Soul.Add(riddenOver);
|
||||
}
|
||||
|
||||
player.Hand.RemoveAt(index);
|
||||
|
|
@ -201,6 +404,8 @@ public sealed class GameEngine
|
|||
State.Phase = Phase.Main;
|
||||
_events.Add(new PhaseChangedEvent(State.ActivePlayer, Phase.Main));
|
||||
|
||||
TriggerFromCard(State.ActivePlayer, riddenOver, AbilityTrigger.OnRideOver, card);
|
||||
|
||||
return CommandResult.Ok();
|
||||
}
|
||||
|
||||
|
|
@ -342,6 +547,8 @@ public sealed class GameEngine
|
|||
booster.Rested = true;
|
||||
State.Battle.BoostPower += State.Catalog.CardOf(booster.PrintingId).Power;
|
||||
_events.Add(new BoostedEvent(attackerPlayer, boosterSlot, attackerSlot));
|
||||
|
||||
TriggerFromCard(attackerPlayer, booster, AbilityTrigger.OnBoost, attacker);
|
||||
}
|
||||
|
||||
State.Step = Step.Guard;
|
||||
|
|
@ -688,10 +895,9 @@ public sealed class GameEngine
|
|||
|
||||
var attackerDef = State.Catalog.CardOf(attacker.PrintingId);
|
||||
var defenseUnit = battle.Interceptor ?? target;
|
||||
var defenseDef = State.Catalog.CardOf(defenseUnit.PrintingId);
|
||||
|
||||
var attackPower = attackerDef.Power + attacker.TemporaryPower + battle.BoostPower;
|
||||
var defensePower = defenseDef.Power + defenseUnit.TemporaryPower + battle.GuardShield;
|
||||
var attackPower = EffectivePower(attacker) + battle.BoostPower + battle.AbilityPower;
|
||||
var defensePower = EffectivePower(defenseUnit) + battle.GuardShield;
|
||||
var hit = !battle.Nullified && attackPower > defensePower;
|
||||
|
||||
_events.Add(new BattleResolvedEvent(hit));
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using Vanguard.Rules.Cards;
|
||||
using Vanguard.Rules.Engine.Abilities;
|
||||
|
||||
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 UnitRetiredEvent(int Player, int Slot) : 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,
|
||||
Drive,
|
||||
Trigger,
|
||||
Ability,
|
||||
}
|
||||
|
||||
public enum TriggerContext
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using Vanguard.Rules.Cards;
|
||||
using Vanguard.Rules.Engine.Abilities;
|
||||
|
||||
namespace Vanguard.Rules.Engine;
|
||||
|
||||
|
|
@ -23,6 +24,9 @@ public sealed class BattleState
|
|||
|
||||
/// <summary>Сила карт поддержки, действует только в этом бою.</summary>
|
||||
public int BoostPower { get; set; }
|
||||
|
||||
/// <summary>Сила от способностей карт, действует только в этом бою.</summary>
|
||||
public int AbilityPower { get; set; }
|
||||
public List<CardInstance> GuardCards { get; } = new();
|
||||
}
|
||||
|
||||
|
|
@ -50,6 +54,12 @@ public sealed class GameState
|
|||
public BattleState? Battle { 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 int? Winner { get; internal set; }
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,9 @@ public class GameEngineTests
|
|||
private const string Iseult = "BT01/011EN"; // Sentinel, Royal Paladin
|
||||
private const string Nehalem = "BT01/022EN"; // Kagero
|
||||
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]
|
||||
public void Start_Places_Starting_Vanguard_And_Draws_Five()
|
||||
|
|
@ -43,7 +46,7 @@ public class GameEngineTests
|
|||
var result = engine.Execute(new RideCommand(Alfred));
|
||||
|
||||
Assert.False(result.Accepted);
|
||||
Assert.Equal(Barcgal, engine.State.Players[0].Vanguard!.PrintingId);
|
||||
Assert.Equal(Conroe, engine.State.Players[0].Vanguard!.PrintingId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -56,7 +59,7 @@ public class GameEngineTests
|
|||
|
||||
Assert.True(result.Accepted);
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
@ -539,6 +542,72 @@ public class GameEngineTests
|
|||
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]
|
||||
public void ResolveTrigger_Rejects_When_Nothing_Is_Pending()
|
||||
{
|
||||
|
|
@ -578,6 +647,9 @@ public class GameEngineTests
|
|||
var engine = NewEngine();
|
||||
engine.Start();
|
||||
engine.Execute(new FinishMulliganCommand());
|
||||
|
||||
// фиксируем стартовый Авангард, чтобы Barcgal не срабатывал в обычных тестах
|
||||
SetVanguard(engine, 0, Conroe);
|
||||
return engine;
|
||||
}
|
||||
|
||||
|
|
@ -597,6 +669,24 @@ public class GameEngineTests
|
|||
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()
|
||||
{
|
||||
var engine = StartedEngine();
|
||||
|
|
@ -648,17 +738,23 @@ public class GameEngineTests
|
|||
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()
|
||||
{
|
||||
var mustInclude = new[] { Barcgal, Marron, Gallatin, Alfred, Nike, Flogal, Govannon, Iseult, Nehalem, Overlord };
|
||||
var names = mustInclude
|
||||
.Select(_catalog.CardOf)
|
||||
.Select(c => c.NameId)
|
||||
var names = Seeds
|
||||
.Select(s => _catalog.CardOf(s.PrintingId).NameId)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
var deck = new List<string>();
|
||||
foreach (var printing in mustInclude)
|
||||
deck.AddRange(Enumerable.Repeat(printing, 4));
|
||||
foreach (var (printingId, count) in Seeds)
|
||||
deck.AddRange(Enumerable.Repeat(printingId, count));
|
||||
|
||||
foreach (var group in _catalog.Printings
|
||||
.Where(p => !p.IsParallel)
|
||||
|
|
|
|||
Loading…
Reference in New Issue