From ff7774b236bee8a00275bba0647550c866881478 Mon Sep 17 00:00:00 2001 From: MpakobecEx Date: Wed, 21 Oct 2026 13:38:21 +0500 Subject: [PATCH] Multi-target choices, action restrictions and rear-guard hit triggers AskUnitMultiChoice asks one target at a time, offers Done once the minimum is met and resolves automatically at the maximum. CardRestriction models cannot-stand/attack/boost/intercept with source, effect id and duration, including UntilOwnerNextStand. Restraint can be overridden by effects. OnAttackHit now fires when the attack hits a rear-guard. Five tests. --- IMPLEMENTATION_CHECKLIST.md | 3 + .../src/Vanguard.Rules/Engine/GameEngine.cs | 118 ++++++++++++++++- .../src/Vanguard.Rules/Engine/GameModels.cs | 12 ++ server/src/Vanguard.Rules/Engine/GameState.cs | 14 +- server/src/Vanguard.Rules/Engine/Modifiers.cs | 27 ++++ .../Vanguard.Rules.Tests/GameEngineTests.cs | 125 ++++++++++++++++++ 6 files changed, 293 insertions(+), 6 deletions(-) diff --git a/IMPLEMENTATION_CHECKLIST.md b/IMPLEMENTATION_CHECKLIST.md index d6d42c9..25e8e25 100644 --- a/IMPLEMENTATION_CHECKLIST.md +++ b/IMPLEMENTATION_CHECKLIST.md @@ -122,6 +122,9 @@ - [x] Выбор цели игроком (`PendingChoice` / `ChooseCommand`); «до N» / «ровно N» ещё нет. - [x] Выбор карты из руки как стоимости эффекта (Morgana: сброс → +4000). - [x] Модификаторы силы/крита с источником и сроком жизни (`StatModifier`); выдача и снятие способностей эффектами (`GrantedAbility`). +- [x] Выбор от `min` до `max` целей на поле (`AskUnitMultiChoice`) для «до N» и «ровно N». +- [x] Запреты с источником и сроком жизни (`CardRestriction`): не вставать, не атаковать, не поддерживать, не перехватывать, не быть выбранным; снятие Сдерживания эффектом. +- [x] `OnAttackHit` срабатывает и при попадании по Арьергарду. - [x] Поиск в колоде без просмотра: 0 совпадений — уведомление и перемешивание, 1 — сразу в руку, несколько — выбор; разные арты считаются разными картами. - [x] Поиск с призывом в круг Арьергарда (Alfred) и с райдом (API, Llew). - [x] Просмотр верхней карты с решением «оставить на верху / положить в низ» (Cocoa). diff --git a/server/src/Vanguard.Rules/Engine/GameEngine.cs b/server/src/Vanguard.Rules/Engine/GameEngine.cs index 4777129..516495c 100644 --- a/server/src/Vanguard.Rules/Engine/GameEngine.cs +++ b/server/src/Vanguard.Rules/Engine/GameEngine.cs @@ -529,6 +529,30 @@ public sealed class GameEngine 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) => @@ -630,6 +654,74 @@ public sealed class GameEngine 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]; @@ -1133,10 +1225,12 @@ public sealed class GameEngine if (attacker.Rested) return CommandResult.Reject("Юнит уже повёрнут."); - var attackerDef = State.Catalog.CardOf(attacker.PrintingId); - if (attackerDef.IsRestraint) + if (State.IsRestraint(attacker)) return CommandResult.Reject("Юнит со Сдерживанием не может атаковать."); + if (State.HasRestriction(attacker, RestrictionType.CannotAttack)) + return CommandResult.Reject("Юнит не может атаковать."); + var target = State.GetUnit(1 - State.ActivePlayer, targetSlot); if (target is null) return CommandResult.Reject("Нет цели."); @@ -1340,7 +1434,14 @@ public sealed class GameEngine State.TurnNumber++; foreach (var unit in AllUnits(State.ActivePlayer)) - unit.Rested = false; + { + 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)); @@ -1376,6 +1477,7 @@ public sealed class GameEngine { unit.Modifiers.RemoveAll(m => m.Duration == ModifierDuration.UntilEndOfTurn); unit.GrantedAbilities.RemoveAll(g => g.Duration == ModifierDuration.UntilEndOfTurn); + unit.Restrictions.RemoveAll(r => r.Duration == ModifierDuration.UntilEndOfTurn); } } @@ -1547,7 +1649,6 @@ public sealed class GameEngine return; } - var attackerDef = State.Catalog.CardOf(attacker.PrintingId); var defenseUnit = battle.Interceptor ?? target; var attackPower = EffectivePower(attacker); @@ -1603,6 +1704,14 @@ public sealed class GameEngine State.Players[defenderIndex].RearGuards[battle.TargetSlot] = null; State.Players[defenderIndex].Drop.Add(target); _events.Add(new UnitRetiredEvent(defenderIndex, battle.TargetSlot)); + + TriggerFromCard(battle.AttackerPlayer, attacker, AbilityTrigger.OnAttackHit, target); + + if (State.Step is Step.Ability or Step.Choice) + { + State.Continuation = () => ClearBattle(battle); + return; + } } } @@ -1625,6 +1734,7 @@ public sealed class GameEngine { 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; diff --git a/server/src/Vanguard.Rules/Engine/GameModels.cs b/server/src/Vanguard.Rules/Engine/GameModels.cs index d64a1ea..789889f 100644 --- a/server/src/Vanguard.Rules/Engine/GameModels.cs +++ b/server/src/Vanguard.Rules/Engine/GameModels.cs @@ -26,6 +26,9 @@ public enum Step /// Вариант выбора, который предлагается игроку. public sealed record ChoiceOption(int Slot, string Label); +/// Карта на поле: игрок и круг (-1 — Авангард). +public sealed record UnitTarget(int Player, int Slot); + /// /// Ожидание выбора игрока. Resolve получает индекс выбранного варианта. /// Пока выбор не сделан, движок не продолжает разрешение способности. @@ -77,6 +80,15 @@ public sealed class CardInstance /// Способности, выданные эффектами, а не напечатанные на карте. public List GrantedAbilities { get; } = new(); + + /// Запреты, выданные эффектами. + public List Restrictions { get; } = new(); + + /// + /// Переопределение Сдерживания, выданное эффектом. + /// null — как напечатано на карте. + /// + public bool? RestraintOverride { get; set; } /// /// Переопределение способности поддержки, выданное эффектом. /// null — как напечатано на карте. diff --git a/server/src/Vanguard.Rules/Engine/GameState.cs b/server/src/Vanguard.Rules/Engine/GameState.cs index 483f8af..56f7cbb 100644 --- a/server/src/Vanguard.Rules/Engine/GameState.cs +++ b/server/src/Vanguard.Rules/Engine/GameState.cs @@ -79,11 +79,21 @@ public sealed class GameState /// Итоговая способность поддержки: выданная эффектом важнее печатной. /// public bool CanBoost(CardInstance unit) => - unit.BoostOverride ?? Definition(unit).HasBoost; + !HasRestriction(unit, RestrictionType.CannotBoost) + && (unit.BoostOverride ?? Definition(unit).HasBoost); /// Итоговая способность перехвата: выданная эффектом важнее печатной. public bool CanIntercept(CardInstance unit) => - unit.InterceptOverride ?? Definition(unit).HasIntercept; + !HasRestriction(unit, RestrictionType.CannotIntercept) + && (unit.InterceptOverride ?? Definition(unit).HasIntercept); + + /// Есть ли у карты запрет указанного вида. + public bool HasRestriction(CardInstance unit, RestrictionType type) => + unit.Restrictions.Any(r => r.Type == type); + + /// Действует ли на карте Сдерживание с учётом переопределения эффектом. + public bool IsRestraint(CardInstance unit) => + unit.RestraintOverride ?? Definition(unit).IsRestraint; public CardInstance? GetUnit(int player, int slot) => slot < 0 diff --git a/server/src/Vanguard.Rules/Engine/Modifiers.cs b/server/src/Vanguard.Rules/Engine/Modifiers.cs index 4caedd6..5d855dc 100644 --- a/server/src/Vanguard.Rules/Engine/Modifiers.cs +++ b/server/src/Vanguard.Rules/Engine/Modifiers.cs @@ -6,9 +6,27 @@ public enum ModifierDuration { UntilEndOfBattle, UntilEndOfTurn, + + /// + /// Живёт до следующей фазы Подъёма владельца карты. Нужен запретам вида + /// «не может вставать в следующую фазу Подъёма»: снимается уже после того, + /// как карта должна была встать. + /// + UntilOwnerNextStand, + Permanent, } +/// Постоянный запрет на действие. +public enum RestrictionType +{ + CannotStand, + CannotAttack, + CannotBoost, + CannotIntercept, + CannotBeChosen, +} + /// /// Прибавка к силе или криту. Знает, от какой карты пришла и каким эффектом, /// поэтому её можно точечно снять, не трогая остальные. @@ -30,3 +48,12 @@ public sealed class GrantedAbility public required string EffectId { get; init; } public required ModifierDuration Duration { get; init; } } + +/// Запрет на действие, выданный эффектом и знающий свой источник. +public sealed class CardRestriction +{ + public required RestrictionType Type { get; init; } + public required CardInstance Source { get; init; } + public required string EffectId { get; init; } + public required ModifierDuration Duration { get; init; } +} diff --git a/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs b/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs index ee695de..2420a53 100644 --- a/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs +++ b/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs @@ -1040,6 +1040,131 @@ public class GameEngineTests Assert.Equal(0, vanguard.Modifiers.Where(m => m.EffectId == "ability").Sum(m => m.Power)); } + [Fact] + public void MultiChoice_Stops_When_Player_Says_Done() + { + var engine = StartedEngine(); + var opponent = engine.State.Players[1]; + opponent.RearGuards[0] = TakeFromDeck(opponent, Marron); + opponent.RearGuards[1] = TakeFromDeck(opponent, Gallatin); + opponent.RearGuards[3] = TakeFromDeck(opponent, Gallatin); + + IReadOnlyList? result = null; + engine.AskUnitMultiChoice( + 0, + "Выберите до двух", + new[] { new UnitTarget(1, 0), new UnitTarget(1, 1), new UnitTarget(1, 3) }, + 0, + 2, + chosen => result = chosen); + + Assert.Equal(Step.Choice, engine.State.Step); + Assert.Equal(4, engine.State.PendingChoice!.Options.Count); // три цели и «Готово» + + engine.Execute(new ChooseCommand(0)); + engine.Execute(new ChooseCommand(2)); // «Готово» среди двух оставшихся + + Assert.NotNull(result); + Assert.Single(result!); + Assert.Equal(0, result![0].Slot); + Assert.Equal(Step.None, engine.State.Step); + } + + [Fact] + public void MultiChoice_Resolves_Automatically_When_Max_Reached() + { + var engine = StartedEngine(); + var opponent = engine.State.Players[1]; + opponent.RearGuards[0] = TakeFromDeck(opponent, Marron); + opponent.RearGuards[1] = TakeFromDeck(opponent, Gallatin); + + IReadOnlyList? result = null; + engine.AskUnitMultiChoice( + 0, + "Выберите ровно две", + new[] { new UnitTarget(1, 0), new UnitTarget(1, 1) }, + 2, + 2, + chosen => result = chosen); + + Assert.Equal(2, engine.State.PendingChoice!.Options.Count); // две цели, «Готово» ещё нет + + engine.Execute(new ChooseCommand(0)); + Assert.Null(result); // после первой цели выбор ещё продолжается + engine.Execute(new ChooseCommand(0)); + + Assert.NotNull(result); + Assert.Equal(2, result!.Count); + } + + [Fact] + public void CannotStand_Skips_Stand_And_Expires_After_Stand_Phase() + { + var engine = StartedEngine(); + SetVanguard(engine, 0, Gallatin); + engine.Execute(new SkipRideCommand()); + + var vanguard = engine.State.Players[0].Vanguard!; + engine.AddRestriction( + vanguard, + RestrictionType.CannotStand, + vanguard, + "test", + ModifierDuration.UntilOwnerNextStand); + vanguard.Rested = true; + + engine.Execute(new EndTurnCommand()); // ход переходит к игроку 1 + engine.Execute(new SkipRideCommand()); // игрок 1 выходит в Основную фазу + engine.Execute(new EndTurnCommand()); // и обратно + + Assert.True(vanguard.Rested); + Assert.Empty(vanguard.Restrictions); + } + + [Fact] + public void CannotAttack_Restriction_Blocks_Attack() + { + var engine = ReadyToAttackRearGuard(); + var attacker = engine.State.Players[0].RearGuards[3]!; + engine.AddRestriction( + attacker, + RestrictionType.CannotAttack, + attacker, + "test", + ModifierDuration.UntilEndOfTurn); + + Assert.False(engine.Execute(new AttackCommand(3, 0)).Accepted); + } + + [Fact] + public void Attack_Hit_On_RearGuard_Triggers_OnAttackHit() + { + var engine = ReadyToAttackRearGuard(); + var attacker = engine.State.Players[0].RearGuards[3]!; + + engine.GrantAbility( + attacker, + new AbilityDefinition + { + Trigger = AbilityTrigger.OnAttackHit, + Resolve = ctx => ctx.Engine.AddPower( + ctx.Source, + 2000, + ctx.Source, + "hit", + ModifierDuration.UntilEndOfTurn), + }, + attacker, + "test", + ModifierDuration.UntilEndOfTurn); + + engine.Execute(new AttackCommand(3, 0)); + engine.Execute(new PassGuardCommand()); + + Assert.Null(engine.State.Players[1].RearGuards[0]); + Assert.Equal(2000, attacker.Modifiers.Where(m => m.EffectId == "hit").Sum(m => m.Power)); + } + [Fact] public void ResolveTrigger_Rejects_When_Nothing_Is_Pending() {