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()
{