1702 lines
59 KiB
C#
1702 lines
59 KiB
C#
using Vanguard.Rules.Cards;
|
||
using Vanguard.Rules.Decks;
|
||
using Vanguard.Rules.Engine.Abilities;
|
||
|
||
namespace Vanguard.Rules.Engine;
|
||
|
||
/// <summary>
|
||
/// Ядро партии. Не знает ни о сети, ни о БД: на вход команды, на выход события.
|
||
///
|
||
/// Что реализовано и какие есть упрощения:
|
||
/// * идеальная защита (Sentinel): щит 0, но при оплате стоимости (сброс карты того же клана)
|
||
/// атака полностью отменяется; без оплаты не даёт ничего;
|
||
/// * поддержка работает как отдельная способность: карты 0 и 1 ранга могут передать
|
||
/// свою силу атакующему Арьергарду из той же колонны, сила действует только в этом бою;
|
||
/// * перехват — отдельная способность карт 2 ранга: карта из переднего ряда выходит
|
||
/// в круг защиты и при атаке по Авангарду, и при атаке по Арьергарду;
|
||
/// атакованный Арьергард не может перехватить атаку на самого себя;
|
||
/// из заднего ряда — только если эффект разрешил (InterceptFromBackRow);
|
||
/// * число проверок принадлежит атакующей карте: 0–2 ранг — одна, 3+ — две;
|
||
/// Арьергард по умолчанию без проверок, но эффект может их выдать;
|
||
/// * эффекты попадания срабатывают после нанесения урона и помещения карты в зону урона;
|
||
/// * эффекты не вкладываются друг в друга: триггеры, поднятые во время эффекта,
|
||
/// копятся в очереди и разрешаются после него;
|
||
/// * непрерывные эффекты пересчитываются при каждом запросе (EffectivePower);
|
||
/// * триггер требует выбора целей через ResolveTriggerCommand:
|
||
/// +5000 любой карте в кругах Авангарда или Арьергарда, затем второй эффект;
|
||
/// правило одинаково для drive check и damage check;
|
||
/// * порядок проверки: карта → зона триггера → эффекты → рука (drive) или урон (damage);
|
||
/// * усиления от триггеров живут до конца хода.
|
||
/// </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)
|
||
{
|
||
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<GameEvent> Events => _events;
|
||
public IReadOnlyDictionary<string, CardScript> Scripts => _scripts;
|
||
|
||
/// <summary>Глубина разрешения эффекта: пока > 0, новые триггеры только ставятся в очередь.</summary>
|
||
private int _resolvingDepth;
|
||
|
||
/// <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 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<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 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();
|
||
}
|
||
|
||
/// <summary>Сила карты с учётом непрерывных эффектов. Пересчитывается при каждом запросе.</summary>
|
||
public int EffectivePower(CardInstance unit)
|
||
{
|
||
var total = State.Catalog.CardOf(unit.PrintingId).Power + unit.TemporaryPower;
|
||
return total + ContinuousPower(unit);
|
||
}
|
||
|
||
private int ContinuousPower(CardInstance unit)
|
||
{
|
||
var owner = OwnerOf(unit);
|
||
if (owner < 0)
|
||
return 0;
|
||
|
||
var nameId = State.Catalog.CardOf(unit.PrintingId).NameId;
|
||
if (!_scripts.TryGetValue(nameId, out var script))
|
||
return 0;
|
||
|
||
var context = new AbilityContext
|
||
{
|
||
Engine = this,
|
||
Source = unit,
|
||
Owner = owner,
|
||
Phase = State.Phase,
|
||
};
|
||
|
||
return script.Continuous
|
||
.Where(c => c.Applies(context))
|
||
.Sum(c => c.PowerBonus?.Invoke(context) ?? 0);
|
||
}
|
||
|
||
public int OwnerOf(CardInstance unit)
|
||
{
|
||
for (var i = 0; i < 2; i++)
|
||
{
|
||
if (ReferenceEquals(State.Players[i].Vanguard, unit))
|
||
return i;
|
||
|
||
if (State.Players[i].RearGuards.Any(u => ReferenceEquals(u, unit)))
|
||
return i;
|
||
}
|
||
|
||
return -1;
|
||
}
|
||
|
||
public bool IsVanguardOf(CardInstance unit, int player) =>
|
||
ReferenceEquals(State.Players[player].Vanguard, unit);
|
||
|
||
public int CountRearGuards(int player, Clan clan) =>
|
||
State.Players[player].RearGuards.Count(u =>
|
||
u is not null && State.Catalog.CardOf(u.PrintingId).Clan == clan);
|
||
|
||
/// <summary>Призыв карты из души — используется способностями вроде Barcgal.</summary>
|
||
public bool CallFromSoul(CardInstance card, int player)
|
||
{
|
||
var p = State.Players[player];
|
||
if (!p.Soul.Contains(card))
|
||
return false;
|
||
|
||
var slot = Array.FindIndex(p.RearGuards, u => u is null);
|
||
if (slot < 0)
|
||
return false;
|
||
|
||
p.Soul.Remove(card);
|
||
card.Rested = true;
|
||
p.RearGuards[slot] = card;
|
||
|
||
_events.Add(new UnitCalledEvent(player, slot, card.PrintingId));
|
||
return true;
|
||
}
|
||
|
||
/// <summary>Бонус к силе текущего боя от способности карты.</summary>
|
||
public void AddBattlePower(int amount)
|
||
{
|
||
if (State.Battle is { } battle)
|
||
battle.AbilityPower += amount;
|
||
}
|
||
|
||
// ---------- стоимости ----------
|
||
|
||
public 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);
|
||
}
|
||
}
|
||
|
||
// ---------- выбор целей ----------
|
||
|
||
/// <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;
|
||
}
|
||
|
||
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));
|
||
}
|
||
|
||
// ---------- поиск в колоде ----------
|
||
|
||
/// <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));
|
||
AskEmptyRearGuardChoice(player, "Выберите круг Арьергарда", slot => 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)
|
||
return;
|
||
|
||
card.Rested = true;
|
||
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)
|
||
{
|
||
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 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.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();
|
||
}
|
||
|
||
/// <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("Юнит уже повёрнут.");
|
||
|
||
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<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>Усиления от триггеров живут до конца хода, а не до конца боя.</summary>
|
||
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);
|
||
}
|
||
|
||
/// <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--;
|
||
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);
|
||
}
|
||
|
||
/// <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 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));
|
||
}
|
||
}
|