Wire phase-start, attack-declared and attack-hit triggers

Added a command guard while an ability resolves, a continuation mechanism for mid-battle pauses, and TriggerAll for scanning the field. Cards: Demon Eater (soul charge at main phase start), Tyrant Deathrex (+5000 when attacking), Maiden of Libra (Counter Blast 2 and draw on hit). Three tests.
This commit is contained in:
MpakobecEx 2026-10-21 13:23:36 +05:00
parent a5fef89984
commit 4333bb5bd0
5 changed files with 255 additions and 13 deletions

View File

@ -114,7 +114,8 @@
- [ ] Discard, Shuffle как отдельные интенты. - [ ] Discard, Shuffle как отдельные интенты.
- [x] Число проверок принадлежит атакующей карте: 0–2 ранг — одна, 3+ — две; Арьергард по умолчанию без проверок; `DriveChecksOverride` меняет число (в том числе снимает двойную). - [x] Число проверок принадлежит атакующей карте: 0–2 ранг — одна, 3+ — две; Арьергард по умолчанию без проверок; `DriveChecksOverride` меняет число (в том числе снимает двойную).
- [x] Результат боя: `Hit` / `Guarded` как событие. - [x] Результат боя: `Hit` / `Guarded` как событие.
- [x] Событийная шина: `Triggered` (OnRideOver, OnPlace, OnBoost), `Continuous` с пересчётом при запросе, `Activated` (повернуть себя + сброс карты). - [x] Событийная шина: `Triggered` (OnRideOver, OnPlace, OnBoost, OnPhaseStart, OnAttackDeclared, OnAttackHit), `Continuous` с пересчётом при запросе, `Activated` (повернуть себя + сброс карты).
- [ ] Событие OnDamageTaken и триггеры на получение урона.
- [x] Check Timing: сбор сработавших, порядок (активный игрок первым, выбор игрока), пауза на опциональные способности. - [x] Check Timing: сбор сработавших, порядок (активный игрок первым, выбор игрока), пауза на опциональные способности.
- [x] Выбор целей: `PendingChoice` + `ChooseCommand`, используется Blaster Blade для уничтожения Арьергарда противника. - [x] Выбор целей: `PendingChoice` + `ChooseCommand`, используется Blaster Blade для уничтожения Арьергарда противника.
- [ ] Стоимости: CB, SB, discard, rest self, retire self, put to soul, return to deck. - [ ] Стоимости: CB, SB, discard, rest self, retire self, put to soul, return to deck.

View File

@ -17,9 +17,73 @@ public static class CardScripts
["screamin-and-dancin-announcer-shout"] = new DrawOneScript("screamin-and-dancin-announcer-shout"), ["screamin-and-dancin-announcer-shout"] = new DrawOneScript("screamin-and-dancin-announcer-shout"),
["lizard-soldier-conroe"] = new ConroeScript(), ["lizard-soldier-conroe"] = new ConroeScript(),
["battle-sister-cocoa"] = new CocoaScript(), ["battle-sister-cocoa"] = new CocoaScript(),
["tyrant-deathrex"] = new DeathrexScript(),
["demon-eater"] = new DemonEaterScript(),
["maiden-of-libra"] = new MaidenOfLibraScript(),
}; };
} }
/// <summary>Tyrant, Deathrex: когда эта карта атакует, получает +5000 до конца боя.</summary>
public sealed class DeathrexScript : CardScript
{
public override string NameId => "tyrant-deathrex";
public override IEnumerable<AbilityDefinition> Abilities
{
get
{
yield return new AbilityDefinition
{
Trigger = AbilityTrigger.OnAttackDeclared,
Resolve = ctx => ctx.Engine.AddBattlePower(5000),
};
}
}
}
/// <summary>Demon Eater: в начале Основной фазы Soul Charge 1 и +2000 до конца хода.</summary>
public sealed class DemonEaterScript : CardScript
{
public override string NameId => "demon-eater";
public override IEnumerable<AbilityDefinition> Abilities
{
get
{
yield return new AbilityDefinition
{
Trigger = AbilityTrigger.OnPhaseStart,
Resolve = ctx =>
{
ctx.Engine.SoulCharge(ctx.Owner, 1);
ctx.Source.TemporaryPower += 2000;
},
};
}
}
}
/// <summary>Maiden of Libra: когда атака попадает, Counter Blast 2 и взять карту.</summary>
public sealed class MaidenOfLibraScript : CardScript
{
public override string NameId => "maiden-of-libra";
public override IEnumerable<AbilityDefinition> Abilities
{
get
{
yield return new AbilityDefinition
{
Trigger = AbilityTrigger.OnAttackHit,
Optional = true,
CanPay = ctx => ctx.Engine.CanCounterBlast(ctx.Owner, 2),
Pay = ctx => ctx.Engine.CounterBlast(ctx.Owner, 2),
Resolve = ctx => ctx.Engine.DrawCards(ctx.Owner, 1),
};
}
}
}
/// <summary> /// <summary>
/// Battle Sister, Cocoa: при размещении на VC/RC, если Авангард Oracle Think Tank, /// Battle Sister, Cocoa: при размещении на VC/RC, если Авангард Oracle Think Tank,
/// посмотреть верхнюю карту и оставить её на верху или отправить в низ. /// посмотреть верхнюю карту и оставить её на верху или отправить в низ.

View File

@ -112,6 +112,58 @@ public sealed class GameEngine
State.ActivePlayer = triggered[0].Owner; State.ActivePlayer = triggered[0].Owner;
} }
/// <summary>Собирает срабатывания со всех карт на поле, активный игрок первым.</summary>
private void TriggerAll(AbilityTrigger trigger)
{
var triggered = new List<TriggeredAbility>();
for (var owner = 0; owner < 2; owner++)
{
foreach (var unit in AllUnits(owner))
{
var nameId = State.Catalog.CardOf(unit.PrintingId).NameId;
if (!_scripts.TryGetValue(nameId, out var script))
continue;
var context = new AbilityContext
{
Engine = this,
Source = unit,
Owner = owner,
Phase = State.Phase,
};
triggered.AddRange(script.Abilities
.Where(a => a.Trigger == trigger && (a.Condition is null || a.Condition(context)))
.Select(a => new TriggeredAbility { Definition = a, Context = context }));
}
}
if (triggered.Count == 0)
return;
triggered = triggered
.OrderBy(t => t.Owner == State.ActivePlayer ? 0 : 1)
.ToList();
if (triggered.Count == 1)
{
OfferAbility(triggered[0]);
return;
}
State.PendingOrder = triggered;
State.Step = Step.Ability;
State.ActivePlayer = triggered[0].Owner;
}
private void EnterMainPhase()
{
State.Phase = Phase.Main;
_events.Add(new PhaseChangedEvent(State.ActivePlayer, Phase.Main));
TriggerAll(AbilityTrigger.OnPhaseStart);
}
private void OfferAbility(TriggeredAbility ability) private void OfferAbility(TriggeredAbility ability)
{ {
var definition = ability.Definition; var definition = ability.Definition;
@ -169,6 +221,12 @@ public sealed class GameEngine
} }
State.Step = Step.None; State.Step = Step.None;
if (State.Continuation is { } continuation)
{
State.Continuation = null;
continuation();
}
} }
private CommandResult Activate(int instanceId, string? discardPrintingId) private CommandResult Activate(int instanceId, string? discardPrintingId)
@ -404,6 +462,23 @@ public sealed class GameEngine
public bool CanSoulBlast(int player, int amount) => public bool CanSoulBlast(int player, int amount) =>
State.Players[player].Soul.Count >= amount; State.Players[player].Soul.Count >= amount;
public void SoulCharge(int player, int amount)
{
var p = State.Players[player];
for (var i = 0; i < amount; i++)
{
if (p.Deck.Count == 0)
{
Defeat(player);
return;
}
var card = p.Deck[0];
p.Deck.RemoveAt(0);
p.Soul.Add(card);
}
}
public void SoulBlast(int player, int amount) public void SoulBlast(int player, int amount)
{ {
var soul = State.Players[player].Soul; var soul = State.Players[player].Soul;
@ -721,8 +796,25 @@ public sealed class GameEngine
// ---------- команды ---------- // ---------- команды ----------
public CommandResult Execute(GameCommand command) => command switch public CommandResult Execute(GameCommand command)
{ {
// пока идёт разрешение способности или триггера, посторонние команды запрещены
if (State.Step is Step.Trigger or Step.Ability or Step.Choice)
{
var allowed = command switch
{
ResolveTriggerCommand => State.Step == Step.Trigger,
ResolveAbilityCommand or SelectTriggerCommand => State.Step == Step.Ability,
ChooseCommand => State.Step == Step.Choice,
_ => false,
};
if (!allowed)
return CommandResult.Reject("Сначала завершите текущее разрешение.");
}
return command switch
{
MulliganCommand c => Mulligan(c), MulliganCommand c => Mulligan(c),
FinishMulliganCommand => FinishMulligan(), FinishMulliganCommand => FinishMulligan(),
RideCommand c => Ride(c.PrintingId), RideCommand c => Ride(c.PrintingId),
@ -741,7 +833,8 @@ public sealed class GameEngine
ActivateAbilityCommand c => Activate(c.InstanceId, c.DiscardPrintingId), ActivateAbilityCommand c => Activate(c.InstanceId, c.DiscardPrintingId),
EndTurnCommand => EndTurn(), EndTurnCommand => EndTurn(),
_ => CommandResult.Reject("Неизвестная команда."), _ => CommandResult.Reject("Неизвестная команда."),
}; };
}
private CommandResult Mulligan(MulliganCommand command) private CommandResult Mulligan(MulliganCommand command)
{ {
@ -808,14 +901,17 @@ public sealed class GameEngine
player.Vanguard = card; player.Vanguard = card;
_events.Add(new UnitRodeEvent(State.ActivePlayer, card.PrintingId)); _events.Add(new UnitRodeEvent(State.ActivePlayer, card.PrintingId));
State.Phase = Phase.Main;
_events.Add(new PhaseChangedEvent(State.ActivePlayer, Phase.Main));
TriggerFromCard(State.ActivePlayer, card, AbilityTrigger.OnPlace, null); TriggerFromCard(State.ActivePlayer, card, AbilityTrigger.OnPlace, null);
if (State.Step == Step.None) if (State.Step == Step.None)
TriggerFromCard(State.ActivePlayer, riddenOver, AbilityTrigger.OnRideOver, card); TriggerFromCard(State.ActivePlayer, riddenOver, AbilityTrigger.OnRideOver, card);
if (State.Step == Step.None)
EnterMainPhase();
else
State.Continuation = EnterMainPhase;
return CommandResult.Ok(); return CommandResult.Ok();
} }
@ -824,8 +920,7 @@ public sealed class GameEngine
if (State.Phase != Phase.Ride) if (State.Phase != Phase.Ride)
return CommandResult.Reject("Пропустить смену можно только в фазе Смены."); return CommandResult.Reject("Пропустить смену можно только в фазе Смены.");
State.Phase = Phase.Main; EnterMainPhase();
_events.Add(new PhaseChangedEvent(State.ActivePlayer, Phase.Main));
return CommandResult.Ok(); return CommandResult.Ok();
} }
@ -964,12 +1059,22 @@ public sealed class GameEngine
TriggerFromCard(attackerPlayer, booster, AbilityTrigger.OnBoost, attacker); TriggerFromCard(attackerPlayer, booster, AbilityTrigger.OnBoost, attacker);
} }
State.Step = Step.Guard; var defenderIndex = 1 - attackerPlayer;
// на шаге защиты ход ввода переходит обороняющемуся
State.ActivePlayer = 1 - attackerPlayer;
_events.Add(new AttackDeclaredEvent(attackerPlayer, attackerSlot, targetSlot)); _events.Add(new AttackDeclaredEvent(attackerPlayer, attackerSlot, targetSlot));
void BeginGuard()
{
State.Step = Step.Guard;
State.ActivePlayer = defenderIndex;
}
TriggerFromCard(attackerPlayer, attacker, AbilityTrigger.OnAttackDeclared, null);
if (State.Step is Step.Ability or Step.Choice)
State.Continuation = BeginGuard;
else
BeginGuard();
return CommandResult.Ok(); return CommandResult.Ok();
} }
@ -1326,7 +1431,25 @@ public sealed class GameEngine
else if (battle.TargetSlot < 0) else if (battle.TargetSlot < 0)
{ {
var damage = attackerDef.Critical + attacker.TemporaryCritical; var damage = attackerDef.Critical + attacker.TemporaryCritical;
DealDamage(defenderIndex, damage);
void ResolveDamage()
{
DealDamage(defenderIndex, damage);
if (State.PendingTrigger is null)
ClearBattle(battle);
}
TriggerFromCard(battle.AttackerPlayer, attacker, AbilityTrigger.OnAttackHit, null);
if (State.Step is Step.Ability or Step.Choice)
{
State.Continuation = ResolveDamage;
return;
}
ResolveDamage();
return;
} }
else else
{ {

View File

@ -63,6 +63,9 @@ public sealed class GameState
/// <summary>Ожидание выбора цели или карты.</summary> /// <summary>Ожидание выбора цели или карты.</summary>
public PendingChoice? PendingChoice { get; internal set; } public PendingChoice? PendingChoice { get; internal set; }
/// <summary>Продолжение разрешения, которое надо выполнить после способностей.</summary>
public Action? Continuation { get; internal set; }
public bool IsFinished { get; internal set; } public bool IsFinished { get; internal set; }
public int? Winner { get; internal set; } public int? Winner { get; internal set; }

View File

@ -24,6 +24,8 @@ public class GameEngineTests
private const string Lien = "BT01/043EN"; // activated: rest + discard → draw private const string Lien = "BT01/043EN"; // activated: rest + discard → draw
private const string Cocoa = "BT01/007EN"; // OTT, смотрит верхнюю карту private const string Cocoa = "BT01/007EN"; // OTT, смотрит верхнюю карту
private const string Libra = "BT01/017EN"; // OTT, grade 2 private const string Libra = "BT01/017EN"; // OTT, grade 2
private const string Deathrex = "BT01/033EN"; // Tachikaze, 10000
private const string DemonEater = "BT01/036EN"; // Dark Irregulars, 10000
[Fact] [Fact]
public void Start_Places_Starting_Vanguard_And_Draws_Five() public void Start_Places_Starting_Vanguard_And_Draws_Five()
@ -872,6 +874,54 @@ public class GameEngineTests
Assert.Equal("blaster-blade", _catalog.CardOf(vanguard.PrintingId).NameId); Assert.Equal("blaster-blade", _catalog.CardOf(vanguard.PrintingId).NameId);
} }
[Fact]
public void Deathrex_Gains_5000_When_Attacking()
{
var engine = StartedEngine();
engine.Execute(new SkipRideCommand());
SetVanguard(engine, 0, Deathrex);
engine.Execute(new EnterBattleCommand());
Assert.True(engine.Execute(new AttackCommand(-1, -1)).Accepted);
Assert.Equal(5000, engine.State.Battle!.AbilityPower);
}
[Fact]
public void DemonEater_Charges_Soul_At_Main_Phase_Start()
{
var engine = StartedEngine();
SetVanguard(engine, 0, DemonEater);
engine.Execute(new SkipRideCommand());
var vanguard = engine.State.Players[0].Vanguard!;
Assert.Single(engine.State.Players[0].Soul);
Assert.Equal(12000, engine.EffectivePower(vanguard));
}
[Fact]
public void MaidenOfLibra_Draws_On_Hit_When_Cost_Paid()
{
var engine = StartedEngine();
engine.Execute(new SkipRideCommand());
SetVanguard(engine, 0, Libra);
GiveDamage(engine, 0, 2);
StackDeck(engine.State.Players[0], Gallatin, Gallatin);
engine.Execute(new EnterBattleCommand());
Assert.True(engine.Execute(new AttackCommand(-1, -1)).Accepted);
Assert.True(engine.Execute(new PassGuardCommand()).Accepted);
Assert.NotNull(engine.State.PendingAbility);
var handBefore = engine.State.Players[0].Hand.Count;
Assert.True(engine.Execute(new ResolveAbilityCommand(true)).Accepted);
Assert.Equal(2, engine.State.Players[0].Damage.Count(c => c.FaceUp));
Assert.Equal(handBefore + 1, engine.State.Players[0].Hand.Count);
Assert.Single(engine.State.Players[1].Damage);
}
[Fact] [Fact]
public void ResolveTrigger_Rejects_When_Nothing_Is_Pending() public void ResolveTrigger_Rejects_When_Nothing_Is_Pending()
{ {
@ -1010,6 +1060,7 @@ public class GameEngineTests
(BlasterBlade, 2), (Wingal, 2), (BlasterBlade, 2), (Wingal, 2),
(Lien, 2), (Lien, 2),
(Cocoa, 2), (Libra, 2), (Cocoa, 2), (Libra, 2),
(Deathrex, 2), (DemonEater, 2),
}; };
private List<string> BuildDeck() private List<string> BuildDeck()