using Vanguard.Rules.Cards;
using Vanguard.Rules.Decks;
using Vanguard.Rules.Engine.Abilities;
namespace Vanguard.Rules.Engine;
///
/// Ядро партии. Не знает ни о сети, ни о БД: на вход команды, на выход события.
///
/// Что реализовано и какие есть упрощения:
/// * идеальная защита (Sentinel): щит 0, но при оплате стоимости (сброс карты того же клана)
/// атака полностью отменяется; без оплаты не даёт ничего;
/// * поддержка работает как отдельная способность: карты 0 и 1 ранга могут передать
/// свою силу атакующему Арьергарду из той же колонны, сила действует только в этом бою;
/// * перехват — отдельная способность карт 2 ранга: карта из переднего ряда выходит
/// в круг защиты и при атаке по Авангарду, и при атаке по Арьергарду;
/// атакованный Арьергард не может перехватить атаку на самого себя;
/// из заднего ряда — только если эффект разрешил (InterceptFromBackRow);
/// * число проверок принадлежит атакующей карте: 0–2 ранг — одна, 3+ — две;
/// Арьергард по умолчанию без проверок, но эффект может их выдать;
/// * эффекты попадания срабатывают после нанесения урона и помещения карты в зону урона;
/// * эффекты не вкладываются друг в друга: триггеры, поднятые во время эффекта,
/// копятся в очереди и разрешаются после него;
/// * непрерывные эффекты пересчитываются при каждом запросе (EffectivePower);
/// * триггер требует выбора целей через ResolveTriggerCommand:
/// +5000 любой карте в кругах Авангарда или Арьергарда, затем второй эффект;
/// правило одинаково для drive check и damage check;
/// * порядок проверки: карта → зона триггера → эффекты → рука (drive) или урон (damage);
/// * усиления от триггеров живут до конца хода.
///
public sealed class GameEngine
{
public const int StartingHand = 5;
public const int MaxDamage = 6;
public const int TriggerPowerBonus = 5000;
private readonly List _events = new();
private readonly IReadOnlyDictionary _scripts;
public GameEngine(
CardCatalog catalog,
ulong seed,
IReadOnlyList deck0,
IReadOnlyList deck1,
IReadOnlyDictionary? scripts = null)
{
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, "Player 1", "Player 2");
FillDeck(0, deck0);
FillDeck(1, deck1);
}
public GameState State { get; }
public IReadOnlyList Events => _events;
public IReadOnlyDictionary Scripts => _scripts;
/// Глубина разрешения эффекта: пока > 0, новые триггеры только ставятся в очередь.
private int _resolvingDepth;
///
/// Сколько проверок делает атакующая карта. Эффект может изменить число;
/// Арьергард по умолчанию проверок не делает.
///
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 nameId = State.Catalog.CardOf(source.PrintingId).NameId;
if (!_scripts.TryGetValue(nameId, out var script))
return;
var context = new AbilityContext
{
Engine = this,
Source = source,
Owner = owner,
Other = other,
Phase = State.Phase,
};
var triggered = script.Abilities
.Where(a => a.Trigger == trigger && (a.Condition is null || a.Condition(context)))
.Select(a => new TriggeredAbility { Definition = a, Context = context })
.ToList();
if (triggered.Count == 0)
return;
RaiseBatch(triggered);
}
private void RaiseBatch(List triggered)
{
if (triggered.Count == 0)
return;
// внутри другого эффекта триггеры только копятся
if (_resolvingDepth > 0)
{
State.PendingQueue ??= new List();
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;
}
/// Собирает срабатывания со всех карт на поле, активный игрок первым.
private void TriggerAll(AbilityTrigger trigger)
{
var triggered = new List();
for (var owner = 0; owner < 2; owner++)
{
foreach (var unit in AllUnits(owner))
{
var nameId = State.Catalog.CardOf(unit.PrintingId).NameId;
if (!_scripts.TryGetValue(nameId, out var script))
continue;
var context = new AbilityContext
{
Engine = this,
Source = unit,
Owner = owner,
Phase = State.Phase,
};
triggered.AddRange(script.Abilities
.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);
}
private void EnterMainPhase()
{
State.Phase = Phase.Main;
_events.Add(new PhaseChangedEvent(State.ActivePlayer, Phase.Main));
TriggerAll(AbilityTrigger.OnPhaseStart);
}
private void OfferAbility(TriggeredAbility ability)
{
var definition = ability.Definition;
// если стоимость оплатить нельзя, способность не предлагается
if (definition.CanPay is not null && !definition.CanPay(ability.Context))
return;
_events.Add(new AbilityTriggeredEvent(ability.Owner, ability.Context.Source.PrintingId, definition.Trigger));
if (definition.Optional)
{
State.PendingAbility = ability;
State.Step = Step.Ability;
State.ActivePlayer = ability.Owner;
return;
}
ResolveAbility(ability, accepted: true);
}
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;
OfferAbility(next);
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)
{
if (State.Phase != Phase.Main)
return CommandResult.Reject("Активируемые способности доступны только в Основной фазе.");
if (State.Step != Step.None || State.PendingAbility is not null || State.PendingChoice is not null)
return CommandResult.Reject("Сейчас нельзя активировать способности.");
var player = State.ActivePlayer;
var p = State.Players[player];
var unit = AllUnits(player).FirstOrDefault(u => u.Id == instanceId);
if (unit is null)
return CommandResult.Reject("Карта не на поле.");
var nameId = State.Catalog.CardOf(unit.PrintingId).NameId;
if (!_scripts.TryGetValue(nameId, out var script))
return CommandResult.Reject("У карты нет способностей.");
var context = new AbilityContext
{
Engine = this,
Source = unit,
Owner = player,
Phase = State.Phase,
};
var ability = script.Activated.FirstOrDefault(a => a.CanActivate(context));
if (ability is null)
return CommandResult.Reject("Нет доступной способности.");
if (ability.RestsSelf && unit.Rested)
return CommandResult.Reject("Карта уже повёрнута.");
CardInstance? discard = null;
if (ability.RequiresDiscard)
{
if (discardPrintingId is null)
return CommandResult.Reject("Нужно выбрать карту для сброса.");
var index = p.Hand.FindIndex(c => c.PrintingId == discardPrintingId);
if (index < 0)
return CommandResult.Reject("Карты для сброса нет в руке.");
discard = p.Hand[index];
}
if (ability.RestsSelf)
unit.Rested = true;
if (discard is not null)
{
p.Hand.Remove(discard);
p.Drop.Add(discard);
}
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));
}
ability.Resolve(context);
_events.Add(new AbilityResolvedEvent(player, unit.PrintingId, true));
return CommandResult.Ok();
}
private CommandResult Choose(int index)
{
var choice = State.PendingChoice;
if (choice is null || State.Step != Step.Choice)
return CommandResult.Reject("Сейчас нет выбора.");
if (State.ActivePlayer != choice.Player)
return CommandResult.Reject("Сейчас выбирает другой игрок.");
if (index < 0 || index >= choice.Options.Count)
return CommandResult.Reject("Некорректный вариант выбора.");
var option = choice.Options[index];
State.PendingChoice = null;
State.Step = Step.None;
_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;
OfferAbility(chosen);
return CommandResult.Ok();
}
private CommandResult ResolvePendingAbility(bool accept)
{
var ability = State.PendingAbility;
if (ability is null || State.Step != Step.Ability)
return CommandResult.Reject("Сейчас нет способности, ожидающей решения.");
State.PendingAbility = null;
ResolveAbility(ability, accept);
return CommandResult.Ok();
}
/// Сила карты с учётом непрерывных эффектов. Пересчитывается при каждом запросе.
public int EffectivePower(CardInstance unit)
{
var total = State.Catalog.CardOf(unit.PrintingId).Power + unit.TemporaryPower;
return total + ContinuousPower(unit);
}
private int ContinuousPower(CardInstance unit)
{
var owner = OwnerOf(unit);
if (owner < 0)
return 0;
var nameId = State.Catalog.CardOf(unit.PrintingId).NameId;
if (!_scripts.TryGetValue(nameId, out var script))
return 0;
var context = new AbilityContext
{
Engine = this,
Source = unit,
Owner = owner,
Phase = State.Phase,
};
return script.Continuous
.Where(c => c.Applies(context))
.Sum(c => c.PowerBonus?.Invoke(context) ?? 0);
}
public int OwnerOf(CardInstance unit)
{
for (var i = 0; i < 2; i++)
{
if (ReferenceEquals(State.Players[i].Vanguard, unit))
return i;
if (State.Players[i].RearGuards.Any(u => ReferenceEquals(u, unit)))
return i;
}
return -1;
}
public bool IsVanguardOf(CardInstance unit, int player) =>
ReferenceEquals(State.Players[player].Vanguard, unit);
public int CountRearGuards(int player, Clan clan) =>
State.Players[player].RearGuards.Count(u =>
u is not null && State.Catalog.CardOf(u.PrintingId).Clan == clan);
/// Призыв карты из души — используется способностями вроде Barcgal.
public bool CallFromSoul(CardInstance card, int player)
{
var p = State.Players[player];
if (!p.Soul.Contains(card))
return false;
var slot = Array.FindIndex(p.RearGuards, u => u is null);
if (slot < 0)
return false;
p.Soul.Remove(card);
card.Rested = true;
p.RearGuards[slot] = card;
_events.Add(new UnitCalledEvent(player, slot, card.PrintingId));
return true;
}
/// Бонус к силе текущего боя от способности карты.
public void AddBattlePower(int amount)
{
if (State.Battle is { } battle)
battle.AbilityPower += amount;
}
// ---------- стоимости ----------
public int FaceDownDamage(int player) =>
State.Players[player].Damage.Count(c => !c.FaceUp);
public bool CanCounterBlast(int player, int amount) =>
FaceDownDamage(player) >= amount;
public void CounterBlast(int player, int amount)
{
var flipped = 0;
foreach (var card in State.Players[player].Damage)
{
if (flipped == amount)
break;
if (card.FaceUp)
continue;
card.FaceUp = true;
flipped++;
}
}
public bool CanSoulBlast(int player, int amount) =>
State.Players[player].Soul.Count >= amount;
public void 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);
}
}
// ---------- выбор целей ----------
/// Круги Арьергарда игрока, подходящие под фильтр.
public IReadOnlyList RearGuardSlots(int player, Func predicate)
{
var slots = new List();
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;
}
/// Останавливает разрешение и просит игрока выбрать карту на поле.
public void AskUnitChoice(
int chooser,
int targetPlayer,
string prompt,
IReadOnlyList slots,
Action onChosen)
{
var options = slots
.Select(slot =>
{
var unit = State.GetUnit(targetPlayer, slot)!;
return new ChoiceOption(slot, State.Catalog.CardOf(unit.PrintingId).NameEn);
})
.ToList();
State.PendingChoice = new PendingChoice
{
Player = chooser,
Prompt = prompt,
Options = options,
Resolve = onChosen,
};
State.Step = Step.Choice;
State.ActivePlayer = chooser;
}
public void RetireRearGuard(int player, int slot)
{
var unit = State.Players[player].RearGuards[slot];
if (unit is null)
return;
State.Players[player].RearGuards[slot] = null;
State.Players[player].Drop.Add(unit);
_events.Add(new UnitRetiredEvent(player, slot));
}
// ---------- поиск в колоде ----------
///
/// Серверный поиск: игрок не видит колоду. Найдена одна карта — сразу передаётся
/// в onFound; несколько — предлагается выбор (разные арты считаются разными картами);
/// ни одной — уведомление. После поиска колода всегда перемешивается.
///
public void SearchDeck(
int player,
string prompt,
Func predicate,
Action 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 onFound)
{
State.Players[player].Deck.Remove(card);
onFound(card);
_events.Add(new CardSearchedEvent(player, card.PrintingId));
ShuffleDeck(player);
}
/// Поиск с добавлением карты в руку.
public void SearchDeckToHand(int player, string prompt, Func predicate) =>
SearchDeck(player, prompt, predicate, card =>
{
_events.Add(new CardRevealedEvent(player, card.PrintingId));
State.Players[player].Hand.Add(card);
});
/// Поиск с призывом в выбранный круг Арьергарда.
public void SearchDeckToCall(int player, string prompt, Func predicate)
{
SearchDeck(player, prompt, predicate, card =>
{
_events.Add(new CardRevealedEvent(player, card.PrintingId));
AskEmptyRearGuardChoice(player, "Выберите круг Арьергарда", slot => CallFromDeck(card, player, slot));
});
}
/// Поиск с райдом найденной карты (superior ride).
public void SearchDeckToRide(int player, string prompt, Func predicate)
{
SearchDeck(player, prompt, predicate, card =>
{
_events.Add(new CardRevealedEvent(player, card.PrintingId));
RideFromDeck(card, player);
});
}
/// Посмотреть верхнюю карту и оставить её на верху или отправить в низ.
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
{
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);
/// Выбор карты из руки (стоимость, сброс, цель эффекта).
public void AskHandCardChoice(
int player,
string prompt,
Func? predicate,
Action 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 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)
return;
card.Rested = true;
p.RearGuards[slot] = card;
_events.Add(new UnitCalledEvent(player, slot, card.PrintingId));
TriggerFromCard(player, card, AbilityTrigger.OnPlace, null);
}
/// Райд картой не из руки (superior ride).
public void RideFromDeck(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);
}
///
/// Останавливает разрешение и просит выбрать карту из найденных.
/// Варианты различаются номером принта, поэтому два Blaster Blade с разными артами — два варианта.
///
public void AskCardChoice(
int player,
string prompt,
IReadOnlyList cards,
Action 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 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 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.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),
ChooseCommand c => Choose(c.Index),
ActivateAbilityCommand c => Activate(c.InstanceId, c.DiscardPrintingId),
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 = true;
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();
}
/// Колонки: 0↔3 (левая), 2↔4 (правая). У центрального заднего ряда пары нет.
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("Юнит уже повёрнут.");
var attackerDef = State.Catalog.CardOf(attacker.PrintingId);
if (attackerDef.IsRestraint)
return CommandResult.Reject("Юнит со Сдерживанием не может атаковать.");
var target = State.GetUnit(1 - State.ActivePlayer, targetSlot);
if (target is null)
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,
};
if (booster is not null)
{
booster.Rested = true;
State.Battle.BoostPower += State.Catalog.CardOf(booster.PrintingId).Power;
_events.Add(new BoostedEvent(attackerPlayer, boosterSlot, attackerSlot));
TriggerFromCard(attackerPlayer, booster, AbilityTrigger.OnBoost, attacker);
}
var defenderIndex = 1 - attackerPlayer;
_events.Add(new AttackDeclaredEvent(attackerPlayer, attackerSlot, targetSlot));
void BeginGuard()
{
State.Step = Step.Guard;
State.ActivePlayer = defenderIndex;
}
TriggerFromCard(attackerPlayer, attacker, AbilityTrigger.OnAttackDeclared, null);
if (State.Step is Step.Ability or Step.Choice)
State.Continuation = BeginGuard;
else
BeginGuard();
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));
}
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));
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))
unit.Rested = false;
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 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;
}
}
/// Усиления от триггеров живут до конца хода, а не до конца боя.
private void ClearTurnModifiers()
{
foreach (var unit in AllUnits(0).Concat(AllUnits(1)))
{
unit.TemporaryPower = 0;
unit.TemporaryCritical = 0;
}
}
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);
}
///
/// Идёт по drive-чекам. На триггере останавливается и ждёт ResolveTriggerCommand:
/// игрок сам выбирает, кому дать +5000 и куда применить второй эффект.
///
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--;
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)
return CommandResult.Reject("Поднять можно только карту в кругу Арьергарда.");
}
if (pending.GrantsPower)
State.GetUnit(player, command.PowerTarget)!.TemporaryPower += TriggerPowerBonus;
switch (pending.Trigger)
{
case TriggerType.Critical:
State.GetUnit(player, command.EffectTarget)!.TemporaryCritical += 1;
break;
case TriggerType.Stand:
State.GetUnit(player, command.EffectTarget)!.Rested = false;
break;
case TriggerType.Draw:
Draw(player, 1);
break;
case TriggerType.Heal:
Heal(player);
break;
}
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 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 attackerDef = State.Catalog.CardOf(attacker.PrintingId);
var defenseUnit = battle.Interceptor ?? target;
var attackPower = EffectivePower(attacker) + battle.BoostPower + battle.AbilityPower;
var defensePower = EffectivePower(defenseUnit) + battle.GuardShield;
var hit = !battle.Nullified && attackPower > defensePower;
_events.Add(new BattleResolvedEvent(hit));
if (hit)
{
if (battle.Interceptor is not null)
{
// перехватчик уже вышел из круга и уйдёт в сброс при закрытии боя,
// первоначальная цель (Авангард или Арьергард) не страдает
_events.Add(new InterceptorRetiredEvent(defenderIndex, battle.Interceptor.PrintingId));
}
else if (battle.TargetSlot < 0)
{
var damage = attackerDef.Critical + attacker.TemporaryCritical;
void AfterDamage()
{
TriggerFromCard(battle.AttackerPlayer, attacker, AbilityTrigger.OnAttackHit, null);
if (State.Step is Step.Ability or Step.Choice)
{
State.Continuation = () => ClearBattle(battle);
return;
}
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));
}
}
if (State.PendingTrigger is not null)
return;
ClearBattle(battle);
}
private void ClearBattle(BattleState battle)
{
var defender = State.Players[1 - battle.AttackerPlayer];
foreach (var guard in battle.GuardCards)
{
defender.Guardian.Remove(guard);
defender.Drop.Add(guard);
}
State.Battle = null;
State.PendingTrigger = null;
State.Step = Step.None;
State.ActivePlayer = battle.AttackerPlayer;
}
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);
}
/// Продолжает урон после разрешения триггера damage check.
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 void Heal(int player)
{
var p = State.Players[player];
var opponent = State.Players[1 - player];
if (p.Damage.Count <= opponent.Damage.Count || p.Damage.Count == 0)
return;
var card = p.Damage[^1];
p.Damage.RemoveAt(p.Damage.Count - 1);
p.Drop.Add(card);
_events.Add(new HealedEvent(player));
}
// ---------- служебное ----------
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));
}
}