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:
parent
a5fef89984
commit
4333bb5bd0
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
/// посмотреть верхнюю карту и оставить её на верху или отправить в низ.
|
/// посмотреть верхнюю карту и оставить её на верху или отправить в низ.
|
||||||
|
|
|
||||||
|
|
@ -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,7 +796,24 @@ 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(),
|
||||||
|
|
@ -742,6 +834,7 @@ public sealed class GameEngine
|
||||||
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;
|
||||||
|
|
||||||
|
void ResolveDamage()
|
||||||
|
{
|
||||||
DealDamage(defenderIndex, damage);
|
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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue