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