2785 lines
98 KiB
C#
2785 lines
98 KiB
C#
using Vanguard.Rules.Cards;
|
||
using Vanguard.Rules.Decks;
|
||
using Vanguard.Rules.Engine.Abilities;
|
||
|
||
namespace Vanguard.Rules.Engine;
|
||
|
||
/// <summary>
|
||
/// Ядро партии. Не знает ни о сети, ни о БД: на вход команды, на выход события.
|
||
///
|
||
/// Что реализовано и какие есть упрощения:
|
||
/// * идеальная защита (Sentinel): щит 0, но при оплате стоимости (сброс карты того же клана)
|
||
/// атака полностью отменяется; без оплаты не даёт ничего;
|
||
/// * поддержка работает как отдельная способность: карты 0 и 1 ранга могут передать
|
||
/// свою силу атакующему Арьергарду из той же колонны, сила действует только в этом бою;
|
||
/// * перехват — отдельная способность карт 2 ранга: карта из переднего ряда выходит
|
||
/// в круг защиты и при атаке по Авангарду, и при атаке по Арьергарду;
|
||
/// атакованный Арьергард не может перехватить атаку на самого себя;
|
||
/// из заднего ряда — только если эффект разрешил (InterceptFromBackRow);
|
||
/// * число проверок принадлежит атакующей карте: 0–2 ранг — одна, 3+ — две;
|
||
/// Арьергард по умолчанию без проверок, но эффект может их выдать;
|
||
/// * модификаторы силы и крита хранят источник и срок жизни (бой / ход / постоянно);
|
||
/// способности можно выдавать эффектом и снимать по источнику;
|
||
/// * эффекты попадания срабатывают после нанесения урона и помещения карты в зону урона;
|
||
/// * эффекты не вкладываются друг в друга: триггеры, поднятые во время эффекта,
|
||
/// копятся в очереди и разрешаются после него;
|
||
/// * непрерывные эффекты пересчитываются при каждом запросе (EffectivePower);
|
||
/// * триггер требует выбора целей через ResolveTriggerCommand:
|
||
/// +5000 любой карте в кругах Авангарда или Арьергарда, затем второй эффект;
|
||
/// правило одинаково для drive check и damage check;
|
||
/// * порядок проверки: карта → зона триггера → эффекты → рука (drive) или урон (damage);
|
||
/// * усиления от триггеров живут до конца хода;
|
||
/// * атаковать можно Авангард или любой круг Арьергарда: деления на передний
|
||
/// и задний ряд при выборе цели пока нет (поэтому Tejas не выделяется);
|
||
/// * задний ряд защищён только перехватом, отдельного правила «атакуют лишь передний ряд» нет.
|
||
/// </summary>
|
||
public sealed class GameEngine
|
||
{
|
||
public const int StartingHand = 5;
|
||
public const int MaxDamage = 6;
|
||
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,
|
||
IReadOnlyDictionary<string, CardScript>? scripts = null,
|
||
string player0Name = "Player 1",
|
||
string player1Name = "Player 2")
|
||
{
|
||
var violations = DeckValidator.Validate(catalog, deck0.ToList())
|
||
.Concat(DeckValidator.Validate(catalog, deck1.ToList()))
|
||
.ToList();
|
||
|
||
if (violations.Count > 0)
|
||
throw new InvalidOperationException(
|
||
"Некорректная колода: " + string.Join("; ", violations.Select(v => v.Message)));
|
||
|
||
_scripts = scripts ?? CardScripts.Default;
|
||
State = new GameState(catalog, seed, player0Name, player1Name);
|
||
FillDeck(0, deck0);
|
||
FillDeck(1, deck1);
|
||
}
|
||
|
||
public GameState State { get; }
|
||
public IReadOnlyList<GameEvent> Events => _events;
|
||
public IReadOnlyDictionary<string, CardScript> Scripts => _scripts;
|
||
|
||
/// <summary>Глубина разрешения эффекта: пока > 0, новые триггеры только ставятся в очередь.</summary>
|
||
private int _resolvingDepth;
|
||
private int _counterBlastDisplayIndex = -1;
|
||
|
||
/// <summary>
|
||
/// Сколько проверок делает атакующая карта. Эффект может изменить число;
|
||
/// Арьергард по умолчанию проверок не делает.
|
||
/// </summary>
|
||
public int DriveChecksFor(int slot, CardInstance attacker)
|
||
{
|
||
if (attacker.DriveChecksOverride is { } overridden)
|
||
return Math.Max(0, overridden);
|
||
|
||
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 context = new AbilityContext
|
||
{
|
||
Engine = this,
|
||
Source = source,
|
||
Owner = owner,
|
||
Other = other,
|
||
Phase = State.Phase,
|
||
};
|
||
|
||
var triggered = AbilitiesOf(source)
|
||
.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;
|
||
|
||
RaiseBatch(triggered);
|
||
}
|
||
|
||
/// <summary>Печатные способности карты плюс выданные эффектами.</summary>
|
||
private IEnumerable<AbilityDefinition> AbilitiesOf(CardInstance unit)
|
||
{
|
||
var nameId = State.Catalog.CardOf(unit.PrintingId).NameId;
|
||
if (_scripts.TryGetValue(nameId, out var script))
|
||
{
|
||
foreach (var ability in script.Abilities)
|
||
yield return ability;
|
||
}
|
||
|
||
foreach (var granted in unit.GrantedAbilities)
|
||
yield return granted.Definition;
|
||
}
|
||
|
||
private void RaiseBatch(List<TriggeredAbility> triggered)
|
||
{
|
||
if (triggered.Count == 0)
|
||
return;
|
||
|
||
// внутри другого эффекта триггеры только копятся
|
||
if (_resolvingDepth > 0)
|
||
{
|
||
State.PendingQueue ??= new List<TriggeredAbility>();
|
||
State.PendingQueue.AddRange(triggered);
|
||
return;
|
||
}
|
||
|
||
if (triggered.Count == 1)
|
||
{
|
||
OfferAbility(triggered[0]);
|
||
return;
|
||
}
|
||
|
||
State.PendingOrder = triggered;
|
||
State.Step = Step.Ability;
|
||
State.ActivePlayer = triggered[0].Owner;
|
||
}
|
||
|
||
/// <summary>Собирает срабатывания со всех карт на поле, активный игрок первым.</summary>
|
||
private void TriggerAll(AbilityTrigger trigger) {
|
||
var triggered = new List<TriggeredAbility>();
|
||
|
||
for (var owner = 0; owner < 2; owner++)
|
||
{
|
||
foreach (var unit in AllUnits(owner))
|
||
{
|
||
var context = new AbilityContext
|
||
{
|
||
Engine = this,
|
||
Source = unit,
|
||
Owner = owner,
|
||
Phase = State.Phase,
|
||
};
|
||
|
||
triggered.AddRange(AbilitiesOf(unit)
|
||
.Where(a => a.Trigger == trigger && (a.Condition is null || a.Condition(context)))
|
||
.Select(a => new TriggeredAbility { Definition = a, Context = context }));
|
||
}
|
||
}
|
||
|
||
if (triggered.Count == 0)
|
||
return;
|
||
|
||
triggered = triggered
|
||
.OrderBy(t => t.Owner == State.ActivePlayer ? 0 : 1)
|
||
.ToList();
|
||
|
||
RaiseBatch(triggered);
|
||
}
|
||
|
||
/// <summary>
|
||
/// «Когда Арьергард противника ушёл в сброс»: срабатывания карт на поле
|
||
/// противоположной стороны. В руке такие способности пока не ищутся.
|
||
/// </summary>
|
||
private void TriggerOpponentRetire(int retiredOwner)
|
||
{
|
||
var owner = 1 - retiredOwner;
|
||
var triggered = new List<TriggeredAbility>();
|
||
|
||
foreach (var unit in AllUnits(owner))
|
||
{
|
||
var context = new AbilityContext
|
||
{
|
||
Engine = this,
|
||
Source = unit,
|
||
Owner = owner,
|
||
Phase = State.Phase,
|
||
};
|
||
|
||
triggered.AddRange(AbilitiesOf(unit)
|
||
.Where(a => a.Trigger == AbilityTrigger.OnOpponentRetire
|
||
&& (a.Condition is null || a.Condition(context)))
|
||
.Select(a => new TriggeredAbility { Definition = a, Context = context }));
|
||
}
|
||
|
||
// способности из руки (Yaksha): «покажи и соверши райд»
|
||
foreach (var card in State.Players[owner].Hand)
|
||
{
|
||
var context = new AbilityContext
|
||
{
|
||
Engine = this,
|
||
Source = card,
|
||
Owner = owner,
|
||
Phase = State.Phase,
|
||
};
|
||
|
||
triggered.AddRange(AbilitiesOf(card)
|
||
.Where(a => a.Trigger == AbilityTrigger.OnOpponentRetire
|
||
&& (a.Condition is null || a.Condition(context)))
|
||
.Select(a => new TriggeredAbility { Definition = a, Context = context }));
|
||
}
|
||
|
||
if (triggered.Count == 0)
|
||
return;
|
||
|
||
RaiseBatch(triggered);
|
||
}
|
||
|
||
private void EnterMainPhase()
|
||
{
|
||
State.Phase = Phase.Main;
|
||
_events.Add(new PhaseChangedEvent(State.ActivePlayer, Phase.Main));
|
||
TriggerAll(AbilityTrigger.OnPhaseStart);
|
||
}
|
||
|
||
private enum OfferResult
|
||
{
|
||
Skipped,
|
||
Paused,
|
||
Resolved,
|
||
}
|
||
|
||
private OfferResult OfferAbility(TriggeredAbility ability)
|
||
{
|
||
var definition = ability.Definition;
|
||
|
||
// если стоимость оплатить нельзя, способность не предлагается
|
||
if (definition.CanPay is not null && !definition.CanPay(ability.Context))
|
||
return OfferResult.Skipped;
|
||
|
||
_events.Add(new AbilityTriggeredEvent(ability.Owner, ability.Context.Source.PrintingId, definition.Trigger));
|
||
|
||
if (definition.Optional)
|
||
{
|
||
State.PendingAbility = ability;
|
||
State.Step = Step.Ability;
|
||
State.ActivePlayer = ability.Owner;
|
||
return OfferResult.Paused;
|
||
}
|
||
|
||
ResolveAbility(ability, accepted: true);
|
||
return OfferResult.Resolved;
|
||
}
|
||
|
||
private void ResolveAbility(TriggeredAbility ability, bool accepted)
|
||
{
|
||
if (accepted)
|
||
{
|
||
_resolvingDepth++;
|
||
try
|
||
{
|
||
ability.Definition.Pay?.Invoke(ability.Context);
|
||
ability.Definition.Resolve(ability.Context);
|
||
}
|
||
finally
|
||
{
|
||
_resolvingDepth--;
|
||
}
|
||
}
|
||
|
||
_events.Add(new AbilityResolvedEvent(ability.Owner, ability.Context.Source.PrintingId, accepted));
|
||
|
||
// способность попросила выбрать цель — ждём ChooseCommand
|
||
if (State.Step == Step.Choice)
|
||
return;
|
||
|
||
ContinueOrder();
|
||
}
|
||
|
||
private void ContinueOrder()
|
||
{
|
||
// сначала доигрываем триггеры, накопившиеся во время эффекта
|
||
if (State.PendingQueue is { Count: > 0 } queued
|
||
&& State.PendingOrder is null
|
||
&& State.PendingAbility is null)
|
||
{
|
||
var batch = queued.ToList();
|
||
State.PendingQueue = null;
|
||
RaiseBatch(batch);
|
||
return;
|
||
}
|
||
|
||
if (State.PendingOrder is { Count: > 0 } order)
|
||
{
|
||
if (order.Count == 1)
|
||
{
|
||
var next = order[0];
|
||
State.PendingOrder = null;
|
||
State.ActivePlayer = next.Owner;
|
||
|
||
if (OfferAbility(next) == OfferResult.Skipped)
|
||
ContinueOrder();
|
||
|
||
return;
|
||
}
|
||
|
||
State.Step = Step.Ability;
|
||
State.ActivePlayer = order[0].Owner;
|
||
return;
|
||
}
|
||
|
||
State.Step = Step.None;
|
||
|
||
if (State.Continuation is { } continuation)
|
||
{
|
||
State.Continuation = null;
|
||
continuation();
|
||
}
|
||
}
|
||
|
||
private CommandResult Activate(int instanceId, string? discardPrintingId, int damageIndex)
|
||
{
|
||
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);
|
||
var fromHand = false;
|
||
var fromDrop = false;
|
||
|
||
if (unit is null)
|
||
{
|
||
unit = p.Hand.FirstOrDefault(c => c.Id == instanceId);
|
||
fromHand = unit is not null;
|
||
}
|
||
|
||
if (unit is null)
|
||
{
|
||
unit = p.Drop.FirstOrDefault(c => c.Id == instanceId);
|
||
fromDrop = unit is not null;
|
||
}
|
||
|
||
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 candidates = script.Activated
|
||
.Where(a =>
|
||
(fromHand ? a.FromHand : fromDrop ? a.FromDropZone : !a.FromHand && !a.FromDropZone)
|
||
&& a.CanActivate(context))
|
||
.ToList();
|
||
|
||
var ability = discardPrintingId is null
|
||
? candidates.FirstOrDefault(a => !a.RequiresDiscard)
|
||
: candidates.FirstOrDefault(a => a.RequiresDiscard);
|
||
|
||
if (ability is null)
|
||
{
|
||
if (discardPrintingId is null && candidates.Any(a => a.RequiresDiscard))
|
||
return CommandResult.Reject("Нужно выбрать карту для сброса.");
|
||
|
||
if (discardPrintingId is not null && candidates.Count > 0)
|
||
return CommandResult.Reject("Эта способность не требует сброса.");
|
||
|
||
return CommandResult.Reject("Нет доступной способности.");
|
||
}
|
||
|
||
if ((fromHand || fromDrop) && (ability.RestsSelf || ability.RetiresSelf))
|
||
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);
|
||
}
|
||
|
||
if (ability.RetiresSelf)
|
||
{
|
||
var slot = Array.IndexOf(p.RearGuards, unit);
|
||
if (slot < 0)
|
||
return CommandResult.Reject("Уничтожить себя можно только из круга Арьергарда.");
|
||
|
||
p.RearGuards[slot] = null;
|
||
p.Drop.Add(unit);
|
||
_events.Add(new UnitRetiredEvent(player, slot));
|
||
}
|
||
|
||
_counterBlastDisplayIndex = damageIndex;
|
||
try
|
||
{
|
||
ability.Resolve(context);
|
||
}
|
||
finally
|
||
{
|
||
_counterBlastDisplayIndex = -1;
|
||
}
|
||
|
||
_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;
|
||
|
||
_resolvingDepth++;
|
||
try
|
||
{
|
||
choice.Resolve(option.Slot);
|
||
}
|
||
finally
|
||
{
|
||
_resolvingDepth--;
|
||
}
|
||
|
||
if (State.Step != Step.Choice)
|
||
ContinueOrder();
|
||
|
||
return CommandResult.Ok();
|
||
}
|
||
|
||
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;
|
||
|
||
if (OfferAbility(chosen) == OfferResult.Skipped)
|
||
ContinueOrder();
|
||
|
||
return CommandResult.Ok();
|
||
}
|
||
|
||
private CommandResult ResolvePendingAbility(bool accept, int damageIndex)
|
||
{
|
||
var ability = State.PendingAbility;
|
||
if (ability is null || State.Step != Step.Ability)
|
||
return CommandResult.Reject("Сейчас нет способности, ожидающей решения.");
|
||
|
||
State.PendingAbility = null;
|
||
_counterBlastDisplayIndex = damageIndex;
|
||
try
|
||
{
|
||
ResolveAbility(ability, accept);
|
||
}
|
||
finally
|
||
{
|
||
_counterBlastDisplayIndex = -1;
|
||
}
|
||
|
||
return CommandResult.Ok();
|
||
}
|
||
|
||
/// <summary>Сила карты с учётом всех модификаторов и непрерывных эффектов.</summary>
|
||
public int EffectivePower(CardInstance unit)
|
||
{
|
||
var total = State.Catalog.CardOf(unit.PrintingId).Power;
|
||
total += unit.Modifiers.Sum(m => m.Power);
|
||
return total + ContinuousPower(unit);
|
||
}
|
||
|
||
public int EffectiveCritical(CardInstance unit) =>
|
||
State.Catalog.CardOf(unit.PrintingId).Critical + unit.Modifiers.Sum(m => m.Critical);
|
||
|
||
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 = false;
|
||
p.RearGuards[slot] = card;
|
||
|
||
_events.Add(new UnitCalledEvent(player, slot, card.PrintingId));
|
||
return true;
|
||
}
|
||
|
||
/// <summary>Бонус к силе текущего боя от способности карты.</summary>
|
||
public void AddBattlePower(CardInstance source, int amount) =>
|
||
AddBattlePower(source, amount, source);
|
||
|
||
public void AddBattlePower(CardInstance target, int amount, CardInstance source) =>
|
||
AddPower(target, amount, source, "ability", ModifierDuration.UntilEndOfBattle);
|
||
|
||
/// <summary>Добавить модификатор силы и крита с указанием источника и срока жизни.</summary>
|
||
public void AddPower(
|
||
CardInstance unit,
|
||
int power,
|
||
CardInstance source,
|
||
string effectId,
|
||
ModifierDuration duration,
|
||
int critical = 0)
|
||
{
|
||
unit.Modifiers.Add(new StatModifier
|
||
{
|
||
Power = power,
|
||
Critical = critical,
|
||
Source = source,
|
||
EffectId = effectId,
|
||
Duration = duration,
|
||
});
|
||
}
|
||
|
||
/// <summary>Выдать карте способность, которой нет в её тексте.</summary>
|
||
public void GrantAbility(
|
||
CardInstance unit,
|
||
AbilityDefinition definition,
|
||
CardInstance source,
|
||
string effectId,
|
||
ModifierDuration duration)
|
||
{
|
||
unit.GrantedAbilities.Add(new GrantedAbility
|
||
{
|
||
Definition = definition,
|
||
Source = source,
|
||
EffectId = effectId,
|
||
Duration = duration,
|
||
});
|
||
}
|
||
|
||
/// <summary>Снять способности, выданные конкретным эффектом.</summary>
|
||
public void RemoveGrantedAbilities(Func<GrantedAbility, bool> predicate)
|
||
{
|
||
foreach (var unit in AllUnits(0).Concat(AllUnits(1)))
|
||
unit.GrantedAbilities.RemoveAll(g => predicate(g));
|
||
}
|
||
|
||
/// <summary>Запретить карте действие с указанием источника и срока жизни.</summary>
|
||
public void AddRestriction(
|
||
CardInstance unit,
|
||
RestrictionType type,
|
||
CardInstance source,
|
||
string effectId,
|
||
ModifierDuration duration)
|
||
{
|
||
unit.Restrictions.Add(new CardRestriction
|
||
{
|
||
Type = type,
|
||
Source = source,
|
||
EffectId = effectId,
|
||
Duration = duration,
|
||
});
|
||
}
|
||
|
||
/// <summary>Снять запреты, подходящие под условие.</summary>
|
||
public void RemoveRestrictions(Func<CardRestriction, bool> predicate)
|
||
{
|
||
foreach (var unit in AllUnits(0).Concat(AllUnits(1)))
|
||
unit.Restrictions.RemoveAll(r => predicate(r));
|
||
}
|
||
|
||
// ---------- стоимости ----------
|
||
|
||
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 damage = State.Players[player].Damage;
|
||
var flipped = 0;
|
||
|
||
// Клиент передаёт индекс в том же порядке, в котором рисует урон:
|
||
// сначала закрытые, затем открытые карты.
|
||
if (_counterBlastDisplayIndex >= 0)
|
||
{
|
||
var order = DamageDisplayOrder(player);
|
||
if (_counterBlastDisplayIndex < order.Count)
|
||
{
|
||
var chosen = order[_counterBlastDisplayIndex].Card;
|
||
if (!chosen.FaceUp)
|
||
{
|
||
chosen.FaceUp = true;
|
||
flipped++;
|
||
}
|
||
}
|
||
|
||
_counterBlastDisplayIndex = -1;
|
||
}
|
||
|
||
foreach (var card in 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 SoulCharge(int player, int amount)
|
||
{
|
||
var p = State.Players[player];
|
||
for (var i = 0; i < amount; i++)
|
||
{
|
||
if (p.Deck.Count == 0)
|
||
{
|
||
Defeat(player);
|
||
return;
|
||
}
|
||
|
||
var card = p.Deck[0];
|
||
p.Deck.RemoveAt(0);
|
||
p.Soul.Add(card);
|
||
}
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Выбор от min до max целей на поле. Игрок выбирает по одной; когда минимум
|
||
/// набран, появляется вариант «Готово». Выбранные цели приходят одним списком.
|
||
/// </summary>
|
||
public void AskUnitMultiChoice(
|
||
int chooser,
|
||
string prompt,
|
||
IReadOnlyList<UnitTarget> targets,
|
||
int min,
|
||
int max,
|
||
Action<IReadOnlyList<UnitTarget>> onChosen)
|
||
{
|
||
var chosen = new List<UnitTarget>();
|
||
|
||
void Ask()
|
||
{
|
||
var remaining = targets.Where(t => !chosen.Contains(t)).ToList();
|
||
|
||
if (remaining.Count == 0 || chosen.Count >= max)
|
||
{
|
||
onChosen(chosen.ToList());
|
||
return;
|
||
}
|
||
|
||
var options = remaining
|
||
.Select((target, index) =>
|
||
{
|
||
var unit = State.GetUnit(target.Player, target.Slot)!;
|
||
return new ChoiceOption(index, State.Catalog.CardOf(unit.PrintingId).NameEn);
|
||
})
|
||
.ToList();
|
||
|
||
// «Готово» появляется только когда минимум уже набран
|
||
var stopIndex = -1;
|
||
if (chosen.Count >= min)
|
||
{
|
||
stopIndex = options.Count;
|
||
options.Add(new ChoiceOption(stopIndex, "Готово"));
|
||
}
|
||
|
||
var currentPrompt = chosen.Count == 0
|
||
? prompt
|
||
: $"{prompt} (выбрано: {chosen.Count})";
|
||
|
||
State.PendingChoice = new PendingChoice
|
||
{
|
||
Player = chooser,
|
||
Prompt = currentPrompt,
|
||
Options = options,
|
||
Resolve = index =>
|
||
{
|
||
if (index == stopIndex)
|
||
{
|
||
onChosen(chosen.ToList());
|
||
return;
|
||
}
|
||
|
||
chosen.Add(remaining[index]);
|
||
Ask();
|
||
},
|
||
};
|
||
State.Step = Step.Choice;
|
||
State.ActivePlayer = chooser;
|
||
}
|
||
|
||
Ask();
|
||
}
|
||
|
||
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));
|
||
|
||
TriggerFromCard(player, unit, AbilityTrigger.OnRetireFromRC, null);
|
||
TriggerOpponentRetire(player);
|
||
}
|
||
|
||
// ---------- эффекты карт ----------
|
||
|
||
public int HandCount(int player) => State.Players[player].Hand.Count;
|
||
|
||
public bool HasInSoul(int player, string nameId) =>
|
||
State.Players[player].Soul.Any(c => State.Catalog.CardOf(c.PrintingId).NameId == nameId);
|
||
|
||
public bool HasInDrop(int player, string nameId) =>
|
||
State.Players[player].Drop.Any(c => State.Catalog.CardOf(c.PrintingId).NameId == nameId);
|
||
|
||
/// <summary>Сколько карт клана на поле, не считая указанную.</summary>
|
||
public int CountFieldUnits(int player, Clan clan, CardInstance? except = null) =>
|
||
AllUnits(player).Count(u =>
|
||
!ReferenceEquals(u, except)
|
||
&& State.Catalog.CardOf(u.PrintingId).Clan == clan);
|
||
|
||
public bool AllUnitsRested(int player) => AllUnits(player).All(u => u.Rested);
|
||
|
||
/// <summary>Перевернуть всю зону урона лицом вверх.</summary>
|
||
public void TurnAllDamageFaceUp(int player)
|
||
{
|
||
foreach (var card in State.Players[player].Damage)
|
||
card.FaceUp = true;
|
||
}
|
||
|
||
/// <summary>Перевернуть одну закрытую карту урона лицом вверх. false — закрытых нет.</summary>
|
||
public bool TurnOneDamageFaceUp(int player)
|
||
{
|
||
var card = State.Players[player].Damage.FirstOrDefault(c => !c.FaceUp);
|
||
if (card is null)
|
||
return false;
|
||
|
||
card.FaceUp = true;
|
||
return true;
|
||
}
|
||
|
||
public void RetireAllRearGuards(int player)
|
||
{
|
||
for (var slot = 0; slot < PlayerState.RearGuardSlots; slot++)
|
||
RetireRearGuard(player, slot);
|
||
}
|
||
|
||
public void StandAll(int player)
|
||
{
|
||
foreach (var unit in AllUnits(player))
|
||
unit.Rested = false;
|
||
}
|
||
|
||
/// <summary>Вернуть выбранную карту из руки в колоду и перемешать.</summary>
|
||
public void ReturnHandCardToDeck(int player, string prompt)
|
||
{
|
||
if (State.Players[player].Hand.Count == 0)
|
||
return;
|
||
|
||
AskHandCardChoice(player, prompt, null, card =>
|
||
{
|
||
var p = State.Players[player];
|
||
p.Hand.Remove(card);
|
||
p.Deck.Add(card);
|
||
ShuffleDeck(player);
|
||
_events.Add(new CardReturnedToDeckEvent(player, card.PrintingId));
|
||
});
|
||
}
|
||
|
||
/// <summary>Противник выбирает карту в руке и сбрасывает её.</summary>
|
||
public void OpponentDiscardsChosen(int player, string prompt)
|
||
{
|
||
var p = State.Players[player];
|
||
if (p.Hand.Count == 0)
|
||
return;
|
||
|
||
AskHandCardChoice(player, prompt, null, card =>
|
||
{
|
||
p.Hand.Remove(card);
|
||
p.Drop.Add(card);
|
||
_events.Add(new CardDiscardedEvent(player, card.PrintingId));
|
||
});
|
||
}
|
||
|
||
/// <summary>Положить верхние карты колоды в сброс.</summary>
|
||
public void MillTop(int player, int count)
|
||
{
|
||
var p = State.Players[player];
|
||
for (var i = 0; i < count && p.Deck.Count > 0; i++)
|
||
{
|
||
var card = p.Deck[0];
|
||
p.Deck.RemoveAt(0);
|
||
p.Drop.Add(card);
|
||
}
|
||
}
|
||
|
||
/// <summary>Переместить карту из сброса в душу (Rock the Wall, Redshoe Milly).</summary>
|
||
public bool MoveDropToSoul(int player, CardInstance card)
|
||
{
|
||
var p = State.Players[player];
|
||
if (!p.Drop.Remove(card))
|
||
return false;
|
||
|
||
p.Soul.Add(card);
|
||
_events.Add(new CardMovedToSoulEvent(player, card.PrintingId));
|
||
return true;
|
||
}
|
||
|
||
/// <summary>Вернуть карту с поля в колоду и перемешать. Авангард так вернуть нельзя.</summary>
|
||
public bool ReturnUnitToDeck(int player, CardInstance unit)
|
||
{
|
||
var p = State.Players[player];
|
||
var slot = Array.IndexOf(p.RearGuards, unit);
|
||
if (slot < 0)
|
||
return false;
|
||
|
||
p.RearGuards[slot] = null;
|
||
unit.ReturnToDeck = null;
|
||
p.Deck.Add(unit);
|
||
ShuffleDeck(player);
|
||
_events.Add(new UnitReturnedToDeckEvent(player, unit.PrintingId));
|
||
return true;
|
||
}
|
||
|
||
// ---------- поиск в колоде ----------
|
||
|
||
/// <summary>
|
||
/// Серверный поиск: игрок не видит колоду. Найдена одна карта — сразу передаётся
|
||
/// в onFound; несколько — предлагается выбор (разные арты считаются разными картами);
|
||
/// ни одной — уведомление. После поиска колода всегда перемешивается.
|
||
/// </summary>
|
||
public void SearchDeck(
|
||
int player,
|
||
string prompt,
|
||
Func<CardDefinition, bool> predicate,
|
||
Action<CardInstance> onFound)
|
||
{
|
||
var deck = State.Players[player].Deck;
|
||
var matches = deck
|
||
.Where(c => predicate(State.Catalog.CardOf(c.PrintingId)))
|
||
.ToList();
|
||
|
||
if (matches.Count == 0)
|
||
{
|
||
_events.Add(new SearchFailedEvent(player, prompt));
|
||
ShuffleDeck(player);
|
||
return;
|
||
}
|
||
|
||
if (matches.Count == 1)
|
||
{
|
||
CompleteSearch(player, matches[0], onFound);
|
||
return;
|
||
}
|
||
|
||
AskCardChoice(player, prompt, matches, chosen => CompleteSearch(player, chosen, onFound));
|
||
}
|
||
|
||
private void CompleteSearch(int player, CardInstance card, Action<CardInstance> onFound)
|
||
{
|
||
State.Players[player].Deck.Remove(card);
|
||
onFound(card);
|
||
_events.Add(new CardSearchedEvent(player, card.PrintingId));
|
||
ShuffleDeck(player);
|
||
}
|
||
|
||
/// <summary>Поиск с добавлением карты в руку.</summary>
|
||
public void SearchDeckToHand(int player, string prompt, Func<CardDefinition, bool> predicate) =>
|
||
SearchDeck(player, prompt, predicate, card =>
|
||
{
|
||
_events.Add(new CardRevealedEvent(player, card.PrintingId));
|
||
State.Players[player].Hand.Add(card);
|
||
});
|
||
|
||
/// <summary>Поиск с призывом в выбранный круг Арьергарда.</summary>
|
||
public void SearchDeckToCall(int player, string prompt, Func<CardDefinition, bool> predicate)
|
||
{
|
||
SearchDeck(player, prompt, predicate, card =>
|
||
{
|
||
_events.Add(new CardRevealedEvent(player, card.PrintingId));
|
||
|
||
// призывать некуда — карта возвращается в руку, а не теряется
|
||
if (!HasEmptyRearGuard(player))
|
||
{
|
||
State.Players[player].Hand.Add(card);
|
||
return;
|
||
}
|
||
|
||
State.InTransit.Add((player, card));
|
||
AskEmptyRearGuardChoice(player, "Выберите круг Арьергарда", slot =>
|
||
{
|
||
State.InTransit.Remove((player, card));
|
||
CallFromDeck(card, player, slot);
|
||
});
|
||
});
|
||
}
|
||
|
||
/// <summary>Поиск с райдом найденной карты (superior ride).</summary>
|
||
public void SearchDeckToRide(int player, string prompt, Func<CardDefinition, bool> predicate)
|
||
{
|
||
SearchDeck(player, prompt, predicate, card =>
|
||
{
|
||
_events.Add(new CardRevealedEvent(player, card.PrintingId));
|
||
RideFromDeck(card, player);
|
||
});
|
||
}
|
||
|
||
/// <summary>Посмотреть верхнюю карту и оставить её на верху или отправить в низ.</summary>
|
||
public void LookTopAndReorder(int player, string prompt)
|
||
{
|
||
var deck = State.Players[player].Deck;
|
||
if (deck.Count == 0)
|
||
return;
|
||
|
||
var top = deck[0];
|
||
|
||
State.PendingChoice = new PendingChoice
|
||
{
|
||
Player = player,
|
||
Prompt = $"{prompt}: {State.Catalog.CardOf(top.PrintingId).NameEn}",
|
||
Options = new List<ChoiceOption>
|
||
{
|
||
new(0, "Оставить на верху"),
|
||
new(1, "Положить в низ"),
|
||
},
|
||
Resolve = index =>
|
||
{
|
||
if (index == 1)
|
||
{
|
||
deck.RemoveAt(0);
|
||
deck.Add(top);
|
||
}
|
||
|
||
_events.Add(new CardRevealedEvent(player, top.PrintingId));
|
||
},
|
||
};
|
||
State.Step = Step.Choice;
|
||
State.ActivePlayer = player;
|
||
}
|
||
|
||
public bool HasEmptyRearGuard(int player) =>
|
||
State.Players[player].RearGuards.Any(u => u is null);
|
||
|
||
/// <summary>Выбор карты из руки (стоимость, сброс, цель эффекта).</summary>
|
||
public void AskHandCardChoice(
|
||
int player,
|
||
string prompt,
|
||
Func<CardInstance, bool>? predicate,
|
||
Action<CardInstance> onChosen)
|
||
{
|
||
var cards = State.Players[player].Hand
|
||
.Where(c => predicate is null || predicate(c))
|
||
.ToList();
|
||
|
||
if (cards.Count == 0)
|
||
return;
|
||
|
||
State.PendingChoice = new PendingChoice
|
||
{
|
||
Player = player,
|
||
Prompt = prompt,
|
||
Options = cards
|
||
.Select((card, index) => new ChoiceOption(index, State.Catalog.CardOf(card.PrintingId).NameEn))
|
||
.ToList(),
|
||
Resolve = index => onChosen(cards[index]),
|
||
};
|
||
State.Step = Step.Choice;
|
||
State.ActivePlayer = player;
|
||
}
|
||
|
||
public void AskEmptyRearGuardChoice(int player, string prompt, Action<int> onChosen)
|
||
{
|
||
var slots = Enumerable.Range(0, PlayerState.RearGuardSlots)
|
||
.Where(s => State.Players[player].RearGuards[s] is null)
|
||
.ToList();
|
||
|
||
if (slots.Count == 0)
|
||
return;
|
||
|
||
State.PendingChoice = new PendingChoice
|
||
{
|
||
Player = player,
|
||
Prompt = prompt,
|
||
Options = slots.Select(s => new ChoiceOption(s, $"Круг {s + 1}")).ToList(),
|
||
Resolve = onChosen,
|
||
};
|
||
State.Step = Step.Choice;
|
||
State.ActivePlayer = player;
|
||
}
|
||
|
||
public void CallFromDeck(CardInstance card, int player, int slot)
|
||
{
|
||
var p = State.Players[player];
|
||
if (p.RearGuards[slot] is not null)
|
||
{
|
||
p.Hand.Add(card);
|
||
return;
|
||
}
|
||
|
||
card.Rested = false;
|
||
p.RearGuards[slot] = card;
|
||
_events.Add(new UnitCalledEvent(player, slot, card.PrintingId));
|
||
|
||
TriggerFromCard(player, card, AbilityTrigger.OnPlace, null);
|
||
}
|
||
|
||
/// <summary>Райд картой не из руки (superior ride).</summary>
|
||
public void RideFromDeck(CardInstance card, int player) => RideUnit(card, player);
|
||
|
||
/// <summary>Райд картой из сброса (Monster Frank).</summary>
|
||
public void RideFromDrop(CardInstance card, int player)
|
||
{
|
||
if (!State.Players[player].Drop.Remove(card))
|
||
return;
|
||
|
||
RideUnit(card, player);
|
||
}
|
||
|
||
/// <summary>Райд картой из руки по эффекту (Demonic Dragon Berserker, Yaksha).</summary>
|
||
public void RideFromHand(CardInstance card, int player)
|
||
{
|
||
if (!State.Players[player].Hand.Remove(card))
|
||
return;
|
||
|
||
RideUnit(card, player);
|
||
}
|
||
|
||
public bool IsInHand(int player, CardInstance card) =>
|
||
State.Players[player].Hand.Contains(card);
|
||
|
||
private void RideUnit(CardInstance card, int player)
|
||
{
|
||
var p = State.Players[player];
|
||
var riddenOver = p.Vanguard;
|
||
|
||
if (riddenOver is not null)
|
||
{
|
||
riddenOver.Rested = false;
|
||
p.Soul.Add(riddenOver);
|
||
}
|
||
|
||
card.Rested = false;
|
||
p.Vanguard = card;
|
||
_events.Add(new UnitRodeEvent(player, card.PrintingId));
|
||
|
||
TriggerFromCard(player, card, AbilityTrigger.OnPlace, null);
|
||
|
||
if (State.Step == Step.None)
|
||
TriggerFromCard(player, riddenOver, AbilityTrigger.OnRideOver, card);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Останавливает разрешение и просит выбрать карту из найденных.
|
||
/// Варианты различаются номером принта, поэтому два Blaster Blade с разными артами — два варианта.
|
||
/// </summary>
|
||
public void AskCardChoice(
|
||
int player,
|
||
string prompt,
|
||
IReadOnlyList<CardInstance> cards,
|
||
Action<CardInstance> onChosen)
|
||
{
|
||
var options = cards
|
||
.Select((card, index) => new ChoiceOption(
|
||
index,
|
||
$"{State.Catalog.CardOf(card.PrintingId).NameEn} ({card.PrintingId})"))
|
||
.ToList();
|
||
|
||
State.PendingChoice = new PendingChoice
|
||
{
|
||
Player = player,
|
||
Prompt = prompt,
|
||
Options = options,
|
||
Resolve = index => onChosen(cards[index]),
|
||
};
|
||
State.Step = Step.Choice;
|
||
State.ActivePlayer = player;
|
||
}
|
||
|
||
public Clan VanguardClan(int player)
|
||
{
|
||
var vanguard = State.Players[player].Vanguard;
|
||
return vanguard is null ? Clan.RoyalPaladin : State.Catalog.CardOf(vanguard.PrintingId).Clan;
|
||
}
|
||
|
||
public int VanguardGrade(int player) =>
|
||
State.Players[player].Vanguard is { } vanguard
|
||
? State.Catalog.CardOf(vanguard.PrintingId).Grade
|
||
: 0;
|
||
|
||
/// <summary>Есть ли на своих кругах Арьергарда по одной карте каждого имени.</summary>
|
||
public bool HasFieldUnits(int player, IReadOnlyList<string> nameIds)
|
||
{
|
||
var slots = new HashSet<int>();
|
||
foreach (var nameId in nameIds)
|
||
{
|
||
var found = false;
|
||
for (var slot = 0; slot < PlayerState.RearGuardSlots; slot++)
|
||
{
|
||
if (slots.Contains(slot))
|
||
continue;
|
||
|
||
var unit = State.Players[player].RearGuards[slot];
|
||
if (unit is null || State.Catalog.CardOf(unit.PrintingId).NameId != nameId)
|
||
continue;
|
||
|
||
slots.Add(slot);
|
||
found = true;
|
||
break;
|
||
}
|
||
|
||
if (!found)
|
||
return false;
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
/// <summary>Положить по одной карте каждого имени со своих кругов в душу.</summary>
|
||
public bool PutFieldUnitsToSoul(int player, IReadOnlyList<string> nameIds)
|
||
{
|
||
if (!HasFieldUnits(player, nameIds))
|
||
return false;
|
||
|
||
var p = State.Players[player];
|
||
var used = new HashSet<int>();
|
||
|
||
foreach (var nameId in nameIds)
|
||
{
|
||
for (var slot = 0; slot < PlayerState.RearGuardSlots; slot++)
|
||
{
|
||
if (used.Contains(slot))
|
||
continue;
|
||
|
||
var unit = p.RearGuards[slot];
|
||
if (unit is null || State.Catalog.CardOf(unit.PrintingId).NameId != nameId)
|
||
continue;
|
||
|
||
used.Add(slot);
|
||
p.RearGuards[slot] = null;
|
||
p.Soul.Add(unit);
|
||
_events.Add(new CardMovedToSoulEvent(player, unit.PrintingId));
|
||
break;
|
||
}
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
// ---------- легальные интенты ----------
|
||
|
||
/// <summary>
|
||
/// Все команды, которые сейчас имеют смысл. Нужны ботам, тестам и будущему UI.
|
||
/// Список может быть неполным там, где команда требует дополнительного выбора
|
||
/// (например, сброс карты как стоимость активируемой способности).
|
||
/// </summary>
|
||
public IReadOnlyList<GameCommand> LegalCommands()
|
||
{
|
||
// Защита от состояния «бой уже начался, но очередь эффектов оставила
|
||
// continuation невыполненным». Иначе клиент получает EndTurn,
|
||
// который движок затем отклоняет.
|
||
if (State.Step == Step.None && State.Battle is not null
|
||
&& State.Continuation is not null)
|
||
{
|
||
ContinueOrder();
|
||
}
|
||
|
||
var commands = new List<GameCommand>();
|
||
if (State.IsFinished)
|
||
return commands;
|
||
|
||
if (State.PendingChoice is { } choice)
|
||
{
|
||
for (var i = 0; i < choice.Options.Count; i++)
|
||
commands.Add(new ChooseCommand(i));
|
||
|
||
return commands;
|
||
}
|
||
|
||
if (State.PendingAbility is not null)
|
||
{
|
||
commands.Add(new ResolveAbilityCommand(true));
|
||
commands.Add(new ResolveAbilityCommand(false));
|
||
return commands;
|
||
}
|
||
|
||
if (State.PendingOrder is { Count: > 0 } order)
|
||
{
|
||
for (var i = 0; i < order.Count; i++)
|
||
{
|
||
if (order[i].Owner == State.ActivePlayer)
|
||
commands.Add(new SelectTriggerCommand(i));
|
||
}
|
||
|
||
return commands;
|
||
}
|
||
|
||
if (State.PendingTrigger is { } pending)
|
||
{
|
||
var hasRearGuard = State.Players[pending.Player].RearGuards.Any(u => u is not null);
|
||
|
||
foreach (var powerSlot in UnitSlots(pending.Player))
|
||
{
|
||
if (pending.Trigger is TriggerType.Critical or TriggerType.Stand)
|
||
{
|
||
foreach (var effectSlot in UnitSlots(pending.Player))
|
||
{
|
||
if (pending.Trigger == TriggerType.Stand && effectSlot < 0 && hasRearGuard)
|
||
continue;
|
||
|
||
commands.Add(new ResolveTriggerCommand(powerSlot, effectSlot));
|
||
}
|
||
}
|
||
else
|
||
{
|
||
commands.Add(new ResolveTriggerCommand(powerSlot, -1));
|
||
}
|
||
}
|
||
|
||
return commands;
|
||
}
|
||
|
||
var player = State.ActivePlayer;
|
||
var p = State.Players[player];
|
||
|
||
switch (State.Phase)
|
||
{
|
||
case Phase.Mulligan:
|
||
commands.Add(new FinishMulliganCommand());
|
||
break;
|
||
|
||
case Phase.Ride:
|
||
foreach (var printingId in p.Hand.Select(c => c.PrintingId).Distinct())
|
||
{
|
||
if (State.Catalog.CardOf(printingId).Grade == VanguardGrade(player) + 1)
|
||
commands.Add(new RideCommand(printingId));
|
||
}
|
||
|
||
commands.Add(new SkipRideCommand());
|
||
break;
|
||
|
||
case Phase.Main:
|
||
AddMainCommands(commands, player);
|
||
break;
|
||
|
||
case Phase.Battle:
|
||
AddBattleCommands(commands, player);
|
||
break;
|
||
}
|
||
|
||
return commands;
|
||
}
|
||
|
||
private void AddMainCommands(List<GameCommand> commands, int player)
|
||
{
|
||
var p = State.Players[player];
|
||
var maxGrade = VanguardGrade(player);
|
||
var emptySlots = Enumerable.Range(0, PlayerState.RearGuardSlots)
|
||
.Where(s => p.RearGuards[s] is null)
|
||
.ToList();
|
||
|
||
foreach (var printingId in p.Hand.Select(c => c.PrintingId).Distinct())
|
||
{
|
||
if (State.Catalog.CardOf(printingId).Grade > maxGrade)
|
||
continue;
|
||
|
||
foreach (var slot in emptySlots)
|
||
commands.Add(new CallCommand(printingId, slot));
|
||
}
|
||
|
||
for (var from = 0; from < PlayerState.RearGuardSlots; from++)
|
||
{
|
||
if (p.RearGuards[from] is null)
|
||
continue;
|
||
|
||
for (var to = 0; to < PlayerState.RearGuardSlots; to++)
|
||
{
|
||
if (to != from && SameColumn(from, to))
|
||
commands.Add(new MoveCommand(from, to));
|
||
}
|
||
}
|
||
|
||
foreach (var unit in AllUnits(player))
|
||
AddActivatedCommands(commands, unit, player, fromHand: false, fromDrop: false);
|
||
|
||
foreach (var card in p.Hand)
|
||
AddActivatedCommands(commands, card, player, fromHand: true, fromDrop: false);
|
||
|
||
foreach (var card in p.Drop)
|
||
AddActivatedCommands(commands, card, player, fromHand: false, fromDrop: true);
|
||
|
||
commands.Add(new EnterBattleCommand());
|
||
commands.Add(new EndTurnCommand());
|
||
}
|
||
|
||
private void AddBattleCommands(List<GameCommand> commands, int player)
|
||
{
|
||
var p = State.Players[player];
|
||
|
||
if (State.Step == Step.None)
|
||
{
|
||
foreach (var attackerSlot in UnitSlots(player))
|
||
{
|
||
// Задний ряд не атакует; -1 — Вэнгард, 3/4 — передний ряд.
|
||
if (attackerSlot is 0 or 1 or 2)
|
||
continue;
|
||
|
||
var attacker = State.GetUnit(player, attackerSlot);
|
||
if (attacker is null || attacker.Rested)
|
||
continue;
|
||
|
||
if (State.IsRestraint(attacker)
|
||
|| State.HasRestriction(attacker, RestrictionType.CannotAttack))
|
||
continue;
|
||
|
||
var targets = new List<int> { -1 };
|
||
var canSnipe = State.Catalog.CardOf(attacker.PrintingId).CanSnipeBackRow;
|
||
|
||
targets.AddRange(UnitSlots(1 - player).Where(slot => slot >= 0
|
||
&& (slot is 3 or 4
|
||
|| (canSnipe
|
||
&& attackerSlot is 3 or 4
|
||
&& SameColumn(attackerSlot, slot)))));
|
||
|
||
foreach (var target in targets)
|
||
commands.Add(new AttackCommand(attackerSlot, target));
|
||
|
||
if (attackerSlot is 3 or 4)
|
||
{
|
||
var boosterSlot = attackerSlot == 3 ? 0 : 2;
|
||
var booster = State.GetUnit(player, boosterSlot);
|
||
if (booster is not null && !booster.Rested && State.CanBoost(booster))
|
||
{
|
||
foreach (var target in targets)
|
||
commands.Add(new AttackCommand(attackerSlot, target, boosterSlot));
|
||
}
|
||
}
|
||
}
|
||
|
||
commands.Add(new EndTurnCommand());
|
||
return;
|
||
}
|
||
|
||
if (State.Step != Step.Guard || State.Battle is null)
|
||
return;
|
||
|
||
foreach (var printingId in p.Hand.Select(c => c.PrintingId).Distinct())
|
||
{
|
||
var def = State.Catalog.CardOf(printingId);
|
||
if (def.IsSentinel)
|
||
{
|
||
commands.Add(new GuardCommand(printingId));
|
||
|
||
foreach (var discardId in p.Hand
|
||
.Where(c => c.PrintingId != printingId
|
||
&& State.Catalog.CardOf(c.PrintingId).Clan == def.Clan)
|
||
.Select(c => c.PrintingId)
|
||
.Distinct())
|
||
{
|
||
commands.Add(new GuardCommand(printingId, discardId));
|
||
}
|
||
}
|
||
else if (def.Shield > 0)
|
||
{
|
||
commands.Add(new GuardCommand(printingId));
|
||
}
|
||
}
|
||
|
||
foreach (var slot in Enumerable.Range(0, PlayerState.RearGuardSlots))
|
||
{
|
||
if (State.Battle.Interceptor is not null)
|
||
break;
|
||
|
||
if (slot == State.Battle.TargetSlot)
|
||
continue;
|
||
|
||
var unit = p.RearGuards[slot];
|
||
if (unit is null || unit.Rested)
|
||
continue;
|
||
|
||
if (slot is not (3 or 4) && !unit.InterceptFromBackRow)
|
||
continue;
|
||
|
||
if (!State.CanIntercept(unit))
|
||
continue;
|
||
|
||
commands.Add(new InterceptCommand(slot));
|
||
}
|
||
|
||
commands.Add(new PassGuardCommand());
|
||
}
|
||
|
||
private void AddActivatedCommands(
|
||
List<GameCommand> commands,
|
||
CardInstance card,
|
||
int player,
|
||
bool fromHand,
|
||
bool fromDrop)
|
||
{
|
||
var nameId = State.Catalog.CardOf(card.PrintingId).NameId;
|
||
if (!_scripts.TryGetValue(nameId, out var script))
|
||
return;
|
||
|
||
var context = new AbilityContext
|
||
{
|
||
Engine = this,
|
||
Source = card,
|
||
Owner = player,
|
||
Phase = State.Phase,
|
||
};
|
||
|
||
var isOnRearGuard = !fromHand && !fromDrop
|
||
&& Array.IndexOf(State.Players[player].RearGuards, card) >= 0;
|
||
|
||
var available = script.Activated
|
||
.Where(ability =>
|
||
(fromHand ? ability.FromHand : fromDrop ? ability.FromDropZone
|
||
: !ability.FromHand && !ability.FromDropZone)
|
||
&& (!(fromHand || fromDrop) || (!ability.RestsSelf && !ability.RetiresSelf))
|
||
&& (!ability.RestsSelf || !card.Rested)
|
||
&& (!ability.RetiresSelf || isOnRearGuard)
|
||
&& ability.CanActivate(context))
|
||
.ToList();
|
||
|
||
if (available.Count == 0)
|
||
return;
|
||
|
||
if (available.Any(ability => !ability.RequiresDiscard))
|
||
commands.Add(new ActivateAbilityCommand(card.Id));
|
||
|
||
if (!available.Any(ability => ability.RequiresDiscard))
|
||
return;
|
||
|
||
var discardPrintings = State.Players[player].Hand
|
||
.Select(handCard => handCard.PrintingId)
|
||
.Distinct(StringComparer.Ordinal)
|
||
.ToList();
|
||
|
||
foreach (var printingId in discardPrintings)
|
||
{
|
||
// карту из руки нельзя предлагать самой себе как стоимость
|
||
if (fromHand && printingId == card.PrintingId)
|
||
continue;
|
||
|
||
commands.Add(new ActivateAbilityCommand(card.Id, printingId));
|
||
}
|
||
}
|
||
|
||
private IEnumerable<int> UnitSlots(int player)
|
||
{
|
||
yield return -1;
|
||
|
||
for (var slot = 0; slot < PlayerState.RearGuardSlots; slot++)
|
||
{
|
||
if (State.Players[player].RearGuards[slot] is not null)
|
||
yield return slot;
|
||
}
|
||
}
|
||
|
||
// ---------- старт партии ----------
|
||
|
||
public void Start()
|
||
{
|
||
ShuffleDeck(0);
|
||
ShuffleDeck(1);
|
||
|
||
PlaceStartingVanguard(0);
|
||
PlaceStartingVanguard(1);
|
||
|
||
Draw(0, StartingHand);
|
||
Draw(1, StartingHand);
|
||
|
||
State.Phase = Phase.Mulligan;
|
||
_events.Add(new GameStartedEvent(0));
|
||
}
|
||
|
||
private void ShuffleDeck(int player)
|
||
{
|
||
var state = State.RngState;
|
||
Rng.Shuffle(ref state, State.Players[player].Deck);
|
||
State.RngState = state;
|
||
}
|
||
|
||
private void FillDeck(int player, IReadOnlyList<string> printingIds)
|
||
{
|
||
foreach (var printingId in printingIds)
|
||
{
|
||
State.Players[player].Deck.Add(new CardInstance
|
||
{
|
||
Id = State.NextInstanceId++,
|
||
PrintingId = printingId,
|
||
});
|
||
}
|
||
}
|
||
|
||
private void PlaceStartingVanguard(int player)
|
||
{
|
||
var p = State.Players[player];
|
||
var index = p.Deck.FindIndex(c =>
|
||
{
|
||
var def = State.Catalog.CardOf(c.PrintingId);
|
||
return def.IsStartingVanguardCandidate;
|
||
});
|
||
|
||
if (index < 0)
|
||
throw new InvalidOperationException("В колоде нет карты для стартового Авангарда (grade 0 без триггера).");
|
||
|
||
var card = p.Deck[index];
|
||
p.Deck.RemoveAt(index);
|
||
p.Vanguard = card;
|
||
}
|
||
|
||
// ---------- команды ----------
|
||
|
||
public CommandResult Execute(GameCommand command)
|
||
{
|
||
if (State.IsFinished)
|
||
return CommandResult.Reject("Партия завершена.");
|
||
|
||
// пока идёт разрешение способности или триггера, посторонние команды запрещены
|
||
if (State.Step is Step.Trigger or Step.Ability or Step.Choice)
|
||
{
|
||
var allowed = command switch
|
||
{
|
||
ResolveTriggerCommand => State.Step == Step.Trigger,
|
||
ResolveAbilityCommand or SelectTriggerCommand => State.Step == Step.Ability,
|
||
ChooseCommand => State.Step == Step.Choice,
|
||
_ => false,
|
||
};
|
||
|
||
if (!allowed)
|
||
return CommandResult.Reject("Сначала завершите текущее разрешение.");
|
||
}
|
||
|
||
return command switch
|
||
{
|
||
MulliganCommand c => Mulligan(c),
|
||
FinishMulliganCommand => FinishMulligan(),
|
||
RideCommand c => Ride(c.PrintingId),
|
||
SkipRideCommand => SkipRide(),
|
||
CallCommand c => Call(c.PrintingId, c.Slot),
|
||
MoveCommand c => Move(c.FromSlot, c.ToSlot),
|
||
EnterBattleCommand => EnterBattle(),
|
||
AttackCommand c => Attack(c.AttackerSlot, c.TargetSlot, c.BoosterSlot),
|
||
GuardCommand c => Guard(c.PrintingId, c.DiscardPrintingId),
|
||
InterceptCommand c => Intercept(c.Slot),
|
||
PassGuardCommand => PassGuard(),
|
||
ResolveTriggerCommand c => ResolveTrigger(c),
|
||
SelectTriggerCommand c => SelectTrigger(c.Index),
|
||
ResolveAbilityCommand c => ResolvePendingAbility(c.Accept, c.DamageIndex),
|
||
ChooseCommand c => Choose(c.Index),
|
||
ActivateAbilityCommand c => Activate(c.InstanceId, c.DiscardPrintingId, c.DamageIndex),
|
||
EndTurnCommand => EndTurn(),
|
||
_ => CommandResult.Reject("Неизвестная команда."),
|
||
};
|
||
}
|
||
|
||
private CommandResult Mulligan(MulliganCommand command)
|
||
{
|
||
if (State.Phase != Phase.Mulligan)
|
||
return CommandResult.Reject("Маллиган доступен только в начале партии.");
|
||
|
||
var player = State.ActivePlayer;
|
||
var hand = State.Players[player].Hand;
|
||
var indexes = command.HandIndexes.Distinct().OrderByDescending(i => i).ToList();
|
||
|
||
if (indexes.Any(i => i < 0 || i >= hand.Count))
|
||
return CommandResult.Reject("Некорректный индекс карты в руке.");
|
||
|
||
foreach (var index in indexes)
|
||
{
|
||
var card = hand[index];
|
||
hand.RemoveAt(index);
|
||
State.Players[player].Deck.Add(card);
|
||
}
|
||
|
||
ShuffleDeck(player);
|
||
Draw(player, indexes.Count);
|
||
_events.Add(new MulliganEvent(player, indexes.Count));
|
||
|
||
return CommandResult.Ok();
|
||
}
|
||
|
||
private CommandResult FinishMulligan()
|
||
{
|
||
if (State.Phase != Phase.Mulligan)
|
||
return CommandResult.Reject("Маллиган уже завершён.");
|
||
|
||
State.ActivePlayer = 0;
|
||
StartTurn();
|
||
return CommandResult.Ok();
|
||
}
|
||
|
||
private CommandResult Ride(string printingId)
|
||
{
|
||
if (State.Phase != Phase.Ride)
|
||
return CommandResult.Reject("Райд доступен только в фазе Смены.");
|
||
|
||
var player = State.Players[State.ActivePlayer];
|
||
var index = player.Hand.FindIndex(c => c.PrintingId == printingId);
|
||
if (index < 0)
|
||
return CommandResult.Reject("Карты нет в руке.");
|
||
|
||
var card = player.Hand[index];
|
||
var def = State.Catalog.CardOf(card.PrintingId);
|
||
var current = player.Vanguard is null ? -1 : State.Catalog.CardOf(player.Vanguard.PrintingId).Grade;
|
||
|
||
if (def.Grade != current && def.Grade != current + 1)
|
||
return CommandResult.Reject($"Нельзя совершить смену с {current}-го ранга на {def.Grade}-й.");
|
||
|
||
var riddenOver = player.Vanguard;
|
||
if (riddenOver is not null)
|
||
{
|
||
riddenOver.Rested = false;
|
||
player.Soul.Add(riddenOver);
|
||
}
|
||
|
||
player.Hand.RemoveAt(index);
|
||
card.Rested = false;
|
||
player.Vanguard = card;
|
||
|
||
_events.Add(new UnitRodeEvent(State.ActivePlayer, card.PrintingId));
|
||
|
||
TriggerFromCard(State.ActivePlayer, card, AbilityTrigger.OnPlace, null);
|
||
|
||
if (State.Step == Step.None)
|
||
TriggerFromCard(State.ActivePlayer, riddenOver, AbilityTrigger.OnRideOver, card);
|
||
|
||
if (State.Step == Step.None)
|
||
EnterMainPhase();
|
||
else
|
||
State.Continuation = EnterMainPhase;
|
||
|
||
return CommandResult.Ok();
|
||
}
|
||
|
||
private CommandResult SkipRide()
|
||
{
|
||
if (State.Phase != Phase.Ride)
|
||
return CommandResult.Reject("Пропустить смену можно только в фазе Смены.");
|
||
|
||
EnterMainPhase();
|
||
return CommandResult.Ok();
|
||
}
|
||
|
||
private CommandResult Call(string printingId, int slot)
|
||
{
|
||
if (State.Phase != Phase.Main)
|
||
return CommandResult.Reject("Призыв доступен только в Основной фазе.");
|
||
|
||
if (slot is < 0 or >= PlayerState.RearGuardSlots)
|
||
return CommandResult.Reject("Некорректный круг Арьергарда.");
|
||
|
||
var player = State.Players[State.ActivePlayer];
|
||
if (player.RearGuards[slot] is not null)
|
||
return CommandResult.Reject("Круг уже занят.");
|
||
|
||
var index = player.Hand.FindIndex(c => c.PrintingId == printingId);
|
||
if (index < 0)
|
||
return CommandResult.Reject("Карты нет в руке.");
|
||
|
||
var card = player.Hand[index];
|
||
player.Hand.RemoveAt(index);
|
||
card.Rested = false;
|
||
player.RearGuards[slot] = card;
|
||
|
||
_events.Add(new UnitCalledEvent(State.ActivePlayer, slot, card.PrintingId));
|
||
|
||
TriggerFromCard(State.ActivePlayer, card, AbilityTrigger.OnPlace, null);
|
||
|
||
return CommandResult.Ok();
|
||
}
|
||
|
||
private CommandResult Move(int fromSlot, int toSlot)
|
||
{
|
||
if (State.Phase != Phase.Main)
|
||
return CommandResult.Reject("Перемещение доступно только в Основной фазе.");
|
||
|
||
if (fromSlot is < 0 or >= PlayerState.RearGuardSlots || toSlot is < 0 or >= PlayerState.RearGuardSlots)
|
||
return CommandResult.Reject("Некорректный круг Арьергарда.");
|
||
|
||
var player = State.Players[State.ActivePlayer];
|
||
var unit = player.RearGuards[fromSlot];
|
||
if (unit is null)
|
||
return CommandResult.Reject("В исходном кругу нет карты.");
|
||
|
||
if (!SameColumn(fromSlot, toSlot))
|
||
return CommandResult.Reject("Двигать можно только в пределах своей колонки.");
|
||
|
||
// целевой круг может быть занят — тогда карты меняются местами
|
||
var other = player.RearGuards[toSlot];
|
||
player.RearGuards[toSlot] = unit;
|
||
player.RearGuards[fromSlot] = other;
|
||
|
||
_events.Add(new UnitMovedEvent(State.ActivePlayer, fromSlot, toSlot));
|
||
return CommandResult.Ok();
|
||
}
|
||
|
||
/// <summary>Колонки: 0↔3 (левая), 2↔4 (правая). У центрального заднего ряда пары нет.</summary>
|
||
private static bool SameColumn(int fromSlot, int toSlot) =>
|
||
(fromSlot, toSlot) is (0, 3) or (3, 0) or (2, 4) or (4, 2);
|
||
|
||
private CommandResult EnterBattle()
|
||
{
|
||
if (State.Phase != Phase.Main)
|
||
return CommandResult.Reject("Перейти в Боевую фазу можно только из Основной.");
|
||
|
||
State.Phase = Phase.Battle;
|
||
_events.Add(new PhaseChangedEvent(State.ActivePlayer, Phase.Battle));
|
||
return CommandResult.Ok();
|
||
}
|
||
|
||
private CommandResult Attack(int attackerSlot, int targetSlot, int boosterSlot = -1)
|
||
{
|
||
if (State.Phase != Phase.Battle)
|
||
return CommandResult.Reject("Атака доступна только в Боевой фазе.");
|
||
|
||
if (State.Battle is not null)
|
||
return CommandResult.Reject("Идёт бой: сначала завершите защиту.");
|
||
|
||
if (attackerSlot is < -1 or >= PlayerState.RearGuardSlots)
|
||
return CommandResult.Reject("Некорректный атакующий.");
|
||
|
||
if (targetSlot is < -1 or >= PlayerState.RearGuardSlots)
|
||
return CommandResult.Reject("Некорректная цель.");
|
||
|
||
var attacker = State.GetUnit(State.ActivePlayer, attackerSlot);
|
||
if (attacker is null)
|
||
return CommandResult.Reject("Нет атакующего юнита.");
|
||
|
||
if (attacker.Rested)
|
||
return CommandResult.Reject("Юнит уже повёрнут.");
|
||
|
||
if (State.IsRestraint(attacker))
|
||
return CommandResult.Reject("Юнит со Сдерживанием не может атаковать.");
|
||
|
||
if (State.HasRestriction(attacker, RestrictionType.CannotAttack))
|
||
return CommandResult.Reject("Юнит не может атаковать.");
|
||
|
||
// Атаковать могут только Вэнгард и Арьергард переднего ряда.
|
||
// Задний ряд — исключительно поддержка (boost) и эффекты.
|
||
if (attackerSlot is 0 or 1 or 2)
|
||
return CommandResult.Reject("Атаковать могут только юниты переднего ряда.");
|
||
|
||
var target = State.GetUnit(1 - State.ActivePlayer, targetSlot);
|
||
if (target is null)
|
||
return CommandResult.Reject("Нет цели.");
|
||
|
||
// Задний ряд защищён: атаковать его можно только способностью
|
||
// и только из переднего ряда своей колонки.
|
||
if (targetSlot is 0 or 1 or 2)
|
||
{
|
||
if (!State.Catalog.CardOf(attacker.PrintingId).CanSnipeBackRow)
|
||
return CommandResult.Reject("Арьергард в заднем ряду нельзя атаковать без способности.");
|
||
|
||
if (attackerSlot is not (3 or 4) || !SameColumn(attackerSlot, targetSlot))
|
||
return CommandResult.Reject("Атаковать задний ряд можно только из своей колонки.");
|
||
}
|
||
|
||
CardInstance? booster = null;
|
||
if (boosterSlot >= 0)
|
||
{
|
||
if (attackerSlot is not (3 or 4))
|
||
return CommandResult.Reject("Поддерживать можно только атаку Арьергарда из переднего ряда.");
|
||
|
||
var expected = attackerSlot == 3 ? 0 : 2;
|
||
if (boosterSlot != expected)
|
||
return CommandResult.Reject("Поддержка возможна только из заднего ряда той же колонны.");
|
||
|
||
booster = State.GetUnit(State.ActivePlayer, boosterSlot);
|
||
if (booster is null)
|
||
return CommandResult.Reject("Нет карты для поддержки.");
|
||
|
||
if (booster.Rested)
|
||
return CommandResult.Reject("Карта поддержки уже повёрнута.");
|
||
|
||
if (!State.CanBoost(booster))
|
||
return CommandResult.Reject("Эта карта не может поддерживать.");
|
||
}
|
||
|
||
var attackerPlayer = State.ActivePlayer;
|
||
attacker.Rested = true;
|
||
State.Battle = new BattleState
|
||
{
|
||
AttackerPlayer = attackerPlayer,
|
||
AttackerSlot = attackerSlot,
|
||
TargetSlot = targetSlot,
|
||
BoosterSlot = boosterSlot,
|
||
};
|
||
|
||
var defenderIndex = 1 - attackerPlayer;
|
||
_events.Add(new AttackDeclaredEvent(attackerPlayer, attackerSlot, targetSlot));
|
||
|
||
void BeginGuard()
|
||
{
|
||
State.Step = Step.Guard;
|
||
State.ActivePlayer = defenderIndex;
|
||
}
|
||
|
||
void AfterAttackDeclared()
|
||
{
|
||
TriggerFromCard(attackerPlayer, attacker, AbilityTrigger.OnAttackDeclared, null);
|
||
|
||
if (State.Step is Step.Ability or Step.Choice)
|
||
State.Continuation = BeginGuard;
|
||
else
|
||
BeginGuard();
|
||
}
|
||
|
||
void AfterBoosted()
|
||
{
|
||
TriggerFromCard(attackerPlayer, attacker, AbilityTrigger.OnBoosted, booster);
|
||
|
||
if (State.Step is Step.Ability or Step.Choice)
|
||
State.Continuation = AfterAttackDeclared;
|
||
else
|
||
AfterAttackDeclared();
|
||
}
|
||
|
||
if (booster is not null)
|
||
{
|
||
booster.Rested = true;
|
||
AddPower(
|
||
attacker,
|
||
State.Catalog.CardOf(booster.PrintingId).Power,
|
||
booster,
|
||
"boost",
|
||
ModifierDuration.UntilEndOfBattle);
|
||
|
||
_events.Add(new BoostedEvent(attackerPlayer, boosterSlot, attackerSlot));
|
||
|
||
TriggerFromCard(attackerPlayer, booster, AbilityTrigger.OnBoost, attacker);
|
||
|
||
if (State.Step is Step.Ability or Step.Choice)
|
||
State.Continuation = AfterBoosted;
|
||
else
|
||
AfterBoosted();
|
||
}
|
||
else
|
||
{
|
||
AfterAttackDeclared();
|
||
}
|
||
|
||
return CommandResult.Ok();
|
||
}
|
||
|
||
private CommandResult Guard(string printingId, string? discardPrintingId = null)
|
||
{
|
||
var battle = State.Battle;
|
||
if (State.Step != Step.Guard || battle is null)
|
||
return CommandResult.Reject("Сейчас нельзя защищаться.");
|
||
|
||
var defenderIndex = 1 - battle.AttackerPlayer;
|
||
if (State.ActivePlayer != defenderIndex)
|
||
return CommandResult.Reject("Защищается только обороняющийся.");
|
||
|
||
var defender = State.Players[defenderIndex];
|
||
var index = defender.Hand.FindIndex(c => c.PrintingId == printingId);
|
||
if (index < 0)
|
||
return CommandResult.Reject("Карты нет в руке.");
|
||
|
||
var card = defender.Hand[index];
|
||
var def = State.Catalog.CardOf(card.PrintingId);
|
||
|
||
if (!def.IsSentinel && discardPrintingId is not null)
|
||
return CommandResult.Reject("Эта карта не требует оплаты стоимости.");
|
||
|
||
if (def.IsSentinel && discardPrintingId is not null)
|
||
{
|
||
var discardIndex = defender.Hand.FindIndex(c => c.PrintingId == discardPrintingId);
|
||
if (discardIndex < 0)
|
||
return CommandResult.Reject("Карты для оплаты нет в руке.");
|
||
|
||
if (State.Catalog.CardOf(defender.Hand[discardIndex].PrintingId).Clan != def.Clan)
|
||
return CommandResult.Reject("Для оплаты нужна карта того же клана.");
|
||
}
|
||
|
||
// Sentinel сам по себе даёт ноль щита: либо оплата и отмена атаки, либо ничего
|
||
var shield = def.IsSentinel ? 0 : def.Shield;
|
||
|
||
defender.Hand.RemoveAt(index);
|
||
defender.Guardian.Add(card);
|
||
battle.GuardCards.Add(card);
|
||
battle.GuardShield += shield;
|
||
_events.Add(new GuardAddedEvent(defenderIndex, card.PrintingId, shield));
|
||
|
||
if (def.IsSentinel && discardPrintingId is not null)
|
||
{
|
||
var discardIndex = defender.Hand.FindIndex(c => c.PrintingId == discardPrintingId);
|
||
var discarded = defender.Hand[discardIndex];
|
||
defender.Hand.RemoveAt(discardIndex);
|
||
defender.Drop.Add(discarded);
|
||
|
||
battle.Nullified = true;
|
||
_events.Add(new PerfectGuardEvent(defenderIndex, card.PrintingId, discarded.PrintingId));
|
||
}
|
||
|
||
TriggerFromCard(defenderIndex, card, AbilityTrigger.OnGuard, null);
|
||
|
||
// способность на размещение в (GC) может встать на выбор игрока:
|
||
// после её разрешения возвращаемся в шаг защиты
|
||
if (State.Step is Step.Ability or Step.Choice)
|
||
{
|
||
State.Continuation = () =>
|
||
{
|
||
State.Step = Step.Guard;
|
||
State.ActivePlayer = defenderIndex;
|
||
};
|
||
}
|
||
|
||
return CommandResult.Ok();
|
||
}
|
||
|
||
private CommandResult Intercept(int slot)
|
||
{
|
||
var battle = State.Battle;
|
||
if (State.Step != Step.Guard || battle is null)
|
||
return CommandResult.Reject("Перехват доступен только на шаге защиты.");
|
||
|
||
var defenderIndex = 1 - battle.AttackerPlayer;
|
||
if (State.ActivePlayer != defenderIndex)
|
||
return CommandResult.Reject("Перехватывает только обороняющийся.");
|
||
|
||
if (battle.Interceptor is not null)
|
||
return CommandResult.Reject("Перехват уже объявлен.");
|
||
|
||
if (slot is < 0 or >= PlayerState.RearGuardSlots || slot == battle.TargetSlot)
|
||
return CommandResult.Reject("Некорректный круг для перехвата.");
|
||
|
||
var defender = State.Players[defenderIndex];
|
||
var unit = defender.RearGuards[slot];
|
||
if (unit is null)
|
||
return CommandResult.Reject("Круг пуст.");
|
||
|
||
if (unit.Rested)
|
||
return CommandResult.Reject("Карта повёрнута и не может перехватывать.");
|
||
|
||
var isFrontRow = slot is 3 or 4;
|
||
if (!isFrontRow && !unit.InterceptFromBackRow)
|
||
return CommandResult.Reject("Перехват возможен только из переднего ряда.");
|
||
|
||
if (!State.CanIntercept(unit))
|
||
return CommandResult.Reject("Эта карта не может перехватывать.");
|
||
|
||
defender.RearGuards[slot] = null;
|
||
defender.Guardian.Add(unit);
|
||
battle.GuardCards.Add(unit);
|
||
battle.Interceptor = unit;
|
||
|
||
_events.Add(new InterceptedEvent(defenderIndex, slot, unit.PrintingId));
|
||
return CommandResult.Ok();
|
||
}
|
||
|
||
private CommandResult PassGuard()
|
||
{
|
||
var battle = State.Battle;
|
||
if (State.Step != Step.Guard || battle is null)
|
||
return CommandResult.Reject("Сейчас нет боя.");
|
||
|
||
if (State.ActivePlayer != 1 - battle.AttackerPlayer)
|
||
return CommandResult.Reject("Защищается только обороняющийся.");
|
||
|
||
BeginResolution(battle);
|
||
return CommandResult.Ok();
|
||
}
|
||
|
||
private CommandResult EndTurn()
|
||
{
|
||
if (State.Phase is not (Phase.Main or Phase.Battle))
|
||
return CommandResult.Reject("Завершить ход можно из Основной или Боевой фазы.");
|
||
|
||
if (State.Battle is not null)
|
||
return CommandResult.Reject("Сначала завершите текущий бой.");
|
||
|
||
State.Phase = Phase.End;
|
||
_events.Add(new PhaseChangedEvent(State.ActivePlayer, Phase.End));
|
||
|
||
// «в начале Завершающей фазы вернуть в колоду»
|
||
foreach (var player in new[] { 0, 1 })
|
||
{
|
||
foreach (var unit in AllUnits(player).ToList())
|
||
{
|
||
if (unit.ReturnToDeck == ReturnToDeckTiming.EndOfTurn)
|
||
ReturnUnitToDeck(player, unit);
|
||
}
|
||
}
|
||
|
||
ClearTurnModifiers();
|
||
|
||
if (State.IsFinished)
|
||
return CommandResult.Ok();
|
||
|
||
State.ActivePlayer = 1 - State.ActivePlayer;
|
||
StartTurn();
|
||
return CommandResult.Ok();
|
||
}
|
||
|
||
// ---------- ход ----------
|
||
|
||
private void StartTurn()
|
||
{
|
||
State.TurnNumber++;
|
||
|
||
foreach (var unit in AllUnits(State.ActivePlayer))
|
||
{
|
||
if (!State.HasRestriction(unit, RestrictionType.CannotStand))
|
||
unit.Rested = false;
|
||
}
|
||
|
||
// Запрет «не вставать» действует до этой фазы Подъёма и снимается после неё
|
||
foreach (var unit in AllUnits(State.ActivePlayer))
|
||
unit.Restrictions.RemoveAll(r => r.Duration == ModifierDuration.UntilOwnerNextStand);
|
||
|
||
State.Phase = Phase.Stand;
|
||
_events.Add(new PhaseChangedEvent(State.ActivePlayer, Phase.Stand));
|
||
|
||
State.Phase = Phase.Draw;
|
||
_events.Add(new PhaseChangedEvent(State.ActivePlayer, Phase.Draw));
|
||
Draw(State.ActivePlayer, 1);
|
||
|
||
if (State.IsFinished)
|
||
return;
|
||
|
||
State.Phase = Phase.Ride;
|
||
_events.Add(new PhaseChangedEvent(State.ActivePlayer, Phase.Ride));
|
||
}
|
||
|
||
private IEnumerable<CardInstance> AllUnits(int player)
|
||
{
|
||
var p = State.Players[player];
|
||
if (p.Vanguard is not null)
|
||
yield return p.Vanguard;
|
||
|
||
foreach (var unit in p.RearGuards)
|
||
{
|
||
if (unit is not null)
|
||
yield return unit;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Усиления от триггеров живут до конца хода, а не до конца боя.
|
||
/// Переопределения Сдерживания и числа проверок тоже сбрасываются здесь:
|
||
/// в BT01 они выдаются только до конца хода.
|
||
/// </summary>
|
||
private void ClearTurnModifiers()
|
||
{
|
||
foreach (var unit in AllUnits(0).Concat(AllUnits(1)))
|
||
{
|
||
unit.Modifiers.RemoveAll(m => m.Duration == ModifierDuration.UntilEndOfTurn);
|
||
unit.GrantedAbilities.RemoveAll(g => g.Duration == ModifierDuration.UntilEndOfTurn);
|
||
unit.Restrictions.RemoveAll(r => r.Duration == ModifierDuration.UntilEndOfTurn);
|
||
unit.RestraintOverride = null;
|
||
unit.DriveChecksOverride = null;
|
||
}
|
||
}
|
||
|
||
private void BeginResolution(BattleState battle)
|
||
{
|
||
var attacker = State.GetUnit(battle.AttackerPlayer, battle.AttackerSlot);
|
||
if (attacker is null)
|
||
{
|
||
FinishBattle(battle);
|
||
return;
|
||
}
|
||
|
||
var checks = DriveChecksFor(battle.AttackerSlot, attacker);
|
||
if (checks == 0)
|
||
{
|
||
FinishBattle(battle);
|
||
return;
|
||
}
|
||
|
||
battle.ChecksRemaining = checks;
|
||
State.ActivePlayer = battle.AttackerPlayer;
|
||
State.Step = Step.Drive;
|
||
ContinueDriveChecks(battle);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Идёт по drive-чекам. На триггере останавливается и ждёт ResolveTriggerCommand:
|
||
/// игрок сам выбирает, кому дать +5000 и куда применить второй эффект.
|
||
/// </summary>
|
||
private void ContinueDriveChecks(BattleState battle)
|
||
{
|
||
var player = battle.AttackerPlayer;
|
||
|
||
while (battle.ChecksRemaining > 0 && !State.IsFinished)
|
||
{
|
||
var card = TakeTop(player);
|
||
if (card is null)
|
||
{
|
||
FinishBattle(battle);
|
||
return;
|
||
}
|
||
|
||
var def = State.Catalog.CardOf(card.PrintingId);
|
||
_events.Add(new DriveCheckedEvent(player, card.PrintingId, def.Trigger));
|
||
|
||
if (def.Trigger == TriggerType.None)
|
||
{
|
||
State.Players[player].Hand.Add(card);
|
||
_events.Add(new CardDrawnEvent(player));
|
||
battle.ChecksRemaining--;
|
||
|
||
var checkedAttacker = State.GetUnit(player, battle.AttackerSlot);
|
||
TriggerFromCard(player, checkedAttacker, AbilityTrigger.OnDriveCheck, card);
|
||
|
||
if (State.Step is Step.Ability or Step.Choice)
|
||
{
|
||
State.Continuation = () => ContinueDriveChecks(battle);
|
||
return;
|
||
}
|
||
|
||
continue;
|
||
}
|
||
|
||
State.Players[player].Trigger.Add(card);
|
||
battle.ChecksRemaining--;
|
||
_events.Add(new TriggerResolvedEvent(player, card.PrintingId, def.Trigger));
|
||
|
||
State.PendingTrigger = new PendingTrigger
|
||
{
|
||
Player = player,
|
||
PrintingId = card.PrintingId,
|
||
Card = card,
|
||
Trigger = def.Trigger,
|
||
Context = TriggerContext.DriveCheck,
|
||
GrantsPower = true,
|
||
};
|
||
State.Step = Step.Trigger;
|
||
State.ActivePlayer = player;
|
||
return;
|
||
}
|
||
|
||
FinishBattle(battle);
|
||
}
|
||
|
||
private CommandResult ResolveTrigger(ResolveTriggerCommand command)
|
||
{
|
||
var pending = State.PendingTrigger;
|
||
if (pending is null || State.Step != Step.Trigger)
|
||
return CommandResult.Reject("Сейчас нет триггера для разрешения.");
|
||
|
||
if (State.ActivePlayer != pending.Player)
|
||
return CommandResult.Reject("Триггер разрешает его владелец.");
|
||
|
||
var player = pending.Player;
|
||
|
||
if (pending.GrantsPower)
|
||
{
|
||
var powerTarget = State.GetUnit(player, command.PowerTarget);
|
||
if (powerTarget is null)
|
||
return CommandResult.Reject("Нет цели для +5000 силы.");
|
||
}
|
||
|
||
if (pending.Trigger is TriggerType.Critical or TriggerType.Stand)
|
||
{
|
||
var effectUnit = State.GetUnit(player, command.EffectTarget);
|
||
if (effectUnit is null)
|
||
return CommandResult.Reject("Нет цели для второго эффекта.");
|
||
|
||
if (pending.Trigger == TriggerType.Stand && command.EffectTarget < 0)
|
||
{
|
||
// если Арьергардов нет, поднимать нечего — второй эффект не применяется
|
||
if (State.Players[player].RearGuards.Any(u => u is not null))
|
||
return CommandResult.Reject("Поднять можно только карту в кругу Арьергарда.");
|
||
}
|
||
}
|
||
|
||
if (pending.GrantsPower)
|
||
{
|
||
var powerTarget = State.GetUnit(player, command.PowerTarget)!;
|
||
AddPower(powerTarget, TriggerPowerBonus, pending.Card, "trigger", ModifierDuration.UntilEndOfTurn);
|
||
}
|
||
|
||
switch (pending.Trigger)
|
||
{
|
||
case TriggerType.Critical:
|
||
AddPower(
|
||
State.GetUnit(player, command.EffectTarget)!,
|
||
0,
|
||
pending.Card,
|
||
"trigger",
|
||
ModifierDuration.UntilEndOfTurn,
|
||
critical: 1);
|
||
break;
|
||
case TriggerType.Stand:
|
||
if (command.EffectTarget >= 0)
|
||
State.GetUnit(player, command.EffectTarget)!.Rested = false;
|
||
break;
|
||
case TriggerType.Draw:
|
||
Draw(player, 1);
|
||
break;
|
||
case TriggerType.Heal:
|
||
if (TryBeginHeal(player, pending, command))
|
||
return CommandResult.Ok();
|
||
break;
|
||
}
|
||
|
||
return CompleteTrigger(pending, command);
|
||
}
|
||
|
||
private CommandResult CompleteTrigger(PendingTrigger pending, ResolveTriggerCommand command)
|
||
{
|
||
var player = pending.Player;
|
||
State.PendingTrigger = null;
|
||
_events.Add(new TriggerAppliedEvent(player, command.PowerTarget, command.EffectTarget));
|
||
|
||
if (pending.Context == TriggerContext.DriveCheck)
|
||
{
|
||
// эффекты сработали — теперь карта уходит из зоны триггера в руку
|
||
var p = State.Players[player];
|
||
p.Trigger.Remove(pending.Card);
|
||
p.Hand.Add(pending.Card);
|
||
|
||
if (State.Battle is { } battle)
|
||
{
|
||
State.Step = Step.Drive;
|
||
ContinueDriveChecks(battle);
|
||
}
|
||
|
||
return CommandResult.Ok();
|
||
}
|
||
|
||
FinishDamage(pending);
|
||
return CommandResult.Ok();
|
||
}
|
||
|
||
private bool TryBeginHeal(int player, PendingTrigger pending, ResolveTriggerCommand command)
|
||
{
|
||
var p = State.Players[player];
|
||
var opponent = State.Players[1 - player];
|
||
if (p.Damage.Count <= opponent.Damage.Count || p.Damage.Count == 0)
|
||
return false;
|
||
|
||
AskDamageChoice(
|
||
player,
|
||
"Выберите карту урона для лечения",
|
||
actualIndex =>
|
||
{
|
||
var card = p.Damage[actualIndex];
|
||
p.Damage.RemoveAt(actualIndex);
|
||
p.Drop.Add(card);
|
||
_events.Add(new HealedEvent(player));
|
||
});
|
||
|
||
var previousContinuation = State.Continuation;
|
||
State.Continuation = () =>
|
||
{
|
||
State.Continuation = previousContinuation;
|
||
CompleteTrigger(pending, command);
|
||
};
|
||
return true;
|
||
}
|
||
|
||
private List<(CardInstance Card, int Index)> DamageDisplayOrder(int player) =>
|
||
State.Players[player].Damage
|
||
.Select((card, index) => (Card: card, Index: index))
|
||
.OrderBy(item => item.Card.FaceUp)
|
||
.ThenBy(item => item.Index)
|
||
.ToList();
|
||
|
||
private void AskDamageChoice(int player, string prompt, Action<int> onChosen)
|
||
{
|
||
var order = DamageDisplayOrder(player);
|
||
|
||
var options = order
|
||
.Select((item, displayIndex) => new ChoiceOption(
|
||
displayIndex,
|
||
item.Card.FaceUp
|
||
? State.Catalog.CardOf(item.Card.PrintingId).NameEn
|
||
: "Закрытая карта"))
|
||
.ToList();
|
||
|
||
State.PendingChoice = new PendingChoice
|
||
{
|
||
Player = player,
|
||
Prompt = prompt,
|
||
Options = options,
|
||
Kind = ChoiceTargetKind.Damage,
|
||
Resolve = displayIndex => onChosen(order[displayIndex].Index),
|
||
};
|
||
State.Step = Step.Choice;
|
||
State.ActivePlayer = player;
|
||
}
|
||
|
||
private void FinishBattle(BattleState battle)
|
||
{
|
||
if (State.IsFinished)
|
||
{
|
||
ClearBattle(battle);
|
||
return;
|
||
}
|
||
|
||
var defenderIndex = 1 - battle.AttackerPlayer;
|
||
var attacker = State.GetUnit(battle.AttackerPlayer, battle.AttackerSlot);
|
||
var target = State.GetUnit(defenderIndex, battle.TargetSlot);
|
||
|
||
if (attacker is null || target is null)
|
||
{
|
||
ClearBattle(battle);
|
||
return;
|
||
}
|
||
|
||
var defenseUnit = battle.Interceptor ?? target;
|
||
|
||
var attackPower = EffectivePower(attacker);
|
||
var defensePower = EffectivePower(defenseUnit) + battle.GuardShield;
|
||
var hit = !battle.Nullified && attackPower > defensePower;
|
||
|
||
_events.Add(new BattleResolvedEvent(hit));
|
||
|
||
if (hit)
|
||
{
|
||
// попадание: сначала «атака, которую поддержала эта карта, попала»,
|
||
// затем «эта карта попала»; каждый шаг может встать на выбор игрока
|
||
void AttackerHitTriggers(CardInstance? hitUnit, Action after)
|
||
{
|
||
TriggerFromCard(battle.AttackerPlayer, attacker, AbilityTrigger.OnAttackHit, hitUnit);
|
||
|
||
if (State.Step is Step.Ability or Step.Choice)
|
||
{
|
||
State.Continuation = after;
|
||
return;
|
||
}
|
||
|
||
after();
|
||
}
|
||
|
||
void BoosterHitTriggers(CardInstance? hitUnit, Action after)
|
||
{
|
||
var booster = battle.BoosterSlot >= 0
|
||
? State.GetUnit(battle.AttackerPlayer, battle.BoosterSlot)
|
||
: null;
|
||
|
||
if (booster is null)
|
||
{
|
||
AttackerHitTriggers(hitUnit, after);
|
||
return;
|
||
}
|
||
|
||
TriggerFromCard(battle.AttackerPlayer, booster, AbilityTrigger.OnBoostedHit, hitUnit);
|
||
|
||
if (State.Step is Step.Ability or Step.Choice)
|
||
{
|
||
State.Continuation = () => AttackerHitTriggers(hitUnit, after);
|
||
return;
|
||
}
|
||
|
||
AttackerHitTriggers(hitUnit, after);
|
||
}
|
||
|
||
if (battle.Interceptor is not null)
|
||
{
|
||
// перехватчик уже вышел из круга и уйдёт в сброс при закрытии боя,
|
||
// первоначальная цель (Авангард или Арьергард) не страдает
|
||
_events.Add(new InterceptorRetiredEvent(defenderIndex, battle.Interceptor.PrintingId));
|
||
}
|
||
else if (battle.TargetSlot < 0)
|
||
{
|
||
var damage = EffectiveCritical(attacker);
|
||
|
||
void AfterDamage() => BoosterHitTriggers(null, () => ClearBattle(battle));
|
||
|
||
void ResolveDamage()
|
||
{
|
||
DealDamage(defenderIndex, damage);
|
||
|
||
// урон ещё разрешается (damage-триггер) — hit-эффекты после него
|
||
if (State.PendingTrigger is not null)
|
||
{
|
||
State.Continuation = AfterDamage;
|
||
return;
|
||
}
|
||
|
||
AfterDamage();
|
||
}
|
||
|
||
ResolveDamage();
|
||
return;
|
||
}
|
||
else
|
||
{
|
||
State.Players[defenderIndex].RearGuards[battle.TargetSlot] = null;
|
||
State.Players[defenderIndex].Drop.Add(target);
|
||
_events.Add(new UnitRetiredEvent(defenderIndex, battle.TargetSlot));
|
||
|
||
void AfterRetire()
|
||
{
|
||
TriggerOpponentRetire(defenderIndex);
|
||
|
||
if (State.Step is Step.Ability or Step.Choice)
|
||
{
|
||
State.Continuation = () => BoosterHitTriggers(target, () => ClearBattle(battle));
|
||
return;
|
||
}
|
||
|
||
BoosterHitTriggers(target, () => ClearBattle(battle));
|
||
}
|
||
|
||
TriggerFromCard(defenderIndex, target, AbilityTrigger.OnRetireFromRC, null);
|
||
|
||
if (State.Step is Step.Ability or Step.Choice)
|
||
{
|
||
State.Continuation = AfterRetire;
|
||
return;
|
||
}
|
||
|
||
AfterRetire();
|
||
return;
|
||
}
|
||
}
|
||
|
||
if (State.PendingTrigger is not null)
|
||
return;
|
||
|
||
ClearBattle(battle);
|
||
}
|
||
|
||
private void ClearBattle(BattleState battle)
|
||
{
|
||
var defenderIndex = 1 - battle.AttackerPlayer;
|
||
var defender = State.Players[defenderIndex];
|
||
|
||
var leftGuardian = new List<CardInstance>();
|
||
foreach (var guard in battle.GuardCards)
|
||
{
|
||
defender.Guardian.Remove(guard);
|
||
defender.Drop.Add(guard);
|
||
leftGuardian.Add(guard);
|
||
}
|
||
|
||
// карты, которые должны вернуться в колоду сразу после боя
|
||
foreach (var player in new[] { 0, 1 })
|
||
{
|
||
foreach (var unit in AllUnits(player).ToList())
|
||
{
|
||
if (unit.ReturnToDeck == ReturnToDeckTiming.AfterBattle)
|
||
ReturnUnitToDeck(player, unit);
|
||
}
|
||
}
|
||
|
||
foreach (var unit in AllUnits(0).Concat(AllUnits(1)))
|
||
{
|
||
unit.Modifiers.RemoveAll(m => m.Duration == ModifierDuration.UntilEndOfBattle);
|
||
unit.GrantedAbilities.RemoveAll(g => g.Duration == ModifierDuration.UntilEndOfBattle);
|
||
unit.Restrictions.RemoveAll(r => r.Duration == ModifierDuration.UntilEndOfBattle);
|
||
}
|
||
|
||
State.Battle = null;
|
||
State.PendingTrigger = null;
|
||
State.Step = Step.None;
|
||
State.ActivePlayer = battle.AttackerPlayer;
|
||
|
||
// бой уже закрыт: эффекты «попала в сброс из (GC)» срабатывают после
|
||
foreach (var guard in leftGuardian)
|
||
TriggerFromCard(defenderIndex, guard, AbilityTrigger.OnLeaveGuardian, null);
|
||
}
|
||
|
||
private void DealDamage(int player, int amount)
|
||
{
|
||
for (var i = 0; i < amount; i++)
|
||
{
|
||
if (State.IsFinished)
|
||
return;
|
||
|
||
var card = TakeTop(player);
|
||
if (card is null)
|
||
return;
|
||
|
||
var def = State.Catalog.CardOf(card.PrintingId);
|
||
if (def.Trigger != TriggerType.None)
|
||
{
|
||
// карта попадает в зону триггера, эффекты срабатывают до урона
|
||
State.Players[player].Trigger.Add(card);
|
||
_events.Add(new TriggerResolvedEvent(player, card.PrintingId, def.Trigger));
|
||
|
||
State.PendingTrigger = new PendingTrigger
|
||
{
|
||
Player = player,
|
||
PrintingId = card.PrintingId,
|
||
Card = card,
|
||
Trigger = def.Trigger,
|
||
Context = TriggerContext.DamageCheck,
|
||
GrantsPower = true,
|
||
RemainingDamage = amount - i - 1,
|
||
};
|
||
State.Step = Step.Trigger;
|
||
State.ActivePlayer = player;
|
||
return;
|
||
}
|
||
|
||
PutIntoDamage(player, card);
|
||
|
||
if (State.IsFinished)
|
||
return;
|
||
}
|
||
}
|
||
|
||
private void PutIntoDamage(int player, CardInstance card)
|
||
{
|
||
card.FaceUp = false;
|
||
var p = State.Players[player];
|
||
p.Damage.Add(card);
|
||
_events.Add(new DamageTakenEvent(player, 1, p.Damage.Count));
|
||
|
||
if (p.Damage.Count >= MaxDamage)
|
||
Defeat(player);
|
||
}
|
||
|
||
/// <summary>Продолжает урон после разрешения триггера damage check.</summary>
|
||
private void FinishDamage(PendingTrigger pending)
|
||
{
|
||
if (State.IsFinished)
|
||
return;
|
||
|
||
var p = State.Players[pending.Player];
|
||
p.Trigger.Remove(pending.Card);
|
||
PutIntoDamage(pending.Player, pending.Card);
|
||
|
||
if (State.IsFinished)
|
||
return;
|
||
|
||
if (pending.RemainingDamage > 0)
|
||
DealDamage(pending.Player, pending.RemainingDamage);
|
||
|
||
if (State.PendingTrigger is not null)
|
||
return;
|
||
|
||
if (State.Continuation is { } continuation)
|
||
{
|
||
State.Continuation = null;
|
||
continuation();
|
||
return;
|
||
}
|
||
|
||
State.Step = Step.None;
|
||
|
||
if (State.Battle is { } battle)
|
||
ClearBattle(battle);
|
||
}
|
||
|
||
// ---------- служебное ----------
|
||
|
||
private CardInstance? TakeTop(int player)
|
||
{
|
||
var deck = State.Players[player].Deck;
|
||
if (deck.Count == 0)
|
||
{
|
||
Defeat(player);
|
||
return null;
|
||
}
|
||
|
||
var card = deck[0];
|
||
deck.RemoveAt(0);
|
||
return card;
|
||
}
|
||
|
||
public void DrawCards(int player, int count) => Draw(player, count);
|
||
|
||
private void Draw(int player, int count)
|
||
{
|
||
for (var i = 0; i < count; i++)
|
||
{
|
||
if (State.IsFinished)
|
||
return;
|
||
|
||
var card = TakeTop(player);
|
||
if (card is null)
|
||
return;
|
||
|
||
State.Players[player].Hand.Add(card);
|
||
_events.Add(new CardDrawnEvent(player));
|
||
}
|
||
}
|
||
|
||
private void Defeat(int player)
|
||
{
|
||
if (State.IsFinished)
|
||
return;
|
||
|
||
State.Players[player].IsDefeated = true;
|
||
State.IsFinished = true;
|
||
State.Winner = 1 - player;
|
||
_events.Add(new GameFinishedEvent(State.Winner.Value));
|
||
}
|
||
|
||
/// <summary>
|
||
/// Завершить партию извне: сдача, истечение времени, отсутствие игрока.
|
||
/// Победа достаётся <paramref name="winner"/>, событие пишется в общий журнал.
|
||
/// </summary>
|
||
public void Finish(int winner)
|
||
{
|
||
if (winner is not (0 or 1))
|
||
throw new ArgumentOutOfRangeException(nameof(winner), winner, "Победитель — 0 или 1.");
|
||
|
||
if (State.IsFinished)
|
||
return;
|
||
|
||
Defeat(1 - winner);
|
||
|
||
// незавершённые выборы больше не имеют смысла: партия закончена
|
||
State.PendingChoice = null;
|
||
State.PendingTrigger = null;
|
||
State.PendingAbility = null;
|
||
State.PendingOrder = null;
|
||
State.PendingQueue = null;
|
||
State.Continuation = null;
|
||
State.Step = Step.None;
|
||
}
|
||
}
|