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.
This commit is contained in:
MpakobecEx 2026-10-21 13:38:21 +05:00
parent 1fcb3b80df
commit ff7774b236
6 changed files with 293 additions and 6 deletions

View File

@ -122,6 +122,9 @@
- [x] Выбор цели игроком (`PendingChoice` / `ChooseCommand`); «до N» / «ровно N» ещё нет. - [x] Выбор цели игроком (`PendingChoice` / `ChooseCommand`); «до N» / «ровно N» ещё нет.
- [x] Выбор карты из руки как стоимости эффекта (Morgana: сброс → +4000). - [x] Выбор карты из руки как стоимости эффекта (Morgana: сброс → +4000).
- [x] Модификаторы силы/крита с источником и сроком жизни (`StatModifier`); выдача и снятие способностей эффектами (`GrantedAbility`). - [x] Модификаторы силы/крита с источником и сроком жизни (`StatModifier`); выдача и снятие способностей эффектами (`GrantedAbility`).
- [x] Выбор от `min` до `max` целей на поле (`AskUnitMultiChoice`) для «до N» и «ровно N».
- [x] Запреты с источником и сроком жизни (`CardRestriction`): не вставать, не атаковать, не поддерживать, не перехватывать, не быть выбранным; снятие Сдерживания эффектом.
- [x] `OnAttackHit` срабатывает и при попадании по Арьергарду.
- [x] Поиск в колоде без просмотра: 0 совпадений — уведомление и перемешивание, 1 — сразу в руку, несколько — выбор; разные арты считаются разными картами. - [x] Поиск в колоде без просмотра: 0 совпадений — уведомление и перемешивание, 1 — сразу в руку, несколько — выбор; разные арты считаются разными картами.
- [x] Поиск с призывом в круг Арьергарда (Alfred) и с райдом (API, Llew). - [x] Поиск с призывом в круг Арьергарда (Alfred) и с райдом (API, Llew).
- [x] Просмотр верхней карты с решением «оставить на верху / положить в низ» (Cocoa). - [x] Просмотр верхней карты с решением «оставить на верху / положить в низ» (Cocoa).

View File

@ -529,6 +529,30 @@ public sealed class GameEngine
unit.GrantedAbilities.RemoveAll(g => predicate(g)); unit.GrantedAbilities.RemoveAll(g => predicate(g));
} }
/// <summary>Запретить карте действие с указанием источника и срока жизни.</summary>
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,
});
}
/// <summary>Снять запреты, подходящие под условие.</summary>
public void RemoveRestrictions(Func<CardRestriction, bool> predicate)
{
foreach (var unit in AllUnits(0).Concat(AllUnits(1)))
unit.Restrictions.RemoveAll(r => predicate(r));
}
// ---------- стоимости ---------- // ---------- стоимости ----------
public int FaceDownDamage(int player) => public int FaceDownDamage(int player) =>
@ -630,6 +654,74 @@ public sealed class GameEngine
State.ActivePlayer = chooser; State.ActivePlayer = chooser;
} }
/// <summary>
/// Выбор от min до max целей на поле. Игрок выбирает по одной; когда минимум
/// набран, появляется вариант «Готово». Выбранные цели приходят одним списком.
/// </summary>
public void AskUnitMultiChoice(
int chooser,
string prompt,
IReadOnlyList<UnitTarget> targets,
int min,
int max,
Action<IReadOnlyList<UnitTarget>> onChosen)
{
var chosen = new List<UnitTarget>();
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) public void RetireRearGuard(int player, int slot)
{ {
var unit = State.Players[player].RearGuards[slot]; var unit = State.Players[player].RearGuards[slot];
@ -1133,10 +1225,12 @@ public sealed class GameEngine
if (attacker.Rested) if (attacker.Rested)
return CommandResult.Reject("Юнит уже повёрнут."); return CommandResult.Reject("Юнит уже повёрнут.");
var attackerDef = State.Catalog.CardOf(attacker.PrintingId); if (State.IsRestraint(attacker))
if (attackerDef.IsRestraint)
return CommandResult.Reject("Юнит со Сдерживанием не может атаковать."); return CommandResult.Reject("Юнит со Сдерживанием не может атаковать.");
if (State.HasRestriction(attacker, RestrictionType.CannotAttack))
return CommandResult.Reject("Юнит не может атаковать.");
var target = State.GetUnit(1 - State.ActivePlayer, targetSlot); var target = State.GetUnit(1 - State.ActivePlayer, targetSlot);
if (target is null) if (target is null)
return CommandResult.Reject("Нет цели."); return CommandResult.Reject("Нет цели.");
@ -1340,7 +1434,14 @@ public sealed class GameEngine
State.TurnNumber++; State.TurnNumber++;
foreach (var unit in AllUnits(State.ActivePlayer)) foreach (var unit in AllUnits(State.ActivePlayer))
{
if (!State.HasRestriction(unit, RestrictionType.CannotStand))
unit.Rested = false; unit.Rested = false;
}
// Запрет «не вставать» действует до этой фазы Подъёма и снимается после неё
foreach (var unit in AllUnits(State.ActivePlayer))
unit.Restrictions.RemoveAll(r => r.Duration == ModifierDuration.UntilOwnerNextStand);
State.Phase = Phase.Stand; State.Phase = Phase.Stand;
_events.Add(new PhaseChangedEvent(State.ActivePlayer, 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.Modifiers.RemoveAll(m => m.Duration == ModifierDuration.UntilEndOfTurn);
unit.GrantedAbilities.RemoveAll(g => g.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; return;
} }
var attackerDef = State.Catalog.CardOf(attacker.PrintingId);
var defenseUnit = battle.Interceptor ?? target; var defenseUnit = battle.Interceptor ?? target;
var attackPower = EffectivePower(attacker); var attackPower = EffectivePower(attacker);
@ -1603,6 +1704,14 @@ public sealed class GameEngine
State.Players[defenderIndex].RearGuards[battle.TargetSlot] = null; State.Players[defenderIndex].RearGuards[battle.TargetSlot] = null;
State.Players[defenderIndex].Drop.Add(target); State.Players[defenderIndex].Drop.Add(target);
_events.Add(new UnitRetiredEvent(defenderIndex, battle.TargetSlot)); _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.Modifiers.RemoveAll(m => m.Duration == ModifierDuration.UntilEndOfBattle);
unit.GrantedAbilities.RemoveAll(g => g.Duration == ModifierDuration.UntilEndOfBattle); unit.GrantedAbilities.RemoveAll(g => g.Duration == ModifierDuration.UntilEndOfBattle);
unit.Restrictions.RemoveAll(r => r.Duration == ModifierDuration.UntilEndOfBattle);
} }
State.Battle = null; State.Battle = null;

View File

@ -26,6 +26,9 @@ public enum Step
/// <summary>Вариант выбора, который предлагается игроку.</summary> /// <summary>Вариант выбора, который предлагается игроку.</summary>
public sealed record ChoiceOption(int Slot, string Label); public sealed record ChoiceOption(int Slot, string Label);
/// <summary>Карта на поле: игрок и круг (-1 — Авангард).</summary>
public sealed record UnitTarget(int Player, int Slot);
/// <summary> /// <summary>
/// Ожидание выбора игрока. Resolve получает индекс выбранного варианта. /// Ожидание выбора игрока. Resolve получает индекс выбранного варианта.
/// Пока выбор не сделан, движок не продолжает разрешение способности. /// Пока выбор не сделан, движок не продолжает разрешение способности.
@ -77,6 +80,15 @@ public sealed class CardInstance
/// <summary>Способности, выданные эффектами, а не напечатанные на карте.</summary> /// <summary>Способности, выданные эффектами, а не напечатанные на карте.</summary>
public List<GrantedAbility> GrantedAbilities { get; } = new(); public List<GrantedAbility> GrantedAbilities { get; } = new();
/// <summary>Запреты, выданные эффектами.</summary>
public List<CardRestriction> Restrictions { get; } = new();
/// <summary>
/// Переопределение Сдерживания, выданное эффектом.
/// null — как напечатано на карте.
/// </summary>
public bool? RestraintOverride { get; set; }
/// <summary> /// <summary>
/// Переопределение способности поддержки, выданное эффектом. /// Переопределение способности поддержки, выданное эффектом.
/// null — как напечатано на карте. /// null — как напечатано на карте.

View File

@ -79,11 +79,21 @@ public sealed class GameState
/// Итоговая способность поддержки: выданная эффектом важнее печатной. /// Итоговая способность поддержки: выданная эффектом важнее печатной.
/// </summary> /// </summary>
public bool CanBoost(CardInstance unit) => public bool CanBoost(CardInstance unit) =>
unit.BoostOverride ?? Definition(unit).HasBoost; !HasRestriction(unit, RestrictionType.CannotBoost)
&& (unit.BoostOverride ?? Definition(unit).HasBoost);
/// <summary>Итоговая способность перехвата: выданная эффектом важнее печатной.</summary> /// <summary>Итоговая способность перехвата: выданная эффектом важнее печатной.</summary>
public bool CanIntercept(CardInstance unit) => public bool CanIntercept(CardInstance unit) =>
unit.InterceptOverride ?? Definition(unit).HasIntercept; !HasRestriction(unit, RestrictionType.CannotIntercept)
&& (unit.InterceptOverride ?? Definition(unit).HasIntercept);
/// <summary>Есть ли у карты запрет указанного вида.</summary>
public bool HasRestriction(CardInstance unit, RestrictionType type) =>
unit.Restrictions.Any(r => r.Type == type);
/// <summary>Действует ли на карте Сдерживание с учётом переопределения эффектом.</summary>
public bool IsRestraint(CardInstance unit) =>
unit.RestraintOverride ?? Definition(unit).IsRestraint;
public CardInstance? GetUnit(int player, int slot) => public CardInstance? GetUnit(int player, int slot) =>
slot < 0 slot < 0

View File

@ -6,9 +6,27 @@ public enum ModifierDuration
{ {
UntilEndOfBattle, UntilEndOfBattle,
UntilEndOfTurn, UntilEndOfTurn,
/// <summary>
/// Живёт до следующей фазы Подъёма владельца карты. Нужен запретам вида
/// «не может вставать в следующую фазу Подъёма»: снимается уже после того,
/// как карта должна была встать.
/// </summary>
UntilOwnerNextStand,
Permanent, Permanent,
} }
/// <summary>Постоянный запрет на действие.</summary>
public enum RestrictionType
{
CannotStand,
CannotAttack,
CannotBoost,
CannotIntercept,
CannotBeChosen,
}
/// <summary> /// <summary>
/// Прибавка к силе или криту. Знает, от какой карты пришла и каким эффектом, /// Прибавка к силе или криту. Знает, от какой карты пришла и каким эффектом,
/// поэтому её можно точечно снять, не трогая остальные. /// поэтому её можно точечно снять, не трогая остальные.
@ -30,3 +48,12 @@ public sealed class GrantedAbility
public required string EffectId { get; init; } public required string EffectId { get; init; }
public required ModifierDuration Duration { get; init; } public required ModifierDuration Duration { get; init; }
} }
/// <summary>Запрет на действие, выданный эффектом и знающий свой источник.</summary>
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; }
}

View File

@ -1040,6 +1040,131 @@ public class GameEngineTests
Assert.Equal(0, vanguard.Modifiers.Where(m => m.EffectId == "ability").Sum(m => m.Power)); 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<UnitTarget>? 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<UnitTarget>? 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] [Fact]
public void ResolveTrigger_Rejects_When_Nothing_Is_Pending() public void ResolveTrigger_Rejects_When_Nothing_Is_Pending()
{ {