VanguardReMaster/server/src/Vanguard.Rules/Engine/GameEngine.cs

2785 lines
98 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
}
}