using Vanguard.Rules.Cards; using Vanguard.Rules.Decks; using Vanguard.Rules.Engine.Abilities; namespace Vanguard.Rules.Engine; /// /// Ядро партии. Не знает ни о сети, ни о БД: на вход команды, на выход события. /// /// Что реализовано и какие есть упрощения: /// * идеальная защита (Sentinel): щит 0, но при оплате стоимости (сброс карты того же клана) /// атака полностью отменяется; без оплаты не даёт ничего; /// * поддержка работает как отдельная способность: карты 0 и 1 ранга могут передать /// свою силу атакующему Арьергарду из той же колонны, сила действует только в этом бою; /// * перехват — отдельная способность карт 2 ранга: карта из переднего ряда выходит /// в круг защиты и при атаке по Авангарду, и при атаке по Арьергарду; /// атакованный Арьергард не может перехватить атаку на самого себя; /// из заднего ряда — только если эффект разрешил (InterceptFromBackRow); /// * число проверок принадлежит атакующей карте: 0–2 ранг — одна, 3+ — две; /// Арьергард по умолчанию без проверок, но эффект может их выдать; /// * эффекты попадания срабатывают после нанесения урона и помещения карты в зону урона; /// * эффекты не вкладываются друг в друга: триггеры, поднятые во время эффекта, /// копятся в очереди и разрешаются после него; /// * непрерывные эффекты пересчитываются при каждом запросе (EffectivePower); /// * триггер требует выбора целей через ResolveTriggerCommand: /// +5000 любой карте в кругах Авангарда или Арьергарда, затем второй эффект; /// правило одинаково для drive check и damage check; /// * порядок проверки: карта → зона триггера → эффекты → рука (drive) или урон (damage); /// * усиления от триггеров живут до конца хода. /// public sealed class GameEngine { public const int StartingHand = 5; public const int MaxDamage = 6; public const int TriggerPowerBonus = 5000; private readonly List _events = new(); private readonly IReadOnlyDictionary _scripts; public GameEngine( CardCatalog catalog, ulong seed, IReadOnlyList deck0, IReadOnlyList deck1, IReadOnlyDictionary? scripts = null) { var violations = DeckValidator.Validate(catalog, deck0.ToList()) .Concat(DeckValidator.Validate(catalog, deck1.ToList())) .ToList(); if (violations.Count > 0) throw new InvalidOperationException( "Некорректная колода: " + string.Join("; ", violations.Select(v => v.Message))); _scripts = scripts ?? CardScripts.Default; State = new GameState(catalog, seed, "Player 1", "Player 2"); FillDeck(0, deck0); FillDeck(1, deck1); } public GameState State { get; } public IReadOnlyList Events => _events; public IReadOnlyDictionary Scripts => _scripts; /// Глубина разрешения эффекта: пока > 0, новые триггеры только ставятся в очередь. private int _resolvingDepth; /// /// Сколько проверок делает атакующая карта. Эффект может изменить число; /// Арьергард по умолчанию проверок не делает. /// public int DriveChecksFor(int slot, CardInstance attacker) { if (attacker.DriveChecksOverride is { } overridden) return Math.Max(0, overridden); return slot < 0 ? State.Catalog.CardOf(attacker.PrintingId).DefaultDriveChecks : 0; } // ---------- способности ---------- private void TriggerFromCard(int owner, CardInstance? source, AbilityTrigger trigger, CardInstance? other) { if (source is null) return; var nameId = State.Catalog.CardOf(source.PrintingId).NameId; if (!_scripts.TryGetValue(nameId, out var script)) return; var context = new AbilityContext { Engine = this, Source = source, Owner = owner, Other = other, Phase = State.Phase, }; var triggered = script.Abilities .Where(a => a.Trigger == trigger && (a.Condition is null || a.Condition(context))) .Select(a => new TriggeredAbility { Definition = a, Context = context }) .ToList(); if (triggered.Count == 0) return; RaiseBatch(triggered); } private void RaiseBatch(List triggered) { if (triggered.Count == 0) return; // внутри другого эффекта триггеры только копятся if (_resolvingDepth > 0) { State.PendingQueue ??= new List(); State.PendingQueue.AddRange(triggered); return; } if (triggered.Count == 1) { OfferAbility(triggered[0]); return; } State.PendingOrder = triggered; State.Step = Step.Ability; State.ActivePlayer = triggered[0].Owner; } /// Собирает срабатывания со всех карт на поле, активный игрок первым. private void TriggerAll(AbilityTrigger trigger) { var triggered = new List(); for (var owner = 0; owner < 2; owner++) { foreach (var unit in AllUnits(owner)) { var nameId = State.Catalog.CardOf(unit.PrintingId).NameId; if (!_scripts.TryGetValue(nameId, out var script)) continue; var context = new AbilityContext { Engine = this, Source = unit, Owner = owner, Phase = State.Phase, }; triggered.AddRange(script.Abilities .Where(a => a.Trigger == trigger && (a.Condition is null || a.Condition(context))) .Select(a => new TriggeredAbility { Definition = a, Context = context })); } } if (triggered.Count == 0) return; triggered = triggered .OrderBy(t => t.Owner == State.ActivePlayer ? 0 : 1) .ToList(); RaiseBatch(triggered); } private void EnterMainPhase() { State.Phase = Phase.Main; _events.Add(new PhaseChangedEvent(State.ActivePlayer, Phase.Main)); TriggerAll(AbilityTrigger.OnPhaseStart); } private void OfferAbility(TriggeredAbility ability) { var definition = ability.Definition; // если стоимость оплатить нельзя, способность не предлагается if (definition.CanPay is not null && !definition.CanPay(ability.Context)) return; _events.Add(new AbilityTriggeredEvent(ability.Owner, ability.Context.Source.PrintingId, definition.Trigger)); if (definition.Optional) { State.PendingAbility = ability; State.Step = Step.Ability; State.ActivePlayer = ability.Owner; return; } ResolveAbility(ability, accepted: true); } private void ResolveAbility(TriggeredAbility ability, bool accepted) { if (accepted) { _resolvingDepth++; try { ability.Definition.Pay?.Invoke(ability.Context); ability.Definition.Resolve(ability.Context); } finally { _resolvingDepth--; } } _events.Add(new AbilityResolvedEvent(ability.Owner, ability.Context.Source.PrintingId, accepted)); // способность попросила выбрать цель — ждём ChooseCommand if (State.Step == Step.Choice) return; ContinueOrder(); } private void ContinueOrder() { // сначала доигрываем триггеры, накопившиеся во время эффекта if (State.PendingQueue is { Count: > 0 } queued && State.PendingOrder is null && State.PendingAbility is null) { var batch = queued.ToList(); State.PendingQueue = null; RaiseBatch(batch); return; } if (State.PendingOrder is { Count: > 0 } order) { if (order.Count == 1) { var next = order[0]; State.PendingOrder = null; State.ActivePlayer = next.Owner; OfferAbility(next); return; } State.Step = Step.Ability; State.ActivePlayer = order[0].Owner; return; } State.Step = Step.None; if (State.Continuation is { } continuation) { State.Continuation = null; continuation(); } } private CommandResult Activate(int instanceId, string? discardPrintingId) { if (State.Phase != Phase.Main) return CommandResult.Reject("Активируемые способности доступны только в Основной фазе."); if (State.Step != Step.None || State.PendingAbility is not null || State.PendingChoice is not null) return CommandResult.Reject("Сейчас нельзя активировать способности."); var player = State.ActivePlayer; var p = State.Players[player]; var unit = AllUnits(player).FirstOrDefault(u => u.Id == instanceId); if (unit is null) return CommandResult.Reject("Карта не на поле."); var nameId = State.Catalog.CardOf(unit.PrintingId).NameId; if (!_scripts.TryGetValue(nameId, out var script)) return CommandResult.Reject("У карты нет способностей."); var context = new AbilityContext { Engine = this, Source = unit, Owner = player, Phase = State.Phase, }; var ability = script.Activated.FirstOrDefault(a => a.CanActivate(context)); if (ability is null) return CommandResult.Reject("Нет доступной способности."); if (ability.RestsSelf && unit.Rested) return CommandResult.Reject("Карта уже повёрнута."); CardInstance? discard = null; if (ability.RequiresDiscard) { if (discardPrintingId is null) return CommandResult.Reject("Нужно выбрать карту для сброса."); var index = p.Hand.FindIndex(c => c.PrintingId == discardPrintingId); if (index < 0) return CommandResult.Reject("Карты для сброса нет в руке."); discard = p.Hand[index]; } if (ability.RestsSelf) unit.Rested = true; if (discard is not null) { p.Hand.Remove(discard); p.Drop.Add(discard); } if (ability.RetiresSelf) { var slot = Array.IndexOf(p.RearGuards, unit); if (slot < 0) return CommandResult.Reject("Уничтожить себя можно только из круга Арьергарда."); p.RearGuards[slot] = null; p.Drop.Add(unit); _events.Add(new UnitRetiredEvent(player, slot)); } ability.Resolve(context); _events.Add(new AbilityResolvedEvent(player, unit.PrintingId, true)); return CommandResult.Ok(); } private CommandResult Choose(int index) { var choice = State.PendingChoice; if (choice is null || State.Step != Step.Choice) return CommandResult.Reject("Сейчас нет выбора."); if (State.ActivePlayer != choice.Player) return CommandResult.Reject("Сейчас выбирает другой игрок."); if (index < 0 || index >= choice.Options.Count) return CommandResult.Reject("Некорректный вариант выбора."); var option = choice.Options[index]; State.PendingChoice = null; State.Step = Step.None; _resolvingDepth++; try { choice.Resolve(option.Slot); } finally { _resolvingDepth--; } if (State.Step != Step.Choice) ContinueOrder(); return CommandResult.Ok(); } private CommandResult SelectTrigger(int index) { var order = State.PendingOrder; if (order is null || State.Step != Step.Ability) return CommandResult.Reject("Сейчас нет выбора порядка триггеров."); if (index < 0 || index >= order.Count) return CommandResult.Reject("Некорректный индекс."); if (order[index].Owner != State.ActivePlayer) return CommandResult.Reject("Сейчас выбирает другой игрок."); var chosen = order[index]; order.RemoveAt(index); if (order.Count == 0) State.PendingOrder = null; OfferAbility(chosen); return CommandResult.Ok(); } private CommandResult ResolvePendingAbility(bool accept) { var ability = State.PendingAbility; if (ability is null || State.Step != Step.Ability) return CommandResult.Reject("Сейчас нет способности, ожидающей решения."); State.PendingAbility = null; ResolveAbility(ability, accept); return CommandResult.Ok(); } /// Сила карты с учётом непрерывных эффектов. Пересчитывается при каждом запросе. public int EffectivePower(CardInstance unit) { var total = State.Catalog.CardOf(unit.PrintingId).Power + unit.TemporaryPower; return total + ContinuousPower(unit); } private int ContinuousPower(CardInstance unit) { var owner = OwnerOf(unit); if (owner < 0) return 0; var nameId = State.Catalog.CardOf(unit.PrintingId).NameId; if (!_scripts.TryGetValue(nameId, out var script)) return 0; var context = new AbilityContext { Engine = this, Source = unit, Owner = owner, Phase = State.Phase, }; return script.Continuous .Where(c => c.Applies(context)) .Sum(c => c.PowerBonus?.Invoke(context) ?? 0); } public int OwnerOf(CardInstance unit) { for (var i = 0; i < 2; i++) { if (ReferenceEquals(State.Players[i].Vanguard, unit)) return i; if (State.Players[i].RearGuards.Any(u => ReferenceEquals(u, unit))) return i; } return -1; } public bool IsVanguardOf(CardInstance unit, int player) => ReferenceEquals(State.Players[player].Vanguard, unit); public int CountRearGuards(int player, Clan clan) => State.Players[player].RearGuards.Count(u => u is not null && State.Catalog.CardOf(u.PrintingId).Clan == clan); /// Призыв карты из души — используется способностями вроде Barcgal. public bool CallFromSoul(CardInstance card, int player) { var p = State.Players[player]; if (!p.Soul.Contains(card)) return false; var slot = Array.FindIndex(p.RearGuards, u => u is null); if (slot < 0) return false; p.Soul.Remove(card); card.Rested = true; p.RearGuards[slot] = card; _events.Add(new UnitCalledEvent(player, slot, card.PrintingId)); return true; } /// Бонус к силе текущего боя от способности карты. public void AddBattlePower(int amount) { if (State.Battle is { } battle) battle.AbilityPower += amount; } // ---------- стоимости ---------- public int FaceDownDamage(int player) => State.Players[player].Damage.Count(c => !c.FaceUp); public bool CanCounterBlast(int player, int amount) => FaceDownDamage(player) >= amount; public void CounterBlast(int player, int amount) { var flipped = 0; foreach (var card in State.Players[player].Damage) { if (flipped == amount) break; if (card.FaceUp) continue; card.FaceUp = true; flipped++; } } public bool CanSoulBlast(int player, int amount) => State.Players[player].Soul.Count >= amount; public void SoulCharge(int player, int amount) { var p = State.Players[player]; for (var i = 0; i < amount; i++) { if (p.Deck.Count == 0) { Defeat(player); return; } var card = p.Deck[0]; p.Deck.RemoveAt(0); p.Soul.Add(card); } } public void SoulBlast(int player, int amount) { var soul = State.Players[player].Soul; for (var i = 0; i < amount && soul.Count > 0; i++) { var card = soul[0]; soul.RemoveAt(0); State.Players[player].Drop.Add(card); } } // ---------- выбор целей ---------- /// Круги Арьергарда игрока, подходящие под фильтр. public IReadOnlyList RearGuardSlots(int player, Func predicate) { var slots = new List(); for (var slot = 0; slot < PlayerState.RearGuardSlots; slot++) { var unit = State.Players[player].RearGuards[slot]; if (unit is null) continue; if (predicate(State.Catalog.CardOf(unit.PrintingId))) slots.Add(slot); } return slots; } /// Останавливает разрешение и просит игрока выбрать карту на поле. public void AskUnitChoice( int chooser, int targetPlayer, string prompt, IReadOnlyList slots, Action onChosen) { var options = slots .Select(slot => { var unit = State.GetUnit(targetPlayer, slot)!; return new ChoiceOption(slot, State.Catalog.CardOf(unit.PrintingId).NameEn); }) .ToList(); State.PendingChoice = new PendingChoice { Player = chooser, Prompt = prompt, Options = options, Resolve = onChosen, }; State.Step = Step.Choice; State.ActivePlayer = chooser; } public void RetireRearGuard(int player, int slot) { var unit = State.Players[player].RearGuards[slot]; if (unit is null) return; State.Players[player].RearGuards[slot] = null; State.Players[player].Drop.Add(unit); _events.Add(new UnitRetiredEvent(player, slot)); } // ---------- поиск в колоде ---------- /// /// Серверный поиск: игрок не видит колоду. Найдена одна карта — сразу передаётся /// в onFound; несколько — предлагается выбор (разные арты считаются разными картами); /// ни одной — уведомление. После поиска колода всегда перемешивается. /// public void SearchDeck( int player, string prompt, Func predicate, Action onFound) { var deck = State.Players[player].Deck; var matches = deck .Where(c => predicate(State.Catalog.CardOf(c.PrintingId))) .ToList(); if (matches.Count == 0) { _events.Add(new SearchFailedEvent(player, prompt)); ShuffleDeck(player); return; } if (matches.Count == 1) { CompleteSearch(player, matches[0], onFound); return; } AskCardChoice(player, prompt, matches, chosen => CompleteSearch(player, chosen, onFound)); } private void CompleteSearch(int player, CardInstance card, Action onFound) { State.Players[player].Deck.Remove(card); onFound(card); _events.Add(new CardSearchedEvent(player, card.PrintingId)); ShuffleDeck(player); } /// Поиск с добавлением карты в руку. public void SearchDeckToHand(int player, string prompt, Func predicate) => SearchDeck(player, prompt, predicate, card => { _events.Add(new CardRevealedEvent(player, card.PrintingId)); State.Players[player].Hand.Add(card); }); /// Поиск с призывом в выбранный круг Арьергарда. public void SearchDeckToCall(int player, string prompt, Func predicate) { SearchDeck(player, prompt, predicate, card => { _events.Add(new CardRevealedEvent(player, card.PrintingId)); AskEmptyRearGuardChoice(player, "Выберите круг Арьергарда", slot => CallFromDeck(card, player, slot)); }); } /// Поиск с райдом найденной карты (superior ride). public void SearchDeckToRide(int player, string prompt, Func predicate) { SearchDeck(player, prompt, predicate, card => { _events.Add(new CardRevealedEvent(player, card.PrintingId)); RideFromDeck(card, player); }); } /// Посмотреть верхнюю карту и оставить её на верху или отправить в низ. public void LookTopAndReorder(int player, string prompt) { var deck = State.Players[player].Deck; if (deck.Count == 0) return; var top = deck[0]; State.PendingChoice = new PendingChoice { Player = player, Prompt = $"{prompt}: {State.Catalog.CardOf(top.PrintingId).NameEn}", Options = new List { new(0, "Оставить на верху"), new(1, "Положить в низ"), }, Resolve = index => { if (index == 1) { deck.RemoveAt(0); deck.Add(top); } _events.Add(new CardRevealedEvent(player, top.PrintingId)); }, }; State.Step = Step.Choice; State.ActivePlayer = player; } public bool HasEmptyRearGuard(int player) => State.Players[player].RearGuards.Any(u => u is null); /// Выбор карты из руки (стоимость, сброс, цель эффекта). public void AskHandCardChoice( int player, string prompt, Func? predicate, Action onChosen) { var cards = State.Players[player].Hand .Where(c => predicate is null || predicate(c)) .ToList(); if (cards.Count == 0) return; State.PendingChoice = new PendingChoice { Player = player, Prompt = prompt, Options = cards .Select((card, index) => new ChoiceOption(index, State.Catalog.CardOf(card.PrintingId).NameEn)) .ToList(), Resolve = index => onChosen(cards[index]), }; State.Step = Step.Choice; State.ActivePlayer = player; } public void AskEmptyRearGuardChoice(int player, string prompt, Action onChosen) { var slots = Enumerable.Range(0, PlayerState.RearGuardSlots) .Where(s => State.Players[player].RearGuards[s] is null) .ToList(); if (slots.Count == 0) return; State.PendingChoice = new PendingChoice { Player = player, Prompt = prompt, Options = slots.Select(s => new ChoiceOption(s, $"Круг {s + 1}")).ToList(), Resolve = onChosen, }; State.Step = Step.Choice; State.ActivePlayer = player; } public void CallFromDeck(CardInstance card, int player, int slot) { var p = State.Players[player]; if (p.RearGuards[slot] is not null) return; card.Rested = true; p.RearGuards[slot] = card; _events.Add(new UnitCalledEvent(player, slot, card.PrintingId)); TriggerFromCard(player, card, AbilityTrigger.OnPlace, null); } /// Райд картой не из руки (superior ride). public void RideFromDeck(CardInstance card, int player) { var p = State.Players[player]; var riddenOver = p.Vanguard; if (riddenOver is not null) { riddenOver.Rested = false; p.Soul.Add(riddenOver); } card.Rested = false; p.Vanguard = card; _events.Add(new UnitRodeEvent(player, card.PrintingId)); TriggerFromCard(player, card, AbilityTrigger.OnPlace, null); if (State.Step == Step.None) TriggerFromCard(player, riddenOver, AbilityTrigger.OnRideOver, card); } /// /// Останавливает разрешение и просит выбрать карту из найденных. /// Варианты различаются номером принта, поэтому два Blaster Blade с разными артами — два варианта. /// public void AskCardChoice( int player, string prompt, IReadOnlyList cards, Action onChosen) { var options = cards .Select((card, index) => new ChoiceOption( index, $"{State.Catalog.CardOf(card.PrintingId).NameEn} ({card.PrintingId})")) .ToList(); State.PendingChoice = new PendingChoice { Player = player, Prompt = prompt, Options = options, Resolve = index => onChosen(cards[index]), }; State.Step = Step.Choice; State.ActivePlayer = player; } public Clan VanguardClan(int player) { var vanguard = State.Players[player].Vanguard; return vanguard is null ? Clan.RoyalPaladin : State.Catalog.CardOf(vanguard.PrintingId).Clan; } // ---------- старт партии ---------- public void Start() { ShuffleDeck(0); ShuffleDeck(1); PlaceStartingVanguard(0); PlaceStartingVanguard(1); Draw(0, StartingHand); Draw(1, StartingHand); State.Phase = Phase.Mulligan; _events.Add(new GameStartedEvent(0)); } private void ShuffleDeck(int player) { var state = State.RngState; Rng.Shuffle(ref state, State.Players[player].Deck); State.RngState = state; } private void FillDeck(int player, IReadOnlyList printingIds) { foreach (var printingId in printingIds) { State.Players[player].Deck.Add(new CardInstance { Id = State.NextInstanceId++, PrintingId = printingId, }); } } private void PlaceStartingVanguard(int player) { var p = State.Players[player]; var index = p.Deck.FindIndex(c => { var def = State.Catalog.CardOf(c.PrintingId); return def.IsStartingVanguardCandidate; }); if (index < 0) throw new InvalidOperationException("В колоде нет карты для стартового Авангарда (grade 0 без триггера)."); var card = p.Deck[index]; p.Deck.RemoveAt(index); p.Vanguard = card; } // ---------- команды ---------- public CommandResult Execute(GameCommand command) { // пока идёт разрешение способности или триггера, посторонние команды запрещены if (State.Step is Step.Trigger or Step.Ability or Step.Choice) { var allowed = command switch { ResolveTriggerCommand => State.Step == Step.Trigger, ResolveAbilityCommand or SelectTriggerCommand => State.Step == Step.Ability, ChooseCommand => State.Step == Step.Choice, _ => false, }; if (!allowed) return CommandResult.Reject("Сначала завершите текущее разрешение."); } return command switch { MulliganCommand c => Mulligan(c), FinishMulliganCommand => FinishMulligan(), RideCommand c => Ride(c.PrintingId), SkipRideCommand => SkipRide(), CallCommand c => Call(c.PrintingId, c.Slot), MoveCommand c => Move(c.FromSlot, c.ToSlot), EnterBattleCommand => EnterBattle(), AttackCommand c => Attack(c.AttackerSlot, c.TargetSlot, c.BoosterSlot), GuardCommand c => Guard(c.PrintingId, c.DiscardPrintingId), InterceptCommand c => Intercept(c.Slot), PassGuardCommand => PassGuard(), ResolveTriggerCommand c => ResolveTrigger(c), SelectTriggerCommand c => SelectTrigger(c.Index), ResolveAbilityCommand c => ResolvePendingAbility(c.Accept), ChooseCommand c => Choose(c.Index), ActivateAbilityCommand c => Activate(c.InstanceId, c.DiscardPrintingId), EndTurnCommand => EndTurn(), _ => CommandResult.Reject("Неизвестная команда."), }; } private CommandResult Mulligan(MulliganCommand command) { if (State.Phase != Phase.Mulligan) return CommandResult.Reject("Маллиган доступен только в начале партии."); var player = State.ActivePlayer; var hand = State.Players[player].Hand; var indexes = command.HandIndexes.Distinct().OrderByDescending(i => i).ToList(); if (indexes.Any(i => i < 0 || i >= hand.Count)) return CommandResult.Reject("Некорректный индекс карты в руке."); foreach (var index in indexes) { var card = hand[index]; hand.RemoveAt(index); State.Players[player].Deck.Add(card); } ShuffleDeck(player); Draw(player, indexes.Count); _events.Add(new MulliganEvent(player, indexes.Count)); return CommandResult.Ok(); } private CommandResult FinishMulligan() { if (State.Phase != Phase.Mulligan) return CommandResult.Reject("Маллиган уже завершён."); State.ActivePlayer = 0; StartTurn(); return CommandResult.Ok(); } private CommandResult Ride(string printingId) { if (State.Phase != Phase.Ride) return CommandResult.Reject("Райд доступен только в фазе Смены."); var player = State.Players[State.ActivePlayer]; var index = player.Hand.FindIndex(c => c.PrintingId == printingId); if (index < 0) return CommandResult.Reject("Карты нет в руке."); var card = player.Hand[index]; var def = State.Catalog.CardOf(card.PrintingId); var current = player.Vanguard is null ? -1 : State.Catalog.CardOf(player.Vanguard.PrintingId).Grade; if (def.Grade != current && def.Grade != current + 1) return CommandResult.Reject($"Нельзя совершить смену с {current}-го ранга на {def.Grade}-й."); var riddenOver = player.Vanguard; if (riddenOver is not null) { riddenOver.Rested = false; player.Soul.Add(riddenOver); } player.Hand.RemoveAt(index); card.Rested = false; player.Vanguard = card; _events.Add(new UnitRodeEvent(State.ActivePlayer, card.PrintingId)); TriggerFromCard(State.ActivePlayer, card, AbilityTrigger.OnPlace, null); if (State.Step == Step.None) TriggerFromCard(State.ActivePlayer, riddenOver, AbilityTrigger.OnRideOver, card); if (State.Step == Step.None) EnterMainPhase(); else State.Continuation = EnterMainPhase; return CommandResult.Ok(); } private CommandResult SkipRide() { if (State.Phase != Phase.Ride) return CommandResult.Reject("Пропустить смену можно только в фазе Смены."); EnterMainPhase(); return CommandResult.Ok(); } private CommandResult Call(string printingId, int slot) { if (State.Phase != Phase.Main) return CommandResult.Reject("Призыв доступен только в Основной фазе."); if (slot is < 0 or >= PlayerState.RearGuardSlots) return CommandResult.Reject("Некорректный круг Арьергарда."); var player = State.Players[State.ActivePlayer]; if (player.RearGuards[slot] is not null) return CommandResult.Reject("Круг уже занят."); var index = player.Hand.FindIndex(c => c.PrintingId == printingId); if (index < 0) return CommandResult.Reject("Карты нет в руке."); var card = player.Hand[index]; player.Hand.RemoveAt(index); card.Rested = true; player.RearGuards[slot] = card; _events.Add(new UnitCalledEvent(State.ActivePlayer, slot, card.PrintingId)); TriggerFromCard(State.ActivePlayer, card, AbilityTrigger.OnPlace, null); return CommandResult.Ok(); } private CommandResult Move(int fromSlot, int toSlot) { if (State.Phase != Phase.Main) return CommandResult.Reject("Перемещение доступно только в Основной фазе."); if (fromSlot is < 0 or >= PlayerState.RearGuardSlots || toSlot is < 0 or >= PlayerState.RearGuardSlots) return CommandResult.Reject("Некорректный круг Арьергарда."); var player = State.Players[State.ActivePlayer]; var unit = player.RearGuards[fromSlot]; if (unit is null) return CommandResult.Reject("В исходном кругу нет карты."); if (!SameColumn(fromSlot, toSlot)) return CommandResult.Reject("Двигать можно только в пределах своей колонки."); // целевой круг может быть занят — тогда карты меняются местами var other = player.RearGuards[toSlot]; player.RearGuards[toSlot] = unit; player.RearGuards[fromSlot] = other; _events.Add(new UnitMovedEvent(State.ActivePlayer, fromSlot, toSlot)); return CommandResult.Ok(); } /// Колонки: 0↔3 (левая), 2↔4 (правая). У центрального заднего ряда пары нет. private static bool SameColumn(int fromSlot, int toSlot) => (fromSlot, toSlot) is (0, 3) or (3, 0) or (2, 4) or (4, 2); private CommandResult EnterBattle() { if (State.Phase != Phase.Main) return CommandResult.Reject("Перейти в Боевую фазу можно только из Основной."); State.Phase = Phase.Battle; _events.Add(new PhaseChangedEvent(State.ActivePlayer, Phase.Battle)); return CommandResult.Ok(); } private CommandResult Attack(int attackerSlot, int targetSlot, int boosterSlot = -1) { if (State.Phase != Phase.Battle) return CommandResult.Reject("Атака доступна только в Боевой фазе."); if (State.Battle is not null) return CommandResult.Reject("Идёт бой: сначала завершите защиту."); if (attackerSlot is < -1 or >= PlayerState.RearGuardSlots) return CommandResult.Reject("Некорректный атакующий."); if (targetSlot is < -1 or >= PlayerState.RearGuardSlots) return CommandResult.Reject("Некорректная цель."); var attacker = State.GetUnit(State.ActivePlayer, attackerSlot); if (attacker is null) return CommandResult.Reject("Нет атакующего юнита."); if (attacker.Rested) return CommandResult.Reject("Юнит уже повёрнут."); var attackerDef = State.Catalog.CardOf(attacker.PrintingId); if (attackerDef.IsRestraint) return CommandResult.Reject("Юнит со Сдерживанием не может атаковать."); var target = State.GetUnit(1 - State.ActivePlayer, targetSlot); if (target is null) return CommandResult.Reject("Нет цели."); CardInstance? booster = null; if (boosterSlot >= 0) { if (attackerSlot is not (3 or 4)) return CommandResult.Reject("Поддерживать можно только атаку Арьергарда из переднего ряда."); var expected = attackerSlot == 3 ? 0 : 2; if (boosterSlot != expected) return CommandResult.Reject("Поддержка возможна только из заднего ряда той же колонны."); booster = State.GetUnit(State.ActivePlayer, boosterSlot); if (booster is null) return CommandResult.Reject("Нет карты для поддержки."); if (booster.Rested) return CommandResult.Reject("Карта поддержки уже повёрнута."); if (!State.CanBoost(booster)) return CommandResult.Reject("Эта карта не может поддерживать."); } var attackerPlayer = State.ActivePlayer; attacker.Rested = true; State.Battle = new BattleState { AttackerPlayer = attackerPlayer, AttackerSlot = attackerSlot, TargetSlot = targetSlot, }; if (booster is not null) { booster.Rested = true; State.Battle.BoostPower += State.Catalog.CardOf(booster.PrintingId).Power; _events.Add(new BoostedEvent(attackerPlayer, boosterSlot, attackerSlot)); TriggerFromCard(attackerPlayer, booster, AbilityTrigger.OnBoost, attacker); } var defenderIndex = 1 - attackerPlayer; _events.Add(new AttackDeclaredEvent(attackerPlayer, attackerSlot, targetSlot)); void BeginGuard() { State.Step = Step.Guard; State.ActivePlayer = defenderIndex; } TriggerFromCard(attackerPlayer, attacker, AbilityTrigger.OnAttackDeclared, null); if (State.Step is Step.Ability or Step.Choice) State.Continuation = BeginGuard; else BeginGuard(); return CommandResult.Ok(); } private CommandResult Guard(string printingId, string? discardPrintingId = null) { var battle = State.Battle; if (State.Step != Step.Guard || battle is null) return CommandResult.Reject("Сейчас нельзя защищаться."); var defenderIndex = 1 - battle.AttackerPlayer; if (State.ActivePlayer != defenderIndex) return CommandResult.Reject("Защищается только обороняющийся."); var defender = State.Players[defenderIndex]; var index = defender.Hand.FindIndex(c => c.PrintingId == printingId); if (index < 0) return CommandResult.Reject("Карты нет в руке."); var card = defender.Hand[index]; var def = State.Catalog.CardOf(card.PrintingId); if (!def.IsSentinel && discardPrintingId is not null) return CommandResult.Reject("Эта карта не требует оплаты стоимости."); if (def.IsSentinel && discardPrintingId is not null) { var discardIndex = defender.Hand.FindIndex(c => c.PrintingId == discardPrintingId); if (discardIndex < 0) return CommandResult.Reject("Карты для оплаты нет в руке."); if (State.Catalog.CardOf(defender.Hand[discardIndex].PrintingId).Clan != def.Clan) return CommandResult.Reject("Для оплаты нужна карта того же клана."); } // Sentinel сам по себе даёт ноль щита: либо оплата и отмена атаки, либо ничего var shield = def.IsSentinel ? 0 : def.Shield; defender.Hand.RemoveAt(index); defender.Guardian.Add(card); battle.GuardCards.Add(card); battle.GuardShield += shield; _events.Add(new GuardAddedEvent(defenderIndex, card.PrintingId, shield)); if (def.IsSentinel && discardPrintingId is not null) { var discardIndex = defender.Hand.FindIndex(c => c.PrintingId == discardPrintingId); var discarded = defender.Hand[discardIndex]; defender.Hand.RemoveAt(discardIndex); defender.Drop.Add(discarded); battle.Nullified = true; _events.Add(new PerfectGuardEvent(defenderIndex, card.PrintingId, discarded.PrintingId)); } return CommandResult.Ok(); } private CommandResult Intercept(int slot) { var battle = State.Battle; if (State.Step != Step.Guard || battle is null) return CommandResult.Reject("Перехват доступен только на шаге защиты."); var defenderIndex = 1 - battle.AttackerPlayer; if (State.ActivePlayer != defenderIndex) return CommandResult.Reject("Перехватывает только обороняющийся."); if (battle.Interceptor is not null) return CommandResult.Reject("Перехват уже объявлен."); if (slot is < 0 or >= PlayerState.RearGuardSlots || slot == battle.TargetSlot) return CommandResult.Reject("Некорректный круг для перехвата."); var defender = State.Players[defenderIndex]; var unit = defender.RearGuards[slot]; if (unit is null) return CommandResult.Reject("Круг пуст."); if (unit.Rested) return CommandResult.Reject("Карта повёрнута и не может перехватывать."); var isFrontRow = slot is 3 or 4; if (!isFrontRow && !unit.InterceptFromBackRow) return CommandResult.Reject("Перехват возможен только из переднего ряда."); if (!State.CanIntercept(unit)) return CommandResult.Reject("Эта карта не может перехватывать."); defender.RearGuards[slot] = null; defender.Guardian.Add(unit); battle.GuardCards.Add(unit); battle.Interceptor = unit; _events.Add(new InterceptedEvent(defenderIndex, slot, unit.PrintingId)); return CommandResult.Ok(); } private CommandResult PassGuard() { var battle = State.Battle; if (State.Step != Step.Guard || battle is null) return CommandResult.Reject("Сейчас нет боя."); if (State.ActivePlayer != 1 - battle.AttackerPlayer) return CommandResult.Reject("Защищается только обороняющийся."); BeginResolution(battle); return CommandResult.Ok(); } private CommandResult EndTurn() { if (State.Phase is not (Phase.Main or Phase.Battle)) return CommandResult.Reject("Завершить ход можно из Основной или Боевой фазы."); if (State.Battle is not null) return CommandResult.Reject("Сначала завершите текущий бой."); State.Phase = Phase.End; _events.Add(new PhaseChangedEvent(State.ActivePlayer, Phase.End)); ClearTurnModifiers(); if (State.IsFinished) return CommandResult.Ok(); State.ActivePlayer = 1 - State.ActivePlayer; StartTurn(); return CommandResult.Ok(); } // ---------- ход ---------- private void StartTurn() { State.TurnNumber++; foreach (var unit in AllUnits(State.ActivePlayer)) unit.Rested = false; State.Phase = Phase.Stand; _events.Add(new PhaseChangedEvent(State.ActivePlayer, Phase.Stand)); State.Phase = Phase.Draw; _events.Add(new PhaseChangedEvent(State.ActivePlayer, Phase.Draw)); Draw(State.ActivePlayer, 1); if (State.IsFinished) return; State.Phase = Phase.Ride; _events.Add(new PhaseChangedEvent(State.ActivePlayer, Phase.Ride)); } private IEnumerable AllUnits(int player) { var p = State.Players[player]; if (p.Vanguard is not null) yield return p.Vanguard; foreach (var unit in p.RearGuards) { if (unit is not null) yield return unit; } } /// Усиления от триггеров живут до конца хода, а не до конца боя. private void ClearTurnModifiers() { foreach (var unit in AllUnits(0).Concat(AllUnits(1))) { unit.TemporaryPower = 0; unit.TemporaryCritical = 0; } } private void BeginResolution(BattleState battle) { var attacker = State.GetUnit(battle.AttackerPlayer, battle.AttackerSlot); if (attacker is null) { FinishBattle(battle); return; } var checks = DriveChecksFor(battle.AttackerSlot, attacker); if (checks == 0) { FinishBattle(battle); return; } battle.ChecksRemaining = checks; State.ActivePlayer = battle.AttackerPlayer; State.Step = Step.Drive; ContinueDriveChecks(battle); } /// /// Идёт по drive-чекам. На триггере останавливается и ждёт ResolveTriggerCommand: /// игрок сам выбирает, кому дать +5000 и куда применить второй эффект. /// private void ContinueDriveChecks(BattleState battle) { var player = battle.AttackerPlayer; while (battle.ChecksRemaining > 0 && !State.IsFinished) { var card = TakeTop(player); if (card is null) { FinishBattle(battle); return; } var def = State.Catalog.CardOf(card.PrintingId); _events.Add(new DriveCheckedEvent(player, card.PrintingId, def.Trigger)); if (def.Trigger == TriggerType.None) { State.Players[player].Hand.Add(card); _events.Add(new CardDrawnEvent(player)); battle.ChecksRemaining--; continue; } State.Players[player].Trigger.Add(card); battle.ChecksRemaining--; _events.Add(new TriggerResolvedEvent(player, card.PrintingId, def.Trigger)); State.PendingTrigger = new PendingTrigger { Player = player, PrintingId = card.PrintingId, Card = card, Trigger = def.Trigger, Context = TriggerContext.DriveCheck, GrantsPower = true, }; State.Step = Step.Trigger; State.ActivePlayer = player; return; } FinishBattle(battle); } private CommandResult ResolveTrigger(ResolveTriggerCommand command) { var pending = State.PendingTrigger; if (pending is null || State.Step != Step.Trigger) return CommandResult.Reject("Сейчас нет триггера для разрешения."); if (State.ActivePlayer != pending.Player) return CommandResult.Reject("Триггер разрешает его владелец."); var player = pending.Player; if (pending.GrantsPower) { var powerTarget = State.GetUnit(player, command.PowerTarget); if (powerTarget is null) return CommandResult.Reject("Нет цели для +5000 силы."); } if (pending.Trigger is TriggerType.Critical or TriggerType.Stand) { var effectUnit = State.GetUnit(player, command.EffectTarget); if (effectUnit is null) return CommandResult.Reject("Нет цели для второго эффекта."); if (pending.Trigger == TriggerType.Stand && command.EffectTarget < 0) return CommandResult.Reject("Поднять можно только карту в кругу Арьергарда."); } if (pending.GrantsPower) State.GetUnit(player, command.PowerTarget)!.TemporaryPower += TriggerPowerBonus; switch (pending.Trigger) { case TriggerType.Critical: State.GetUnit(player, command.EffectTarget)!.TemporaryCritical += 1; break; case TriggerType.Stand: State.GetUnit(player, command.EffectTarget)!.Rested = false; break; case TriggerType.Draw: Draw(player, 1); break; case TriggerType.Heal: Heal(player); break; } State.PendingTrigger = null; _events.Add(new TriggerAppliedEvent(player, command.PowerTarget, command.EffectTarget)); if (pending.Context == TriggerContext.DriveCheck) { // эффекты сработали — теперь карта уходит из зоны триггера в руку var p = State.Players[player]; p.Trigger.Remove(pending.Card); p.Hand.Add(pending.Card); if (State.Battle is { } battle) { State.Step = Step.Drive; ContinueDriveChecks(battle); } return CommandResult.Ok(); } FinishDamage(pending); return CommandResult.Ok(); } private void FinishBattle(BattleState battle) { if (State.IsFinished) { ClearBattle(battle); return; } var defenderIndex = 1 - battle.AttackerPlayer; var attacker = State.GetUnit(battle.AttackerPlayer, battle.AttackerSlot); var target = State.GetUnit(defenderIndex, battle.TargetSlot); if (attacker is null || target is null) { ClearBattle(battle); return; } var attackerDef = State.Catalog.CardOf(attacker.PrintingId); var defenseUnit = battle.Interceptor ?? target; var attackPower = EffectivePower(attacker) + battle.BoostPower + battle.AbilityPower; var defensePower = EffectivePower(defenseUnit) + battle.GuardShield; var hit = !battle.Nullified && attackPower > defensePower; _events.Add(new BattleResolvedEvent(hit)); if (hit) { if (battle.Interceptor is not null) { // перехватчик уже вышел из круга и уйдёт в сброс при закрытии боя, // первоначальная цель (Авангард или Арьергард) не страдает _events.Add(new InterceptorRetiredEvent(defenderIndex, battle.Interceptor.PrintingId)); } else if (battle.TargetSlot < 0) { var damage = attackerDef.Critical + attacker.TemporaryCritical; void AfterDamage() { TriggerFromCard(battle.AttackerPlayer, attacker, AbilityTrigger.OnAttackHit, null); if (State.Step is Step.Ability or Step.Choice) { State.Continuation = () => ClearBattle(battle); return; } ClearBattle(battle); } void ResolveDamage() { DealDamage(defenderIndex, damage); // урон ещё разрешается (damage-триггер) — hit-эффекты после него if (State.PendingTrigger is not null) { State.Continuation = AfterDamage; return; } AfterDamage(); } ResolveDamage(); return; } else { State.Players[defenderIndex].RearGuards[battle.TargetSlot] = null; State.Players[defenderIndex].Drop.Add(target); _events.Add(new UnitRetiredEvent(defenderIndex, battle.TargetSlot)); } } if (State.PendingTrigger is not null) return; ClearBattle(battle); } private void ClearBattle(BattleState battle) { var defender = State.Players[1 - battle.AttackerPlayer]; foreach (var guard in battle.GuardCards) { defender.Guardian.Remove(guard); defender.Drop.Add(guard); } State.Battle = null; State.PendingTrigger = null; State.Step = Step.None; State.ActivePlayer = battle.AttackerPlayer; } private void DealDamage(int player, int amount) { for (var i = 0; i < amount; i++) { if (State.IsFinished) return; var card = TakeTop(player); if (card is null) return; var def = State.Catalog.CardOf(card.PrintingId); if (def.Trigger != TriggerType.None) { // карта попадает в зону триггера, эффекты срабатывают до урона State.Players[player].Trigger.Add(card); _events.Add(new TriggerResolvedEvent(player, card.PrintingId, def.Trigger)); State.PendingTrigger = new PendingTrigger { Player = player, PrintingId = card.PrintingId, Card = card, Trigger = def.Trigger, Context = TriggerContext.DamageCheck, GrantsPower = true, RemainingDamage = amount - i - 1, }; State.Step = Step.Trigger; State.ActivePlayer = player; return; } PutIntoDamage(player, card); if (State.IsFinished) return; } } private void PutIntoDamage(int player, CardInstance card) { card.FaceUp = false; var p = State.Players[player]; p.Damage.Add(card); _events.Add(new DamageTakenEvent(player, 1, p.Damage.Count)); if (p.Damage.Count >= MaxDamage) Defeat(player); } /// Продолжает урон после разрешения триггера damage check. private void FinishDamage(PendingTrigger pending) { if (State.IsFinished) return; var p = State.Players[pending.Player]; p.Trigger.Remove(pending.Card); PutIntoDamage(pending.Player, pending.Card); if (State.IsFinished) return; if (pending.RemainingDamage > 0) DealDamage(pending.Player, pending.RemainingDamage); if (State.PendingTrigger is not null) return; if (State.Continuation is { } continuation) { State.Continuation = null; continuation(); return; } State.Step = Step.None; if (State.Battle is { } battle) ClearBattle(battle); } private void Heal(int player) { var p = State.Players[player]; var opponent = State.Players[1 - player]; if (p.Damage.Count <= opponent.Damage.Count || p.Damage.Count == 0) return; var card = p.Damage[^1]; p.Damage.RemoveAt(p.Damage.Count - 1); p.Drop.Add(card); _events.Add(new HealedEvent(player)); } // ---------- служебное ---------- private CardInstance? TakeTop(int player) { var deck = State.Players[player].Deck; if (deck.Count == 0) { Defeat(player); return null; } var card = deck[0]; deck.RemoveAt(0); return card; } public void DrawCards(int player, int count) => Draw(player, count); private void Draw(int player, int count) { for (var i = 0; i < count; i++) { if (State.IsFinished) return; var card = TakeTop(player); if (card is null) return; State.Players[player].Hand.Add(card); _events.Add(new CardDrawnEvent(player)); } } private void Defeat(int player) { if (State.IsFinished) return; State.Players[player].IsDefeated = true; State.IsFinished = true; State.Winner = 1 - player; _events.Add(new GameFinishedEvent(State.Winner.Value)); } }