Trigger targeting: +5000 and secondary effect are chosen by the player

Drive check now pauses on a trigger and waits for ResolveTriggerCommand(powerTarget, effectTarget). Power and crit modifiers moved from the battle to the unit and are cleared at the end of the battle. Stand stands the chosen rear-guard; Critical applies +1 crit to the chosen unit. Damage-check triggers still apply only the secondary effect.
This commit is contained in:
MpakobecEx 2026-10-21 00:24:21 +05:00
parent 1b317b6532
commit a4457c6d21
7 changed files with 234 additions and 64 deletions

View File

@ -114,7 +114,7 @@
- [ ] Check Timing: сбор → сортировка → разрешение по одному → повтор.
- [ ] Стоимости: CB, SB, discard, rest self, retire self, put to soul, return to deck.
- [ ] Цели: авто по фильтру и выбор игроком, «до N» / «ровно N».
- [ ] Слои модификаторов силы/крита с длительностью.
- [~] Слои модификаторов силы/крита: временные бонусы до конца боя есть, постоянных слоёв нет.
- [x] Валидация интентов: `Rejected(reason)` без изменения состояния.
- [ ] Список легальных интентов на каждый момент (для UI и античита).
- [~] Детерминированный RNG: один поток на партию, состояние в `GameState`.

View File

@ -11,6 +11,7 @@ public sealed record EnterBattleCommand : GameCommand;
public sealed record AttackCommand(int AttackerSlot, int TargetSlot) : GameCommand;
public sealed record GuardCommand(string PrintingId) : GameCommand;
public sealed record PassGuardCommand : GameCommand;
public sealed record ResolveTriggerCommand(int PowerTarget, int EffectTarget) : GameCommand;
public sealed record EndTurnCommand : GameCommand;
public sealed record CommandResult(bool Accepted, string? Reason)

View File

@ -9,8 +9,10 @@ namespace Vanguard.Rules.Engine;
/// Упрощения первого среза (карты со способностями подключаются следующим шагом):
/// * Sentinel добавляет 0 щита и не оплачивает стоимость — Perfect Guard пока не работает;
/// * Boost не реализован;
/// * выбор цели триггера автоматический: усиление получает атакующая карта,
/// Stand возвращает первый повёрнутый Арьергард.
/// * триггер на drive check требует выбора целей через ResolveTriggerCommand:
/// +5000 любой карте в кругах Авангарда или Арьергарда, затем второй эффект;
/// * триггер на damage check пока применяет только второй эффект,
/// Stand поднимает первый повёрнутый Арьергард.
/// </summary>
public sealed class GameEngine
{
@ -105,6 +107,7 @@ public sealed class GameEngine
AttackCommand c => Attack(c.AttackerSlot, c.TargetSlot),
GuardCommand c => Guard(c.PrintingId),
PassGuardCommand => PassGuard(),
ResolveTriggerCommand c => ResolveTrigger(c),
EndTurnCommand => EndTurn(),
_ => CommandResult.Reject("Неизвестная команда."),
};
@ -307,7 +310,7 @@ public sealed class GameEngine
if (State.ActivePlayer != 1 - battle.AttackerPlayer)
return CommandResult.Reject("Защищается только обороняющийся.");
ResolveBattle(battle);
BeginResolution(battle);
return CommandResult.Ok();
}
@ -368,8 +371,133 @@ public sealed class GameEngine
// ---------- бой ----------
private void ResolveBattle(BattleState battle)
private void BeginResolution(BattleState battle)
{
if (battle.AttackerSlot >= 0)
{
FinishBattle(battle);
return;
}
battle.ChecksRemaining = DriveChecks;
State.ActivePlayer = battle.AttackerPlayer;
State.Step = Step.Drive;
ContinueDriveChecks(battle);
}
/// <summary>
/// Идёт по drive-чекам. На триггере останавливается и ждёт ResolveTriggerCommand:
/// игрок сам выбирает, кому дать +5000 и куда применить второй эффект.
/// </summary>
private void ContinueDriveChecks(BattleState battle)
{
var player = battle.AttackerPlayer;
while (battle.ChecksRemaining > 0 && !State.IsFinished)
{
var card = TakeTop(player);
if (card is null)
{
FinishBattle(battle);
return;
}
var def = State.Catalog.CardOf(card.PrintingId);
if (def.Trigger == TriggerType.None)
{
State.Players[player].Hand.Add(card);
_events.Add(new CardDrawnEvent(player));
battle.ChecksRemaining--;
continue;
}
State.Players[player].Trigger.Add(card);
battle.ChecksRemaining--;
_events.Add(new TriggerResolvedEvent(player, card.PrintingId, def.Trigger));
State.PendingTrigger = new PendingTrigger
{
Player = player,
PrintingId = card.PrintingId,
Trigger = def.Trigger,
Context = TriggerContext.DriveCheck,
GrantsPower = true,
};
State.Step = Step.Trigger;
State.ActivePlayer = player;
return;
}
FinishBattle(battle);
}
private CommandResult ResolveTrigger(ResolveTriggerCommand command)
{
var pending = State.PendingTrigger;
if (pending is null || State.Step != Step.Trigger)
return CommandResult.Reject("Сейчас нет триггера для разрешения.");
if (State.ActivePlayer != pending.Player)
return CommandResult.Reject("Триггер разрешает его владелец.");
var player = pending.Player;
if (pending.GrantsPower)
{
var powerTarget = State.GetUnit(player, command.PowerTarget);
if (powerTarget is null)
return CommandResult.Reject("Нет цели для +5000 силы.");
}
if (pending.Trigger is TriggerType.Critical or TriggerType.Stand)
{
var effectUnit = State.GetUnit(player, command.EffectTarget);
if (effectUnit is null)
return CommandResult.Reject("Нет цели для второго эффекта.");
if (pending.Trigger == TriggerType.Stand && command.EffectTarget < 0)
return CommandResult.Reject("Поднять можно только карту в кругу Арьергарда.");
}
if (pending.GrantsPower)
State.GetUnit(player, command.PowerTarget)!.TemporaryPower += TriggerPowerBonus;
switch (pending.Trigger)
{
case TriggerType.Critical:
State.GetUnit(player, command.EffectTarget)!.TemporaryCritical += 1;
break;
case TriggerType.Stand:
State.GetUnit(player, command.EffectTarget)!.Rested = false;
break;
case TriggerType.Draw:
Draw(player, 1);
break;
case TriggerType.Heal:
Heal(player);
break;
}
State.PendingTrigger = null;
_events.Add(new TriggerAppliedEvent(player, command.PowerTarget, command.EffectTarget));
if (pending.Context == TriggerContext.DriveCheck && State.Battle is { } battle)
{
State.Step = Step.Drive;
ContinueDriveChecks(battle);
}
return CommandResult.Ok();
}
private void FinishBattle(BattleState battle)
{
if (State.IsFinished)
{
ClearBattle(battle);
return;
}
var defenderIndex = 1 - battle.AttackerPlayer;
var attacker = State.GetUnit(battle.AttackerPlayer, battle.AttackerSlot);
var target = State.GetUnit(defenderIndex, battle.TargetSlot);
@ -380,20 +508,11 @@ public sealed class GameEngine
return;
}
if (battle.AttackerSlot < 0)
DriveCheck(battle);
if (State.IsFinished)
{
ClearBattle(battle);
return;
}
var attackerDef = State.Catalog.CardOf(attacker.PrintingId);
var targetDef = State.Catalog.CardOf(target.PrintingId);
var attackPower = attackerDef.Power + battle.PowerBonus;
var defensePower = targetDef.Power + battle.GuardShield;
var attackPower = attackerDef.Power + attacker.TemporaryPower;
var defensePower = targetDef.Power + target.TemporaryPower + battle.GuardShield;
var hit = attackPower > defensePower;
_events.Add(new BattleResolvedEvent(hit));
@ -402,7 +521,7 @@ public sealed class GameEngine
{
if (battle.TargetSlot < 0)
{
var damage = attackerDef.Critical + battle.CriticalBonus;
var damage = attackerDef.Critical + attacker.TemporaryCritical;
DealDamage(defenderIndex, damage);
}
else
@ -425,54 +544,18 @@ public sealed class GameEngine
defender.Drop.Add(guard);
}
foreach (var unit in AllUnits(0).Concat(AllUnits(1)))
{
unit.TemporaryPower = 0;
unit.TemporaryCritical = 0;
}
State.Battle = null;
State.PendingTrigger = null;
State.Step = Step.None;
State.ActivePlayer = battle.AttackerPlayer;
}
private void DriveCheck(BattleState battle)
{
var player = battle.AttackerPlayer;
for (var i = 0; i < DriveChecks; i++)
{
if (State.IsFinished)
return;
var card = TakeTop(player);
if (card is null)
return;
var def = State.Catalog.CardOf(card.PrintingId);
if (def.Trigger == TriggerType.None)
{
State.Players[player].Hand.Add(card);
_events.Add(new CardDrawnEvent(player));
continue;
}
State.Players[player].Trigger.Add(card);
battle.PowerBonus += TriggerPowerBonus;
_events.Add(new TriggerResolvedEvent(player, card.PrintingId, def.Trigger));
switch (def.Trigger)
{
case TriggerType.Critical:
battle.CriticalBonus += 1;
break;
case TriggerType.Draw:
Draw(player, 1);
break;
case TriggerType.Stand:
StandFirstRested(player);
break;
case TriggerType.Heal:
Heal(player);
break;
}
}
}
private void DealDamage(int player, int amount)
{
for (var i = 0; i < amount; i++)

View File

@ -13,6 +13,7 @@ public sealed record UnitCalledEvent(int Player, int Slot, string PrintingId) :
public sealed record AttackDeclaredEvent(int Player, int AttackerSlot, int TargetSlot) : 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;
public sealed record BattleResolvedEvent(bool Hit) : GameEvent;
public sealed record DamageTakenEvent(int Player, int Amount, int Total) : GameEvent;
public sealed record HealedEvent(int Player) : GameEvent;

View File

@ -1,3 +1,5 @@
using Vanguard.Rules.Cards;
namespace Vanguard.Rules.Engine;
public enum Phase
@ -15,6 +17,26 @@ public enum Step
{
None,
Guard,
Drive,
Trigger,
}
public enum TriggerContext
{
DriveCheck,
DamageCheck,
}
/// <summary>Триггер, который ждёт выбора целей от владельца.</summary>
public sealed class PendingTrigger
{
public required int Player { get; init; }
public required string PrintingId { get; init; }
public required TriggerType Trigger { get; init; }
public required TriggerContext Context { get; init; }
/// <summary>На drive check триггер сначала даёт +5000 силы, на damage check — нет.</summary>
public required bool GrantsPower { get; init; }
}
/// <summary>Экземпляр карты в партии. Один принт может встречаться много раз.</summary>
@ -28,6 +50,12 @@ public sealed class CardInstance
/// <summary>Открыта ли карта. Для урона — лицом вверх после Counter Blast.</summary>
public bool FaceUp { get; set; }
/// <summary>Временный бонус силы до конца текущего боя (триггеры, эффекты).</summary>
public int TemporaryPower { get; set; }
/// <summary>Временный бонус крита до конца текущего боя.</summary>
public int TemporaryCritical { get; set; }
}
public sealed class PlayerState

View File

@ -12,10 +12,9 @@ public sealed class BattleState
/// <summary>Индекс цели: -1 — Авангард противника.</summary>
public required int TargetSlot { get; init; }
public int PowerBonus { get; set; }
public int CriticalBonus { get; set; }
public List<CardInstance> GuardCards { get; } = new();
public int GuardShield { get; set; }
public int ChecksRemaining { get; set; }
public List<CardInstance> GuardCards { get; } = new();
}
public sealed class GameState
@ -40,6 +39,7 @@ public sealed class GameState
public int NextInstanceId { get; internal set; } = 1;
public BattleState? Battle { get; internal set; }
public PendingTrigger? PendingTrigger { get; internal set; }
public bool IsFinished { get; internal set; }
public int? Winner { get; internal set; }

View File

@ -12,6 +12,9 @@ public class GameEngineTests
private const string Marron = "BT01/042EN"; // grade 1, 8000
private const string Gallatin = "BT01/021EN"; // grade 2, 10000
private const string Alfred = "BT01/001EN"; // grade 3
private const string Nike = "BT01/056EN"; // Critical
private const string Flogal = "BT01/046EN"; // Stand
private const string Govannon = "BT01/045EN"; // Draw
[Fact]
public void Start_Places_Starting_Vanguard_And_Draws_Five()
@ -115,6 +118,49 @@ public class GameEngineTests
Assert.Contains(engine.Events, e => e is GameFinishedEvent);
}
[Fact]
public void Critical_Trigger_Grants_Power_And_Crit_To_Chosen_Unit()
{
var engine = ReadyToAttack();
StackDeck(engine.State.Players[0], Nike, Gallatin);
Assert.True(engine.Execute(new AttackCommand(-1, -1)).Accepted);
Assert.True(engine.Execute(new PassGuardCommand()).Accepted);
Assert.NotNull(engine.State.PendingTrigger);
Assert.Equal(Step.Trigger, engine.State.Step);
Assert.True(engine.Execute(new ResolveTriggerCommand(-1, -1)).Accepted);
Assert.Null(engine.State.PendingTrigger);
Assert.Equal(2, engine.State.Players[1].Damage.Count);
}
[Fact]
public void Stand_Trigger_Stands_Chosen_RearGuard()
{
var engine = ReadyToAttackWithRearGuard();
StackDeck(engine.State.Players[0], Flogal, Gallatin);
Assert.True(engine.Execute(new AttackCommand(-1, -1)).Accepted);
Assert.True(engine.Execute(new PassGuardCommand()).Accepted);
Assert.NotNull(engine.State.PendingTrigger);
var rearGuard = engine.State.Players[0].RearGuards[0]!;
Assert.True(rearGuard.Rested);
Assert.True(engine.Execute(new ResolveTriggerCommand(-1, 0)).Accepted);
Assert.False(rearGuard.Rested);
}
[Fact]
public void ResolveTrigger_Rejects_When_Nothing_Is_Pending()
{
var engine = StartedEngine();
Assert.False(engine.Execute(new ResolveTriggerCommand(-1, -1)).Accepted);
}
[Fact]
public void Same_Seed_And_Commands_Produce_Same_Events()
{
@ -165,9 +211,20 @@ public class GameEngineTests
return engine;
}
private GameEngine ReadyToAttackWithRearGuard()
{
var engine = StartedEngine();
Give(engine, 0, Marron);
engine.Execute(new RideCommand(Marron));
Give(engine, 0, Gallatin);
engine.Execute(new CallCommand(Gallatin, 0));
engine.Execute(new EnterBattleCommand());
return engine;
}
private List<string> BuildDeck()
{
var mustInclude = new[] { Barcgal, Marron, Gallatin, Alfred };
var mustInclude = new[] { Barcgal, Marron, Gallatin, Alfred, Nike, Flogal, Govannon };
var names = mustInclude
.Select(_catalog.CardOf)
.Select(c => c.NameId)