Implement Boost as a separate ability that effects can grant or remove
Cards of grade 0 and 1 have boost by default (HasBoost); CardInstance.BoostOverride lets an effect grant or remove it at runtime. AttackCommand takes an optional BoosterSlot: the back-row unit in the same column adds its power for that battle only and becomes rested. Four new tests.
This commit is contained in:
parent
c2480bab63
commit
66f2ecc3f5
|
|
@ -107,7 +107,8 @@
|
|||
- [x] Фазы: Mulligan → Stand → Draw → Ride → Main → Battle → End.
|
||||
- [x] Старт: выбор grade 0 → выставление → маллиган.
|
||||
- [x] Действия: Ride, Call, Attack, Guard, Drive, Damage, Stand/Rest, Retire, Draw.
|
||||
- [ ] Move, Intercept, Boost, Discard, Shuffle как отдельные интенты.
|
||||
- [x] Поддержка (Boost) как отдельная способность: по умолчанию у 0–1 ранга, передаёт силу карте впереди в той же колонне; `BoostOverride` позволяет выдать или снять её эффектом.
|
||||
- [ ] Move, Intercept, Discard, Shuffle как отдельные интенты.
|
||||
- [ ] `driveCheckCount` как модифицируемое свойство (сейчас константа 2).
|
||||
- [x] Результат боя: `Hit` / `Guarded` как событие.
|
||||
- [ ] Событийная шина и три обработчика: Continuous / Activated / Triggered.
|
||||
|
|
|
|||
|
|
@ -24,6 +24,9 @@ public sealed class CardEntry
|
|||
public Clan Clan { get; set; }
|
||||
public bool Sentinel { get; set; }
|
||||
public bool Restraint { get; set; }
|
||||
|
||||
/// <summary>null — по умолчанию (0 и 1 ранг поддерживают).</summary>
|
||||
public bool? Boost { get; set; }
|
||||
public string? TextEn { get; set; }
|
||||
public string? TextRu { get; set; }
|
||||
|
||||
|
|
@ -41,6 +44,7 @@ public sealed class CardEntry
|
|||
Clan = Clan,
|
||||
IsSentinel = Sentinel,
|
||||
IsRestraint = Restraint,
|
||||
BoostOverride = Boost,
|
||||
TextEn = TextEn,
|
||||
TextRu = TextRu,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -25,6 +25,15 @@ public sealed record CardDefinition
|
|||
/// <summary>Restraint: не может атаковать.</summary>
|
||||
public bool IsRestraint { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Явное переопределение печатной поддержки. null — по умолчанию:
|
||||
/// карты 0 и 1 ранга поддерживают, остальные нет.
|
||||
/// </summary>
|
||||
public bool? BoostOverride { get; init; }
|
||||
|
||||
/// <summary>Печатная способность поддержки. Эффекты могут изменить её в рантайме.</summary>
|
||||
public bool HasBoost => BoostOverride ?? Grade <= 1;
|
||||
|
||||
public string? TextEn { get; init; }
|
||||
public string? TextRu { get; init; }
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ public sealed record RideCommand(string PrintingId) : GameCommand;
|
|||
public sealed record SkipRideCommand : GameCommand;
|
||||
public sealed record CallCommand(string PrintingId, int Slot) : GameCommand;
|
||||
public sealed record EnterBattleCommand : GameCommand;
|
||||
public sealed record AttackCommand(int AttackerSlot, int TargetSlot) : GameCommand;
|
||||
public sealed record AttackCommand(int AttackerSlot, int TargetSlot, int BoosterSlot = -1) : GameCommand;
|
||||
public sealed record GuardCommand(string PrintingId) : GameCommand;
|
||||
public sealed record PassGuardCommand : GameCommand;
|
||||
public sealed record ResolveTriggerCommand(int PowerTarget, int EffectTarget) : GameCommand;
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@ namespace Vanguard.Rules.Engine;
|
|||
///
|
||||
/// Упрощения первого среза (карты со способностями подключаются следующим шагом):
|
||||
/// * Sentinel добавляет 0 щита и не оплачивает стоимость — Perfect Guard пока не работает;
|
||||
/// * Boost не реализован;
|
||||
/// * поддержка работает как отдельная способность: карты 0 и 1 ранга могут передать
|
||||
/// свою силу атакующему Арьергарду из той же колонны, сила действует только в этом бою;
|
||||
/// * триггер требует выбора целей через ResolveTriggerCommand:
|
||||
/// +5000 любой карте в кругах Авангарда или Арьергарда, затем второй эффект;
|
||||
/// правило одинаково для drive check и damage check;
|
||||
|
|
@ -105,7 +106,7 @@ public sealed class GameEngine
|
|||
SkipRideCommand => SkipRide(),
|
||||
CallCommand c => Call(c.PrintingId, c.Slot),
|
||||
EnterBattleCommand => EnterBattle(),
|
||||
AttackCommand c => Attack(c.AttackerSlot, c.TargetSlot),
|
||||
AttackCommand c => Attack(c.AttackerSlot, c.TargetSlot, c.BoosterSlot),
|
||||
GuardCommand c => Guard(c.PrintingId),
|
||||
PassGuardCommand => PassGuard(),
|
||||
ResolveTriggerCommand c => ResolveTrigger(c),
|
||||
|
|
@ -228,7 +229,7 @@ public sealed class GameEngine
|
|||
return CommandResult.Ok();
|
||||
}
|
||||
|
||||
private CommandResult Attack(int attackerSlot, int targetSlot)
|
||||
private CommandResult Attack(int attackerSlot, int targetSlot, int boosterSlot = -1)
|
||||
{
|
||||
if (State.Phase != Phase.Battle)
|
||||
return CommandResult.Reject("Атака доступна только в Боевой фазе.");
|
||||
|
|
@ -257,6 +258,27 @@ public sealed class GameEngine
|
|||
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
|
||||
|
|
@ -265,6 +287,14 @@ public sealed class GameEngine
|
|||
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));
|
||||
}
|
||||
|
||||
State.Step = Step.Guard;
|
||||
|
||||
// на шаге защиты ход ввода переходит обороняющемуся
|
||||
|
|
@ -534,7 +564,7 @@ public sealed class GameEngine
|
|||
var attackerDef = State.Catalog.CardOf(attacker.PrintingId);
|
||||
var targetDef = State.Catalog.CardOf(target.PrintingId);
|
||||
|
||||
var attackPower = attackerDef.Power + attacker.TemporaryPower;
|
||||
var attackPower = attackerDef.Power + attacker.TemporaryPower + battle.BoostPower;
|
||||
var defensePower = targetDef.Power + target.TemporaryPower + battle.GuardShield;
|
||||
var hit = attackPower > defensePower;
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ public sealed record CardDrawnEvent(int Player) : GameEvent;
|
|||
public sealed record UnitRodeEvent(int Player, string PrintingId) : GameEvent;
|
||||
public sealed record UnitCalledEvent(int Player, int Slot, string PrintingId) : GameEvent;
|
||||
public sealed record AttackDeclaredEvent(int Player, int AttackerSlot, int TargetSlot) : GameEvent;
|
||||
public sealed record BoostedEvent(int Player, int BoosterSlot, int AttackerSlot) : GameEvent;
|
||||
public sealed record GuardAddedEvent(int Player, string PrintingId, int Shield) : GameEvent;
|
||||
public sealed record TriggerResolvedEvent(int Player, string PrintingId, TriggerType Trigger) : GameEvent;
|
||||
public sealed record TriggerAppliedEvent(int Player, int PowerTarget, int EffectTarget) : GameEvent;
|
||||
|
|
|
|||
|
|
@ -60,6 +60,12 @@ public sealed class CardInstance
|
|||
|
||||
/// <summary>Временный бонус крита до конца текущего боя.</summary>
|
||||
public int TemporaryCritical { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Переопределение способности поддержки, выданное эффектом.
|
||||
/// null — как напечатано на карте.
|
||||
/// </summary>
|
||||
public bool? BoostOverride { get; set; }
|
||||
}
|
||||
|
||||
public sealed class PlayerState
|
||||
|
|
|
|||
|
|
@ -14,6 +14,9 @@ public sealed class BattleState
|
|||
|
||||
public int GuardShield { get; set; }
|
||||
public int ChecksRemaining { get; set; }
|
||||
|
||||
/// <summary>Сила карт поддержки, действует только в этом бою.</summary>
|
||||
public int BoostPower { get; set; }
|
||||
public List<CardInstance> GuardCards { get; } = new();
|
||||
}
|
||||
|
||||
|
|
@ -49,6 +52,12 @@ public sealed class GameState
|
|||
|
||||
public CardDefinition Definition(CardInstance card) => Catalog.CardOf(card.PrintingId);
|
||||
|
||||
/// <summary>
|
||||
/// Итоговая способность поддержки: выданная эффектом важнее печатной.
|
||||
/// </summary>
|
||||
public bool CanBoost(CardInstance unit) =>
|
||||
unit.BoostOverride ?? Definition(unit).HasBoost;
|
||||
|
||||
public CardInstance? GetUnit(int player, int slot) =>
|
||||
slot < 0
|
||||
? Players[player].Vanguard
|
||||
|
|
|
|||
|
|
@ -198,6 +198,78 @@ public class GameEngineTests
|
|||
Assert.Equal(0, vanguard.TemporaryPower);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Boost_Adds_Back_Row_Power_To_Attacker()
|
||||
{
|
||||
var engine = ReadyToBoost();
|
||||
|
||||
Assert.True(engine.Execute(new AttackCommand(3, -1, 0)).Accepted);
|
||||
Assert.True(engine.State.Players[0].RearGuards[0]!.Rested);
|
||||
Assert.True(engine.Execute(new PassGuardCommand()).Accepted);
|
||||
|
||||
Assert.Single(engine.State.Players[1].Damage);
|
||||
Assert.Contains(engine.Events, e => e is BoostedEvent { BoosterSlot: 0, AttackerSlot: 3 });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Without_Boost_The_Same_Attack_Misses()
|
||||
{
|
||||
var engine = ReadyToBoost();
|
||||
|
||||
Assert.True(engine.Execute(new AttackCommand(3, -1)).Accepted);
|
||||
Assert.True(engine.Execute(new PassGuardCommand()).Accepted);
|
||||
|
||||
Assert.Empty(engine.State.Players[1].Damage);
|
||||
Assert.False(engine.State.Players[0].RearGuards[0]!.Rested);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Boost_Rejects_Wrong_Column()
|
||||
{
|
||||
var engine = ReadyToBoost();
|
||||
|
||||
var result = engine.Execute(new AttackCommand(3, -1, 2));
|
||||
|
||||
Assert.False(result.Accepted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Grade_Two_Cannot_Boost()
|
||||
{
|
||||
var engine = ReadyToBoost();
|
||||
|
||||
engine.State.Players[0].RearGuards[0] = TakeFromDeck(engine.State.Players[0], Gallatin);
|
||||
|
||||
var result = engine.Execute(new AttackCommand(3, -1, 0));
|
||||
|
||||
Assert.False(result.Accepted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Effect_Can_Grant_Boost_To_Grade_Two()
|
||||
{
|
||||
var engine = ReadyToBoost();
|
||||
var booster = TakeFromDeck(engine.State.Players[0], Gallatin);
|
||||
engine.State.Players[0].RearGuards[0] = booster;
|
||||
|
||||
Assert.False(engine.Execute(new AttackCommand(3, -1, 0)).Accepted);
|
||||
|
||||
booster.BoostOverride = true;
|
||||
|
||||
Assert.True(engine.Execute(new AttackCommand(3, -1, 0)).Accepted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Effect_Can_Remove_Boost_From_Grade_One()
|
||||
{
|
||||
var engine = ReadyToBoost();
|
||||
var booster = engine.State.Players[0].RearGuards[0]!;
|
||||
|
||||
booster.BoostOverride = false;
|
||||
|
||||
Assert.False(engine.Execute(new AttackCommand(3, -1, 0)).Accepted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveTrigger_Rejects_When_Nothing_Is_Pending()
|
||||
{
|
||||
|
|
@ -267,6 +339,28 @@ public class GameEngineTests
|
|||
return engine;
|
||||
}
|
||||
|
||||
private GameEngine ReadyToBoost()
|
||||
{
|
||||
var engine = StartedEngine();
|
||||
Give(engine, 0, Marron);
|
||||
engine.Execute(new RideCommand(Marron));
|
||||
|
||||
// сзади слева — поддержка (1 ранг), впереди слева — атакующий (2 ранг)
|
||||
Give(engine, 0, Marron);
|
||||
engine.Execute(new CallCommand(Marron, 0));
|
||||
|
||||
Give(engine, 0, Gallatin);
|
||||
engine.Execute(new CallCommand(Gallatin, 3));
|
||||
|
||||
// призванные карты входят повёрнутыми — для теста поддержки поднимаем обе
|
||||
engine.State.Players[0].RearGuards[0]!.Rested = false;
|
||||
engine.State.Players[0].RearGuards[3]!.Rested = false;
|
||||
|
||||
SetVanguard(engine, 1, Gallatin);
|
||||
engine.Execute(new EnterBattleCommand());
|
||||
return engine;
|
||||
}
|
||||
|
||||
private List<string> BuildDeck()
|
||||
{
|
||||
var mustInclude = new[] { Barcgal, Marron, Gallatin, Alfred, Nike, Flogal, Govannon };
|
||||
|
|
@ -329,12 +423,15 @@ public class GameEngineTests
|
|||
player.Deck.Insert(0, cards[i]);
|
||||
}
|
||||
|
||||
private static CardInstance TakeAnyFromDeck(PlayerState player)
|
||||
private static void SetVanguard(GameEngine engine, int player, string printingId)
|
||||
{
|
||||
Assert.NotEmpty(player.Deck);
|
||||
var card = player.Deck[0];
|
||||
player.Deck.RemoveAt(0);
|
||||
return card;
|
||||
var p = engine.State.Players[player];
|
||||
var index = p.Deck.FindIndex(c => c.PrintingId == printingId);
|
||||
Assert.True(index >= 0, $"В колоде нет {printingId}");
|
||||
|
||||
var card = p.Deck[index];
|
||||
p.Deck.RemoveAt(index);
|
||||
p.Vanguard = card;
|
||||
}
|
||||
|
||||
private static CardInstance TakeFromDeck(PlayerState player, string printingId)
|
||||
|
|
@ -346,4 +443,12 @@ public class GameEngineTests
|
|||
player.Deck.RemoveAt(index);
|
||||
return card;
|
||||
}
|
||||
|
||||
private static CardInstance TakeAnyFromDeck(PlayerState player)
|
||||
{
|
||||
Assert.NotEmpty(player.Deck);
|
||||
var card = player.Deck[0];
|
||||
player.Deck.RemoveAt(0);
|
||||
return card;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue