Drive-check and guardian triggers, return-to-deck effects, 8 more cards

Asura Kaiser reacts to grade 3 Nova Grappler drive checks; Rock the Wall and Redshoe Milly move from the guardian circle to soul; Juggernaut and Brakki return to deck after battle; Lozenge and Battleraizer return at end of turn; Guiding Zombie puts itself into soul and mills three. Four tests; 102 total.
This commit is contained in:
MpakobecEx 2026-10-21 13:47:00 +05:00
parent 80095da51f
commit 7ca53975d7
8 changed files with 388 additions and 3 deletions

View File

@ -114,7 +114,7 @@
- [ ] 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, OnBoosted, OnPhaseStart, OnAttackDeclared, OnAttackHit), `Continuous` с пересчётом при запросе, `Activated` (повернуть себя + сброс карты). - [x] Событийная шина: `Triggered` (OnRideOver, OnPlace, OnBoost, OnBoosted, OnDriveCheck, OnLeaveGuardian, OnPhaseStart, OnAttackDeclared, OnAttackHit), `Continuous` с пересчётом при запросе, `Activated` (повернуть себя + сброс карты).
- [ ] Событие OnDamageTaken и триггеры на получение урона. - [ ] Событие OnDamageTaken и триггеры на получение урона.
- [x] Check Timing: сбор сработавших, порядок (активный игрок первым, выбор игрока), пауза на опциональные способности. - [x] Check Timing: сбор сработавших, порядок (активный игрок первым, выбор игрока), пауза на опциональные способности.
- [x] Выбор целей: `PendingChoice` + `ChooseCommand`, используется Blaster Blade для уничтожения Арьергарда противника. - [x] Выбор целей: `PendingChoice` + `ChooseCommand`, используется Blaster Blade для уничтожения Арьергарда противника.
@ -151,7 +151,9 @@
- [x] RU/EN тексты для всех 94. - [x] RU/EN тексты для всех 94.
- [x] Сила/щит для 14 карт, которых нет в BT01. - [x] Сила/щит для 14 карт, которых нет в BT01.
- [ ] Дашборд покрытия: `scripted / tested / total`. - [ ] Дашборд покрытия: `scripted / tested / total`.
— сейчас заскриптовано **34** карты: 13 базовых + 21 в `CardScriptsBT01.cs`. — сейчас заскриптовано **42** карты: 13 базовых + 29 в `CardScriptsBT01.cs`.
- [x] Возврат карты с поля в колоду: после боя (Juggernaut, Brakki) и в конце хода (Lozenge, Battleraizer).
- [x] Триггеры `OnDriveCheck` (Asura Kaiser) и `OnLeaveGuardian` (Rock the Wall, Redshoe Milly).
- [ ] Wyvern Strike, Tejas: атака заднего ряда не выделена — движок разрешает атаковать любой круг Арьергарда. - [ ] Wyvern Strike, Tejas: атака заднего ряда не выделена — движок разрешает атаковать любой круг Арьергарда.
**Готово, когда:** 94/94 заскриптованы, 94/94 покрыты тестом, приёмка пройдена по чеклисту карты. **Готово, когда:** 94/94 заскриптованы, 94/94 покрыты тестом, приёмка пройдена по чеклисту карты.

View File

@ -11,6 +11,8 @@ public enum AbilityTrigger
OnAttackDeclared, OnAttackDeclared,
OnAttackHit, OnAttackHit,
OnBoosted, OnBoosted,
OnDriveCheck,
OnLeaveGuardian,
OnDamageTaken, OnDamageTaken,
} }

View File

@ -42,6 +42,14 @@ public static class CardScripts
["ceo-amaterasu"] = new CeoAmaterasuScript(), ["ceo-amaterasu"] = new CeoAmaterasuScript(),
["mr-invincible"] = new MrInvincibleScript(), ["mr-invincible"] = new MrInvincibleScript(),
["brutal-jack"] = new BrutalJackScript(), ["brutal-jack"] = new BrutalJackScript(),
["asura-kaiser"] = new AsuraKaiserScript(),
["juggernaut-maximum"] = new ReturnToDeckOnAttackScript("juggernaut-maximum"),
["highspeed-brakki"] = new ReturnToDeckOnAttackScript("highspeed-brakki"),
["lozenge-magus"] = new ReturnToDeckOnBoostScript("lozenge-magus", Clan.OracleThinkTank),
["battleraizer"] = new ReturnToDeckOnBoostScript("battleraizer", Clan.NovaGrappler),
["rock-the-wall"] = new DropToSoulFromGuardianScript("rock-the-wall"),
["redshoe-milly"] = new DropToSoulFromGuardianScript("redshoe-milly"),
["guiding-zombie"] = new GuidingZombieScript(),
}; };
} }

View File

@ -619,6 +619,200 @@ public sealed class MrInvincibleScript : CardScript
} }
} }
/// <summary>
/// Asura Kaiser: −2000 без другого Nova Grappler; когда проверка открывает
/// Nova Grappler 3 ранга, можно поднять один свой Арьергард.
/// </summary>
public sealed class AsuraKaiserScript : CardScript
{
public override string NameId => "asura-kaiser";
public override IEnumerable<ContinuousAbility> Continuous
{
get
{
yield return new ContinuousAbility
{
Applies = ctx => ctx.Engine.CountFieldUnits(ctx.Owner, Clan.NovaGrappler, ctx.Source) == 0,
PowerBonus = _ => -2000,
};
}
}
public override IEnumerable<AbilityDefinition> Abilities
{
get
{
yield return new AbilityDefinition
{
Trigger = AbilityTrigger.OnDriveCheck,
Condition = ctx =>
ctx.Engine.IsVanguardOf(ctx.Source, ctx.Owner) &&
ctx.Other is not null &&
ctx.Catalog.CardOf(ctx.Other.PrintingId) is { Grade: 3, Clan: Clan.NovaGrappler },
Resolve = ctx =>
{
var slots = ctx.Engine.RearGuardSlots(ctx.Owner, _ => true);
if (slots.Count == 0)
return;
ctx.Engine.AskUnitChoice(
ctx.Owner,
ctx.Owner,
"Выберите Арьергард, чтобы поднять",
slots,
slot => ctx.State.GetUnit(ctx.Owner, slot)!.Rested = false);
},
};
}
}
}
/// <summary>
/// Juggernaut Maximum и Highspeed Brakki: на (RC) Soul Blast 1 → +5000 до конца боя,
/// в начале завершающего шага боя карта возвращается в колоду.
/// </summary>
public sealed class ReturnToDeckOnAttackScript : CardScript
{
public ReturnToDeckOnAttackScript(string nameId) => NameId = nameId;
public override string NameId { get; }
public override IEnumerable<AbilityDefinition> Abilities
{
get
{
yield return new AbilityDefinition
{
Trigger = AbilityTrigger.OnAttackDeclared,
Optional = true,
Condition = ctx => !ctx.Engine.IsVanguardOf(ctx.Source, ctx.Owner),
CanPay = ctx => ctx.Engine.CanSoulBlast(ctx.Owner, 1),
Pay = ctx => ctx.Engine.SoulBlast(ctx.Owner, 1),
Resolve = ctx =>
{
ctx.Engine.AddBattlePower(ctx.Source, 5000);
ctx.Source.ReturnToDeck = ReturnToDeckTiming.AfterBattle;
},
};
}
}
}
/// <summary>
/// Lozenge Magus и Battleraizer: при смене можно призвать себя; при поддержке
/// усиленный получает +3000 до конца боя, а в начале Завершающей фазы карта
/// возвращается в колоду.
/// </summary>
public sealed class ReturnToDeckOnBoostScript : CardScript
{
private readonly Clan _clan;
public ReturnToDeckOnBoostScript(string nameId, Clan clan)
{
NameId = nameId;
_clan = clan;
}
public override string NameId { get; }
public override IEnumerable<AbilityDefinition> Abilities
{
get
{
yield return new AbilityDefinition
{
Trigger = AbilityTrigger.OnRideOver,
Optional = true,
Condition = ctx =>
ctx.Other is not null &&
ctx.Catalog.CardOf(ctx.Other.PrintingId).Clan == _clan,
Resolve = ctx => ctx.Engine.CallFromSoul(ctx.Source, ctx.Owner),
};
yield return new AbilityDefinition
{
Trigger = AbilityTrigger.OnBoost,
Condition = ctx => ctx.Other is not null,
Resolve = ctx =>
{
ctx.Engine.AddBattlePower(ctx.Other!, 3000, ctx.Source);
ctx.Source.ReturnToDeck = ReturnToDeckTiming.EndOfTurn;
},
};
}
}
}
/// <summary>
/// Rock the Wall и Redshoe Milly: попав в сброс из круга защиты, карта уходит в душу.
/// </summary>
public sealed class DropToSoulFromGuardianScript : CardScript
{
public DropToSoulFromGuardianScript(string nameId) => NameId = nameId;
public override string NameId { get; }
public override IEnumerable<AbilityDefinition> Abilities
{
get
{
yield return new AbilityDefinition
{
Trigger = AbilityTrigger.OnLeaveGuardian,
Resolve = ctx => ctx.Engine.MoveDropToSoul(ctx.Owner, ctx.Source),
};
}
}
}
/// <summary>
/// Guiding Zombie: при смене можно призвать себя; на (RC) положить себя в душу
/// и отправить три верхние карты колоды в сброс.
/// </summary>
public sealed class GuidingZombieScript : CardScript
{
public override string NameId => "guiding-zombie";
public override IEnumerable<AbilityDefinition> Abilities
{
get
{
yield return new AbilityDefinition
{
Trigger = AbilityTrigger.OnRideOver,
Optional = true,
Condition = ctx =>
ctx.Other is not null &&
ctx.Catalog.CardOf(ctx.Other.PrintingId).Clan == Clan.Granblue,
Resolve = ctx => ctx.Engine.CallFromSoul(ctx.Source, ctx.Owner),
};
}
}
public override IEnumerable<ActivatedAbility> Activated
{
get
{
yield return new ActivatedAbility
{
CanActivate = _ => true,
Resolve = ctx =>
{
var p = ctx.State.Players[ctx.Owner];
var slot = Array.IndexOf(p.RearGuards, ctx.Source);
if (slot < 0)
return;
p.RearGuards[slot] = null;
p.Soul.Add(ctx.Source);
ctx.Engine.MillTop(ctx.Owner, 3);
},
};
}
}
}
/// <summary> /// <summary>
/// Brutal Jack: Сдерживание; за Counter Blast 1 теряет его до конца хода; /// Brutal Jack: Сдерживание; за Counter Blast 1 теряет его до конца хода;
/// получает +5000 до конца боя, когда его поддерживает Nova Grappler. /// получает +5000 до конца боя, когда его поддерживает Nova Grappler.

View File

@ -815,6 +815,46 @@ public sealed class GameEngine
}); });
} }
/// <summary>Положить верхние карты колоды в сброс.</summary>
public void MillTop(int player, int count)
{
var p = State.Players[player];
for (var i = 0; i < count && p.Deck.Count > 0; i++)
{
var card = p.Deck[0];
p.Deck.RemoveAt(0);
p.Drop.Add(card);
}
}
/// <summary>Переместить карту из сброса в душу (Rock the Wall, Redshoe Milly).</summary>
public bool MoveDropToSoul(int player, CardInstance card)
{
var p = State.Players[player];
if (!p.Drop.Remove(card))
return false;
p.Soul.Add(card);
_events.Add(new CardMovedToSoulEvent(player, card.PrintingId));
return true;
}
/// <summary>Вернуть карту с поля в колоду и перемешать. Авангард так вернуть нельзя.</summary>
public bool ReturnUnitToDeck(int player, CardInstance unit)
{
var p = State.Players[player];
var slot = Array.IndexOf(p.RearGuards, unit);
if (slot < 0)
return false;
p.RearGuards[slot] = null;
unit.ReturnToDeck = null;
p.Deck.Add(unit);
ShuffleDeck(player);
_events.Add(new UnitReturnedToDeckEvent(player, unit.PrintingId));
return true;
}
// ---------- поиск в колоде ---------- // ---------- поиск в колоде ----------
/// <summary> /// <summary>
@ -1521,6 +1561,16 @@ public sealed class GameEngine
State.Phase = Phase.End; State.Phase = Phase.End;
_events.Add(new PhaseChangedEvent(State.ActivePlayer, Phase.End)); _events.Add(new PhaseChangedEvent(State.ActivePlayer, Phase.End));
// «в начале Завершающей фазы вернуть в колоду»
foreach (var player in new[] { 0, 1 })
{
foreach (var unit in AllUnits(player).ToList())
{
if (unit.ReturnToDeck == ReturnToDeckTiming.EndOfTurn)
ReturnUnitToDeck(player, unit);
}
}
ClearTurnModifiers(); ClearTurnModifiers();
if (State.IsFinished) if (State.IsFinished)
@ -1638,6 +1688,16 @@ public sealed class GameEngine
State.Players[player].Hand.Add(card); State.Players[player].Hand.Add(card);
_events.Add(new CardDrawnEvent(player)); _events.Add(new CardDrawnEvent(player));
battle.ChecksRemaining--; battle.ChecksRemaining--;
var checkedAttacker = State.GetUnit(player, battle.AttackerSlot);
TriggerFromCard(player, checkedAttacker, AbilityTrigger.OnDriveCheck, card);
if (State.Step is Step.Ability or Step.Choice)
{
State.Continuation = () => ContinueDriveChecks(battle);
return;
}
continue; continue;
} }
@ -1833,11 +1893,25 @@ public sealed class GameEngine
private void ClearBattle(BattleState battle) private void ClearBattle(BattleState battle)
{ {
var defender = State.Players[1 - battle.AttackerPlayer]; var defenderIndex = 1 - battle.AttackerPlayer;
var defender = State.Players[defenderIndex];
var leftGuardian = new List<CardInstance>();
foreach (var guard in battle.GuardCards) foreach (var guard in battle.GuardCards)
{ {
defender.Guardian.Remove(guard); defender.Guardian.Remove(guard);
defender.Drop.Add(guard); defender.Drop.Add(guard);
leftGuardian.Add(guard);
}
// карты, которые должны вернуться в колоду сразу после боя
foreach (var player in new[] { 0, 1 })
{
foreach (var unit in AllUnits(player).ToList())
{
if (unit.ReturnToDeck == ReturnToDeckTiming.AfterBattle)
ReturnUnitToDeck(player, unit);
}
} }
foreach (var unit in AllUnits(0).Concat(AllUnits(1))) foreach (var unit in AllUnits(0).Concat(AllUnits(1)))
@ -1851,6 +1925,10 @@ public sealed class GameEngine
State.PendingTrigger = null; State.PendingTrigger = null;
State.Step = Step.None; State.Step = Step.None;
State.ActivePlayer = battle.AttackerPlayer; State.ActivePlayer = battle.AttackerPlayer;
// бой уже закрыт: эффекты «попала в сброс из (GC)» срабатывают после
foreach (var guard in leftGuardian)
TriggerFromCard(defenderIndex, guard, AbilityTrigger.OnLeaveGuardian, null);
} }
private void DealDamage(int player, int amount) private void DealDamage(int player, int amount)

View File

@ -30,6 +30,8 @@ public sealed record SearchFailedEvent(int Player, string Query) : GameEvent;
public sealed record CardRevealedEvent(int Player, string PrintingId) : GameEvent; public sealed record CardRevealedEvent(int Player, string PrintingId) : GameEvent;
public sealed record CardReturnedToDeckEvent(int Player, string PrintingId) : GameEvent; public sealed record CardReturnedToDeckEvent(int Player, string PrintingId) : GameEvent;
public sealed record CardDiscardedEvent(int Player, string PrintingId) : GameEvent; public sealed record CardDiscardedEvent(int Player, string PrintingId) : GameEvent;
public sealed record UnitReturnedToDeckEvent(int Player, string PrintingId) : GameEvent;
public sealed record CardMovedToSoulEvent(int Player, string PrintingId) : GameEvent;
public sealed record GameFinishedEvent(int Winner) : GameEvent; public sealed record GameFinishedEvent(int Winner) : GameEvent;
public sealed record AbilityTriggeredEvent(int Player, string PrintingId, AbilityTrigger Trigger) : GameEvent; public sealed record AbilityTriggeredEvent(int Player, string PrintingId, AbilityTrigger Trigger) : GameEvent;
public sealed record AbilityResolvedEvent(int Player, string PrintingId, bool Accepted) : GameEvent; public sealed record AbilityResolvedEvent(int Player, string PrintingId, bool Accepted) : GameEvent;

View File

@ -47,6 +47,13 @@ public enum TriggerContext
DamageCheck, DamageCheck,
} }
/// <summary>Когда карта, находившаяся на поле, возвращается в колоду.</summary>
public enum ReturnToDeckTiming
{
AfterBattle,
EndOfTurn,
}
/// <summary>Триггер, который ждёт выбора целей от владельца.</summary> /// <summary>Триггер, который ждёт выбора целей от владельца.</summary>
public sealed class PendingTrigger public sealed class PendingTrigger
{ {
@ -84,6 +91,9 @@ public sealed class CardInstance
/// <summary>Запреты, выданные эффектами.</summary> /// <summary>Запреты, выданные эффектами.</summary>
public List<CardRestriction> Restrictions { get; } = new(); public List<CardRestriction> Restrictions { get; } = new();
/// <summary>Вернуть карту в колоду после боя или в конце хода (Juggernaut, Lozenge).</summary>
public ReturnToDeckTiming? ReturnToDeck { get; set; }
/// <summary> /// <summary>
/// Переопределение Сдерживания, выданное эффектом. /// Переопределение Сдерживания, выданное эффектом.
/// null — как напечатано на карте. /// null — как напечатано на карте.

View File

@ -32,6 +32,10 @@ public class GameEngineTests
private const string HellSpider = "BT01/039EN"; // Megacolony, запрет вставать private const string HellSpider = "BT01/039EN"; // Megacolony, запрет вставать
private const string BrutalJack = "BT01/029EN"; // Nova Grappler, Сдерживание private const string BrutalJack = "BT01/029EN"; // Nova Grappler, Сдерживание
private const string ToughBoy = "BT01/060EN"; // Nova Grappler, grade 1 private const string ToughBoy = "BT01/060EN"; // Nova Grappler, grade 1
private const string Asura = "BT01/008EN"; // Nova Grappler, grade 3
private const string Juggernaut = "BT01/020EN"; // Spike Brothers, возврат в колоду
private const string RockTheWall = "BT01/073EN";
private const string GuidingZombie = "BT01/078EN";
[Fact] [Fact]
public void Start_Places_Starting_Vanguard_And_Draws_Five() public void Start_Places_Starting_Vanguard_And_Draws_Five()
@ -1270,6 +1274,91 @@ public class GameEngineTests
Assert.Empty(overlord.GrantedAbilities); Assert.Empty(overlord.GrantedAbilities);
} }
[Fact]
public void Asura_Kaiser_Stands_RearGuard_On_Grade3_Drive_Check()
{
var engine = StartedEngine();
engine.Execute(new SkipRideCommand());
var asura = MakeCard(Asura);
engine.State.Players[0].Vanguard = asura;
var rear = MakeCard(ToughBoy);
engine.State.Players[0].RearGuards[0] = rear;
rear.Rested = true;
// проверка открывает Mr. Invincible (Nova Grappler 3 ранга), затем обычную карту
engine.State.Players[0].Deck.Insert(0, MakeCard("BT01/028EN"));
engine.State.Players[0].Deck.Insert(1, MakeCard(Gallatin));
engine.State.Players[1].Deck.Insert(0, MakeCard(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.PendingChoice);
Assert.True(engine.Execute(new ChooseCommand(0)).Accepted);
Assert.False(rear.Rested);
}
[Fact]
public void Juggernaut_Returns_To_Deck_After_Battle()
{
var engine = InMainPhase();
var juggernaut = MakeCard(Juggernaut);
engine.State.Players[0].RearGuards[3] = juggernaut;
juggernaut.Rested = false;
engine.SoulCharge(0, 1);
StackDeck(engine.State.Players[1], Gallatin);
engine.Execute(new EnterBattleCommand());
Assert.True(engine.Execute(new AttackCommand(3, -1)).Accepted);
Assert.NotNull(engine.State.PendingAbility);
Assert.True(engine.Execute(new ResolveAbilityCommand(true)).Accepted);
Assert.True(engine.Execute(new PassGuardCommand()).Accepted);
Assert.Null(engine.State.Players[0].RearGuards[3]);
Assert.Contains(engine.State.Players[0].Deck, c => c.PrintingId == Juggernaut);
}
[Fact]
public void Rock_The_Wall_Moves_To_Soul_After_Leaving_Guardian()
{
var engine = ReadyToAttack();
StackDeck(engine.State.Players[0], Gallatin, Gallatin);
engine.State.Players[1].Hand.Add(MakeCard(RockTheWall));
Assert.True(engine.Execute(new AttackCommand(-1, -1)).Accepted);
Assert.True(engine.Execute(new GuardCommand(RockTheWall)).Accepted);
Assert.True(engine.Execute(new PassGuardCommand()).Accepted);
var defender = engine.State.Players[1];
Assert.Contains(defender.Soul, c => c.PrintingId == RockTheWall);
Assert.DoesNotContain(defender.Drop, c => c.PrintingId == RockTheWall);
}
[Fact]
public void Guiding_Zombie_Puts_Itself_Into_Soul_And_Mills_Three()
{
var engine = InMainPhase();
var zombie = MakeCard(GuidingZombie);
engine.State.Players[0].Hand.Add(zombie);
Assert.True(engine.Execute(new CallCommand(GuidingZombie, 0)).Accepted);
zombie.Rested = false;
var deckBefore = engine.State.Players[0].Deck.Count;
Assert.True(engine.Execute(new ActivateAbilityCommand(zombie.Id)).Accepted);
Assert.Null(engine.State.Players[0].RearGuards[0]);
Assert.Contains(engine.State.Players[0].Soul, c => c.PrintingId == GuidingZombie);
Assert.Equal(3, deckBefore - engine.State.Players[0].Deck.Count);
}
[Fact] [Fact]
public void ResolveTrigger_Rejects_When_Nothing_Is_Pending() public void ResolveTrigger_Rejects_When_Nothing_Is_Pending()
{ {