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 как отдельные интенты.
|
||||
- [x] Число проверок принадлежит атакующей карте: 0–2 ранг — одна, 3+ — две; Арьергард по умолчанию без проверок; `DriveChecksOverride` меняет число (в том числе снимает двойную).
|
||||
- [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] Выбор целей: `PendingChoice` + `ChooseCommand`, используется Blaster Blade для уничтожения Арьергарда противника.
|
||||
- [ ] Стоимости: 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"),
|
||||
["lizard-soldier-conroe"] = new ConroeScript(),
|
||||
["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>
|
||||
/// Battle Sister, Cocoa: при размещении на VC/RC, если Авангард Oracle Think Tank,
|
||||
/// посмотреть верхнюю карту и оставить её на верху или отправить в низ.
|
||||
|
|
|
|||
|
|
@ -112,6 +112,58 @@ public sealed class GameEngine
|
|||
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)
|
||||
{
|
||||
var definition = ability.Definition;
|
||||
|
|
@ -169,6 +221,12 @@ public sealed class GameEngine
|
|||
}
|
||||
|
||||
State.Step = Step.None;
|
||||
|
||||
if (State.Continuation is { } continuation)
|
||||
{
|
||||
State.Continuation = null;
|
||||
continuation();
|
||||
}
|
||||
}
|
||||
|
||||
private CommandResult Activate(int instanceId, string? discardPrintingId)
|
||||
|
|
@ -404,6 +462,23 @@ public sealed class GameEngine
|
|||
public bool CanSoulBlast(int player, int 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)
|
||||
{
|
||||
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),
|
||||
FinishMulliganCommand => FinishMulligan(),
|
||||
|
|
@ -742,6 +834,7 @@ public sealed class GameEngine
|
|||
EndTurnCommand => EndTurn(),
|
||||
_ => CommandResult.Reject("Неизвестная команда."),
|
||||
};
|
||||
}
|
||||
|
||||
private CommandResult Mulligan(MulliganCommand command)
|
||||
{
|
||||
|
|
@ -808,14 +901,17 @@ public sealed class GameEngine
|
|||
player.Vanguard = card;
|
||||
|
||||
_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);
|
||||
|
||||
if (State.Step == Step.None)
|
||||
TriggerFromCard(State.ActivePlayer, riddenOver, AbilityTrigger.OnRideOver, card);
|
||||
|
||||
if (State.Step == Step.None)
|
||||
EnterMainPhase();
|
||||
else
|
||||
State.Continuation = EnterMainPhase;
|
||||
|
||||
return CommandResult.Ok();
|
||||
}
|
||||
|
||||
|
|
@ -824,8 +920,7 @@ public sealed class GameEngine
|
|||
if (State.Phase != Phase.Ride)
|
||||
return CommandResult.Reject("Пропустить смену можно только в фазе Смены.");
|
||||
|
||||
State.Phase = Phase.Main;
|
||||
_events.Add(new PhaseChangedEvent(State.ActivePlayer, Phase.Main));
|
||||
EnterMainPhase();
|
||||
return CommandResult.Ok();
|
||||
}
|
||||
|
||||
|
|
@ -964,12 +1059,22 @@ public sealed class GameEngine
|
|||
TriggerFromCard(attackerPlayer, booster, AbilityTrigger.OnBoost, attacker);
|
||||
}
|
||||
|
||||
State.Step = Step.Guard;
|
||||
|
||||
// на шаге защиты ход ввода переходит обороняющемуся
|
||||
State.ActivePlayer = 1 - attackerPlayer;
|
||||
|
||||
var defenderIndex = 1 - attackerPlayer;
|
||||
_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();
|
||||
}
|
||||
|
||||
|
|
@ -1326,7 +1431,25 @@ public sealed class GameEngine
|
|||
else if (battle.TargetSlot < 0)
|
||||
{
|
||||
var damage = attackerDef.Critical + attacker.TemporaryCritical;
|
||||
|
||||
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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -63,6 +63,9 @@ public sealed class GameState
|
|||
/// <summary>Ожидание выбора цели или карты.</summary>
|
||||
public PendingChoice? PendingChoice { get; internal set; }
|
||||
|
||||
/// <summary>Продолжение разрешения, которое надо выполнить после способностей.</summary>
|
||||
public Action? Continuation { get; internal set; }
|
||||
|
||||
public bool IsFinished { 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 Cocoa = "BT01/007EN"; // OTT, смотрит верхнюю карту
|
||||
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]
|
||||
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);
|
||||
}
|
||||
|
||||
[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]
|
||||
public void ResolveTrigger_Rejects_When_Nothing_Is_Pending()
|
||||
{
|
||||
|
|
@ -1010,6 +1060,7 @@ public class GameEngineTests
|
|||
(BlasterBlade, 2), (Wingal, 2),
|
||||
(Lien, 2),
|
||||
(Cocoa, 2), (Libra, 2),
|
||||
(Deathrex, 2), (DemonEater, 2),
|
||||
};
|
||||
|
||||
private List<string> BuildDeck()
|
||||
|
|
|
|||
Loading…
Reference in New Issue