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

1702 lines
59 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

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

using Vanguard.Rules.Cards;
using Vanguard.Rules.Decks;
using Vanguard.Rules.Engine.Abilities;
namespace Vanguard.Rules.Engine;
/// <summary>
/// Ядро партии. Не знает ни о сети, ни о БД: на вход команды, на выход события.
///
/// Что реализовано и какие есть упрощения:
/// * идеальная защита (Sentinel): щит 0, но при оплате стоимости (сброс карты того же клана)
/// атака полностью отменяется; без оплаты не даёт ничего;
/// * поддержка работает как отдельная способность: карты 0 и 1 ранга могут передать
/// свою силу атакующему Арьергарду из той же колонны, сила действует только в этом бою;
/// * перехват — отдельная способность карт 2 ранга: карта из переднего ряда выходит
/// в круг защиты и при атаке по Авангарду, и при атаке по Арьергарду;
/// атакованный Арьергард не может перехватить атаку на самого себя;
/// из заднего ряда — только если эффект разрешил (InterceptFromBackRow);
/// * число проверок принадлежит атакующей карте: 0–2 ранг — одна, 3+ — две;
/// Арьергард по умолчанию без проверок, но эффект может их выдать;
/// * эффекты попадания срабатывают после нанесения урона и помещения карты в зону урона;
/// * эффекты не вкладываются друг в друга: триггеры, поднятые во время эффекта,
/// копятся в очереди и разрешаются после него;
/// * непрерывные эффекты пересчитываются при каждом запросе (EffectivePower);
/// * триггер требует выбора целей через ResolveTriggerCommand:
/// +5000 любой карте в кругах Авангарда или Арьергарда, затем второй эффект;
/// правило одинаково для drive check и damage check;
/// * порядок проверки: карта → зона триггера → эффекты → рука (drive) или урон (damage);
/// * усиления от триггеров живут до конца хода.
/// </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));
}
}