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:
parent
1fcb3b80df
commit
ff7774b236
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -529,6 +529,30 @@ public sealed class GameEngine
|
|||
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) =>
|
||||
|
|
@ -630,6 +654,74 @@ public sealed class GameEngine
|
|||
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)
|
||||
{
|
||||
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))
|
||||
{
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -26,6 +26,9 @@ public enum Step
|
|||
/// <summary>Вариант выбора, который предлагается игроку.</summary>
|
||||
public sealed record ChoiceOption(int Slot, string Label);
|
||||
|
||||
/// <summary>Карта на поле: игрок и круг (-1 — Авангард).</summary>
|
||||
public sealed record UnitTarget(int Player, int Slot);
|
||||
|
||||
/// <summary>
|
||||
/// Ожидание выбора игрока. Resolve получает индекс выбранного варианта.
|
||||
/// Пока выбор не сделан, движок не продолжает разрешение способности.
|
||||
|
|
@ -77,6 +80,15 @@ public sealed class CardInstance
|
|||
|
||||
/// <summary>Способности, выданные эффектами, а не напечатанные на карте.</summary>
|
||||
public List<GrantedAbility> GrantedAbilities { get; } = new();
|
||||
|
||||
/// <summary>Запреты, выданные эффектами.</summary>
|
||||
public List<CardRestriction> Restrictions { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Переопределение Сдерживания, выданное эффектом.
|
||||
/// null — как напечатано на карте.
|
||||
/// </summary>
|
||||
public bool? RestraintOverride { get; set; }
|
||||
/// <summary>
|
||||
/// Переопределение способности поддержки, выданное эффектом.
|
||||
/// null — как напечатано на карте.
|
||||
|
|
|
|||
|
|
@ -79,11 +79,21 @@ public sealed class GameState
|
|||
/// Итоговая способность поддержки: выданная эффектом важнее печатной.
|
||||
/// </summary>
|
||||
public bool CanBoost(CardInstance unit) =>
|
||||
unit.BoostOverride ?? Definition(unit).HasBoost;
|
||||
!HasRestriction(unit, RestrictionType.CannotBoost)
|
||||
&& (unit.BoostOverride ?? Definition(unit).HasBoost);
|
||||
|
||||
/// <summary>Итоговая способность перехвата: выданная эффектом важнее печатной.</summary>
|
||||
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) =>
|
||||
slot < 0
|
||||
|
|
|
|||
|
|
@ -6,9 +6,27 @@ public enum ModifierDuration
|
|||
{
|
||||
UntilEndOfBattle,
|
||||
UntilEndOfTurn,
|
||||
|
||||
/// <summary>
|
||||
/// Живёт до следующей фазы Подъёма владельца карты. Нужен запретам вида
|
||||
/// «не может вставать в следующую фазу Подъёма»: снимается уже после того,
|
||||
/// как карта должна была встать.
|
||||
/// </summary>
|
||||
UntilOwnerNextStand,
|
||||
|
||||
Permanent,
|
||||
}
|
||||
|
||||
/// <summary>Постоянный запрет на действие.</summary>
|
||||
public enum RestrictionType
|
||||
{
|
||||
CannotStand,
|
||||
CannotAttack,
|
||||
CannotBoost,
|
||||
CannotIntercept,
|
||||
CannotBeChosen,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Прибавка к силе или криту. Знает, от какой карты пришла и каким эффектом,
|
||||
/// поэтому её можно точечно снять, не трогая остальные.
|
||||
|
|
@ -30,3 +48,12 @@ public sealed class GrantedAbility
|
|||
public required string EffectId { 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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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]
|
||||
public void ResolveTrigger_Rejects_When_Nothing_Is_Pending()
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in New Issue