Finish TD01/TD02 scripts and the last BT01 effects

New triggers: OnBoostedHit, OnRetireFromRC, OnOpponentRetire, OnGuard; activated abilities from hand and drop zone; superior ride from drop. Cards: Voidmaster, Hagakure, Dreadmaster, Monster Frank, Blightops, Llew, Dragon Knight Aleph, Gancelot hand effect, Deathrex second effect, Brigitte, Bors, Tristan, Starlight Unicorn, Goku, Berserk Dragon, Aermo, Joka, Rakshasa. Four tests; 111 total.
This commit is contained in:
MpakobecEx 2026-10-21 13:51:45 +05:00
parent 7ca53975d7
commit d30be8cec2
8 changed files with 963 additions and 21 deletions

View File

@ -151,9 +151,11 @@
- [x] RU/EN тексты для всех 94.
- [x] Сила/щит для 14 карт, которых нет в BT01.
- [ ] Дашборд покрытия: `scripted / tested / total`.
— сейчас заскриптовано **42** карты: 13 базовых + 29 в `CardScriptsBT01.cs`.
— сейчас заскриптовано **58** карт из 66 с текстом. Остались: Tejas (нужно правило атаки заднего ряда) и Yaksha (триггер из руки). Шесть карт — только напоминания о Sentinel/Heal, им скрипт не нужен.
- [x] Триггеры `OnDriveCheck` (Asura Kaiser, Brigitte, Tristan, Goku), `OnBoostedHit` (Dreadmaster, Aermo), `OnGuard` (Hagakure), `OnRetireFromRC` (Blightops), `OnOpponentRetire` (Joka, Rakshasa).
- [x] Способности из руки (Gancelot) и из сброса (Monster Frank).
- [x] Возврат карты с поля в колоду: после боя (Juggernaut, Brakki) и в конце хода (Lozenge, Battleraizer).
- [x] Триггеры `OnDriveCheck` (Asura Kaiser) и `OnLeaveGuardian` (Rock the Wall, Redshoe Milly).
- [x] Триггер `OnLeaveGuardian` (Rock the Wall, Redshoe Milly).
- [ ] Wyvern Strike, Tejas: атака заднего ряда не выделена — движок разрешает атаковать любой круг Арьергарда.
**Готово, когда:** 94/94 заскриптованы, 94/94 покрыты тестом, приёмка пройдена по чеклисту карты.

View File

@ -11,7 +11,11 @@ public enum AbilityTrigger
OnAttackDeclared,
OnAttackHit,
OnBoosted,
OnBoostedHit,
OnDriveCheck,
OnGuard,
OnRetireFromRC,
OnOpponentRetire,
OnLeaveGuardian,
OnDamageTaken,
}
@ -68,6 +72,12 @@ public sealed class ActivatedAbility
/// <summary>Стоимость: уничтожить саму карту после оплаты.</summary>
public bool RetiresSelf { get; init; }
/// <summary>Способность активируется из руки, а не с поля.</summary>
public bool FromHand { get; init; }
/// <summary>Способность активируется из сброса.</summary>
public bool FromDropZone { get; init; }
public required Action<AbilityContext> Resolve { get; init; }
}

View File

@ -50,6 +50,22 @@ public static class CardScripts
["rock-the-wall"] = new DropToSoulFromGuardianScript("rock-the-wall"),
["redshoe-milly"] = new DropToSoulFromGuardianScript("redshoe-milly"),
["guiding-zombie"] = new GuidingZombieScript(),
["stealth-dragon-voidmaster"] = new VoidmasterScript(),
["stealth-beast-hagakure"] = new HagakureScript(),
["stealth-dragon-dreadmaster"] = new DreadmasterScript(),
["monster-frank"] = new MonsterFrankScript(),
["assault-dragon-blightops"] = new BlightopsScript(),
["future-knight-llew"] = new LlewScript(),
["dragon-knight-aleph"] = new DragonKnightAlephScript(),
["crimson-butterfly-brigitte"] = new DriveCheckGradeScript("crimson-butterfly-brigitte", Clan.RoyalPaladin),
["knight-of-the-harp-tristan"] = new DriveCheckGradeScript("knight-of-the-harp-tristan", Clan.RoyalPaladin),
["knight-of-conviction-bors"] = new BorsScript(),
["starlight-unicorn"] = new StarlightUnicornScript(),
["dragon-monk-goku"] = new GokuScript(),
["berserk-dragon"] = new BerserkDragonScript(),
["flame-of-hope-aermo"] = new AermoScript(),
["demonic-dragon-madonna-joka"] = new OpponentRetireBonusScript("demonic-dragon-madonna-joka"),
["demonic-dragon-mage-rakshasa"] = new OpponentRetireBonusScript("demonic-dragon-mage-rakshasa"),
};
}
@ -104,6 +120,25 @@ public sealed class DeathrexScript : CardScript
Trigger = AbilityTrigger.OnAttackDeclared,
Resolve = ctx => ctx.Engine.AddBattlePower(ctx.Source, 5000),
};
yield return new AbilityDefinition
{
Trigger = AbilityTrigger.OnAttackHit,
Condition = ctx => ctx.Engine.IsVanguardOf(ctx.Source, ctx.Owner),
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.Engine.RetireRearGuard(ctx.Owner, slot));
},
};
}
}
}

View File

@ -353,6 +353,23 @@ public sealed class GancelotScript : CardScript
critical: 1);
},
};
yield return new ActivatedAbility
{
FromHand = true,
CanActivate = _ => true,
Resolve = ctx =>
{
var p = ctx.State.Players[ctx.Owner];
p.Hand.Remove(ctx.Source);
p.Deck.Insert(0, ctx.Source);
ctx.Engine.SearchDeckToHand(
ctx.Owner,
"Найдите Blaster Blade",
d => d.NameId == "blaster-blade");
},
};
}
}
}
@ -813,6 +830,235 @@ public sealed class GuidingZombieScript : CardScript
}
}
/// <summary>
/// Stealth Dragon, Voidmaster: +3000 при атаке, если карт в руке больше;
/// при попадании Counter Blast 1, если карт в руке меньше, — противник сбрасывает карту.
/// </summary>
public sealed class VoidmasterScript : CardScript
{
public override string NameId => "stealth-dragon-voidmaster";
public override IEnumerable<AbilityDefinition> Abilities
{
get
{
yield return new AbilityDefinition
{
Trigger = AbilityTrigger.OnAttackDeclared,
Condition = ctx =>
ctx.Engine.IsVanguardOf(ctx.Source, ctx.Owner) &&
ctx.Engine.HandCount(ctx.Owner) > ctx.Engine.HandCount(1 - ctx.Owner),
Resolve = ctx => ctx.Engine.AddBattlePower(ctx.Source, 3000),
};
yield return new AbilityDefinition
{
Trigger = AbilityTrigger.OnAttackHit,
Optional = true,
Condition = ctx =>
ctx.Engine.HandCount(ctx.Owner) < ctx.Engine.HandCount(1 - ctx.Owner),
CanPay = ctx => ctx.Engine.CanCounterBlast(ctx.Owner, 1),
Pay = ctx => ctx.Engine.CounterBlast(ctx.Owner, 1),
Resolve = ctx => ctx.Engine.OpponentDiscardsChosen(
1 - ctx.Owner,
"Выберите карту и сбросьте её"),
};
}
}
}
/// <summary>
/// Stealth Beast, Hagakure: попав в круг защиты, Counter Blast 1 при Авангарде Nubatama
/// и меньшем числе карт в руке — противник сбрасывает карту.
/// </summary>
public sealed class HagakureScript : CardScript
{
public override string NameId => "stealth-beast-hagakure";
public override IEnumerable<AbilityDefinition> Abilities
{
get
{
yield return new AbilityDefinition
{
Trigger = AbilityTrigger.OnGuard,
Optional = true,
Condition = ctx =>
ctx.Engine.VanguardClan(ctx.Owner) == Clan.Nubatama &&
ctx.Engine.HandCount(ctx.Owner) < ctx.Engine.HandCount(1 - ctx.Owner),
CanPay = ctx => ctx.Engine.CanCounterBlast(ctx.Owner, 1),
Pay = ctx => ctx.Engine.CounterBlast(ctx.Owner, 1),
Resolve = ctx => ctx.Engine.OpponentDiscardsChosen(
1 - ctx.Owner,
"Выберите карту и сбросьте её"),
};
}
}
}
/// <summary>
/// Stealth Dragon, Dreadmaster: когда атака, которую поддержала эта карта, попадает,
/// при меньшем числе карт в руке можно Counter Blast 1, чтобы противник сбросил карту.
/// </summary>
public sealed class DreadmasterScript : CardScript
{
public override string NameId => "stealth-dragon-dreadmaster";
public override IEnumerable<AbilityDefinition> Abilities
{
get
{
yield return new AbilityDefinition
{
Trigger = AbilityTrigger.OnBoostedHit,
Optional = true,
Condition = ctx =>
ctx.Engine.HandCount(ctx.Owner) < ctx.Engine.HandCount(1 - ctx.Owner) &&
ctx.State.Battle is { } battle &&
ctx.State.GetUnit(battle.AttackerPlayer, battle.AttackerSlot) is { } boosted &&
ctx.Catalog.CardOf(boosted.PrintingId).Clan == Clan.Nubatama,
CanPay = ctx => ctx.Engine.CanCounterBlast(ctx.Owner, 1),
Pay = ctx => ctx.Engine.CounterBlast(ctx.Owner, 1),
Resolve = ctx => ctx.Engine.OpponentDiscardsChosen(
1 - ctx.Owner,
"Выберите карту и сбросьте её"),
};
}
}
}
/// <summary>
/// Monster Frank: из сброса Counter Blast 3, если Авангард 2 ранга, — райд этой картой.
/// </summary>
public sealed class MonsterFrankScript : CardScript
{
public override string NameId => "monster-frank";
public override IEnumerable<ActivatedAbility> Activated
{
get
{
yield return new ActivatedAbility
{
FromDropZone = true,
CanActivate = ctx =>
ctx.Engine.CanCounterBlast(ctx.Owner, 3) &&
ctx.Engine.VanguardGrade(ctx.Owner) == 2,
Resolve = ctx =>
{
ctx.Engine.CounterBlast(ctx.Owner, 3);
ctx.Engine.RideFromDrop(ctx.Source, ctx.Owner);
},
};
}
}
}
/// <summary>
/// Assault Dragon, Blightops: попав в сброс из круга Арьергарда, Counter Blast 1 →
/// найти Ironclad Dragon, Shieldon в руку.
/// </summary>
public sealed class BlightopsScript : CardScript
{
public override string NameId => "assault-dragon-blightops";
public override IEnumerable<AbilityDefinition> Abilities
{
get
{
yield return new AbilityDefinition
{
Trigger = AbilityTrigger.OnRetireFromRC,
Optional = true,
CanPay = ctx => ctx.Engine.CanCounterBlast(ctx.Owner, 1),
Pay = ctx => ctx.Engine.CounterBlast(ctx.Owner, 1),
Resolve = ctx => ctx.Engine.SearchDeckToHand(
ctx.Owner,
"Найдите Ironclad Dragon, Shieldon",
d => d.NameId == "ironclad-dragon-shieldon"),
};
}
}
}
/// <summary>
/// Future Knight, Llew: Counter Blast 1 и три именованные карты с кругов в душу,
/// при Авангарде 1 ранга — найти Blaster Blade и совершить им райд.
/// </summary>
public sealed class LlewScript : CardScript
{
private static readonly string[] Cost =
{
"future-knight-llew",
"barcgal",
"flogal",
};
public override string NameId => "future-knight-llew";
public override IEnumerable<ActivatedAbility> Activated
{
get
{
yield return new ActivatedAbility
{
CanActivate = ctx =>
!ctx.Engine.IsVanguardOf(ctx.Source, ctx.Owner) &&
ctx.Engine.VanguardGrade(ctx.Owner) == 1 &&
ctx.Engine.CanCounterBlast(ctx.Owner, 1) &&
ctx.Engine.HasFieldUnits(ctx.Owner, Cost),
Resolve = ctx =>
{
ctx.Engine.CounterBlast(ctx.Owner, 1);
ctx.Engine.PutFieldUnitsToSoul(ctx.Owner, Cost);
ctx.Engine.SearchDeckToRide(
ctx.Owner,
"Найдите Blaster Blade",
d => d.NameId == "blaster-blade");
},
};
}
}
}
/// <summary>
/// Dragon Knight, Aleph: Counter Blast 1 и Bahr с Tahr с кругов в душу →
/// найти Embodiment of Victory, Aleph и совершить им райд.
/// </summary>
public sealed class DragonKnightAlephScript : CardScript
{
private static readonly string[] Cost =
{
"embodiment-of-armor-bahr",
"embodiment-of-spear-tahr",
};
public override string NameId => "dragon-knight-aleph";
public override IEnumerable<ActivatedAbility> Activated
{
get
{
yield return new ActivatedAbility
{
CanActivate = ctx =>
ctx.Engine.IsVanguardOf(ctx.Source, ctx.Owner) &&
ctx.Engine.CanCounterBlast(ctx.Owner, 1) &&
ctx.Engine.HasFieldUnits(ctx.Owner, Cost),
Resolve = ctx =>
{
ctx.Engine.CounterBlast(ctx.Owner, 1);
ctx.Engine.PutFieldUnitsToSoul(ctx.Owner, Cost);
ctx.Engine.SearchDeckToRide(
ctx.Owner,
"Найдите Embodiment of Victory, Aleph",
d => d.NameId == "embodiment-of-victory-aleph");
},
};
}
}
}
/// <summary>
/// Brutal Jack: Сдерживание; за Counter Blast 1 теряет его до конца хода;
/// получает +5000 до конца боя, когда его поддерживает Nova Grappler.

View File

@ -0,0 +1,249 @@
using Vanguard.Rules.Cards;
namespace Vanguard.Rules.Engine.Abilities;
/// <summary>
/// «Когда проверка открывает карту 3 ранга нужного клана, эта карта получает
/// +5000 до конца боя»: Crimson Butterfly, Brigitte и Knight of the Harp, Tristan.
/// </summary>
public sealed class DriveCheckGradeScript : CardScript
{
private readonly Clan _clan;
public DriveCheckGradeScript(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.OnDriveCheck,
Condition = ctx =>
ctx.Engine.IsVanguardOf(ctx.Source, ctx.Owner) &&
ctx.Other is not null &&
ctx.Catalog.CardOf(ctx.Other.PrintingId) is { Grade: 3 } checkedCard &&
checkedCard.Clan == _clan,
Resolve = ctx => ctx.Engine.AddBattlePower(ctx.Source, 5000),
};
}
}
}
/// <summary>Knight of Conviction, Bors: при атаке Counter Blast 1 → +3000 до конца боя.</summary>
public sealed class BorsScript : CardScript
{
public override string NameId => "knight-of-conviction-bors";
public override IEnumerable<AbilityDefinition> Abilities
{
get
{
yield return new AbilityDefinition
{
Trigger = AbilityTrigger.OnAttackDeclared,
Optional = true,
CanPay = ctx => ctx.Engine.CanCounterBlast(ctx.Owner, 1),
Pay = ctx => ctx.Engine.CounterBlast(ctx.Owner, 1),
Resolve = ctx => ctx.Engine.AddBattlePower(ctx.Source, 3000),
};
}
}
}
/// <summary>
/// Starlight Unicorn: при размещении на (RC) выбрать другого Royal Paladin
/// и дать ему +2000 до конца хода.
/// </summary>
public sealed class StarlightUnicornScript : CardScript
{
public override string NameId => "starlight-unicorn";
public override IEnumerable<AbilityDefinition> Abilities
{
get
{
yield return new AbilityDefinition
{
Trigger = AbilityTrigger.OnPlace,
Condition = ctx => !ctx.Engine.IsVanguardOf(ctx.Source, ctx.Owner),
Resolve = ctx =>
{
var slots = new List<int>();
if (ctx.State.Players[ctx.Owner].Vanguard is { } vanguard
&& vanguard != ctx.Source
&& ctx.Catalog.CardOf(vanguard.PrintingId).Clan == Clan.RoyalPaladin)
{
slots.Add(-1);
}
slots.AddRange(ctx.Engine
.RearGuardSlots(ctx.Owner, d => d.Clan == Clan.RoyalPaladin)
.Where(slot => ctx.State.GetUnit(ctx.Owner, slot) != ctx.Source));
if (slots.Count == 0)
return;
ctx.Engine.AskUnitChoice(
ctx.Owner,
ctx.Owner,
"Выберите другого Royal Paladin",
slots,
slot => ctx.Engine.AddPower(
ctx.State.GetUnit(ctx.Owner, slot)!,
2000,
ctx.Source,
"starlight-unicorn",
ModifierDuration.UntilEndOfTurn));
},
};
}
}
}
/// <summary>
/// Dragon Monk, Goku: проверка открыла Kagero 3 ранга → уничтожить Арьергард
/// противника 1 ранга или ниже.
/// </summary>
public sealed class GokuScript : CardScript
{
public override string NameId => "dragon-monk-goku";
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.Kagero },
Resolve = ctx =>
{
var opponent = 1 - ctx.Owner;
var slots = ctx.Engine.RearGuardSlots(opponent, d => d.Grade <= 1);
if (slots.Count == 0)
return;
ctx.Engine.AskUnitChoice(
ctx.Owner,
opponent,
"Выберите Арьергард противника 1 ранга или ниже",
slots,
slot => ctx.Engine.RetireRearGuard(opponent, slot));
},
};
}
}
}
/// <summary>
/// Berserk Dragon: при размещении на (VC)/(RC), если Авангард Kagero,
/// Counter Blast 2 → уничтожить Арьергард противника 2 ранга или ниже.
/// </summary>
public sealed class BerserkDragonScript : CardScript
{
public override string NameId => "berserk-dragon";
public override IEnumerable<AbilityDefinition> Abilities
{
get
{
yield return new AbilityDefinition
{
Trigger = AbilityTrigger.OnPlace,
Optional = true,
Condition = ctx => ctx.Engine.VanguardClan(ctx.Owner) == Clan.Kagero,
CanPay = ctx => ctx.Engine.CanCounterBlast(ctx.Owner, 2),
Pay = ctx => ctx.Engine.CounterBlast(ctx.Owner, 2),
Resolve = ctx =>
{
var opponent = 1 - ctx.Owner;
var slots = ctx.Engine.RearGuardSlots(opponent, d => d.Grade <= 2);
if (slots.Count == 0)
return;
ctx.Engine.AskUnitChoice(
ctx.Owner,
opponent,
"Выберите Арьергард противника 2 ранга или ниже",
slots,
slot => ctx.Engine.RetireRearGuard(opponent, slot));
},
};
}
}
}
/// <summary>
/// Flame of Hope, Aermo: когда атака, которую поддержала эта карта, попадает,
/// можно сбросить карту из руки и взять одну.
/// </summary>
public sealed class AermoScript : CardScript
{
public override string NameId => "flame-of-hope-aermo";
public override IEnumerable<AbilityDefinition> Abilities
{
get
{
yield return new AbilityDefinition
{
Trigger = AbilityTrigger.OnBoostedHit,
Optional = true,
CanPay = ctx => ctx.Engine.HandCount(ctx.Owner) > 0,
Resolve = ctx => ctx.Engine.AskHandCardChoice(
ctx.Owner,
"Сбросьте карту из руки",
null,
card =>
{
var p = ctx.State.Players[ctx.Owner];
p.Hand.Remove(card);
p.Drop.Add(card);
ctx.Engine.DrawCards(ctx.Owner, 1);
}),
};
}
}
}
/// <summary>
/// Demonic Dragon Madonna, Joka и Demonic Dragon Mage, Rakshasa: в Основную фазу,
/// когда Арьергард противника уходит в сброс, эта карта получает +3000 до конца хода.
/// </summary>
public sealed class OpponentRetireBonusScript : CardScript
{
public OpponentRetireBonusScript(string nameId) => NameId = nameId;
public override string NameId { get; }
public override IEnumerable<AbilityDefinition> Abilities
{
get
{
yield return new AbilityDefinition
{
Trigger = AbilityTrigger.OnOpponentRetire,
Condition = ctx =>
!ctx.Engine.IsVanguardOf(ctx.Source, ctx.Owner) &&
ctx.Phase == Phase.Main,
Resolve = ctx => ctx.Engine.AddPower(
ctx.Source,
3000,
ctx.Source,
"opponent-retire",
ModifierDuration.UntilEndOfTurn),
};
}
}
}

View File

@ -148,8 +148,7 @@ public sealed class GameEngine
}
/// <summary>Собирает срабатывания со всех карт на поле, активный игрок первым.</summary>
private void TriggerAll(AbilityTrigger trigger)
{
private void TriggerAll(AbilityTrigger trigger) {
var triggered = new List<TriggeredAbility>();
for (var owner = 0; owner < 2; owner++)
@ -180,6 +179,37 @@ public sealed class GameEngine
RaiseBatch(triggered);
}
/// <summary>
/// «Когда Арьергард противника ушёл в сброс»: срабатывания карт на поле
/// противоположной стороны. В руке такие способности пока не ищутся.
/// </summary>
private void TriggerOpponentRetire(int retiredOwner)
{
var owner = 1 - retiredOwner;
var triggered = new List<TriggeredAbility>();
foreach (var unit in AllUnits(owner))
{
var context = new AbilityContext
{
Engine = this,
Source = unit,
Owner = owner,
Phase = State.Phase,
};
triggered.AddRange(AbilitiesOf(unit)
.Where(a => a.Trigger == AbilityTrigger.OnOpponentRetire
&& (a.Condition is null || a.Condition(context)))
.Select(a => new TriggeredAbility { Definition = a, Context = context }));
}
if (triggered.Count == 0)
return;
RaiseBatch(triggered);
}
private void EnterMainPhase()
{
State.Phase = Phase.Main;
@ -281,9 +311,25 @@ public sealed class GameEngine
var player = State.ActivePlayer;
var p = State.Players[player];
var unit = AllUnits(player).FirstOrDefault(u => u.Id == instanceId);
var fromHand = false;
var fromDrop = false;
if (unit is null)
return CommandResult.Reject("Карта не на поле.");
{
unit = p.Hand.FirstOrDefault(c => c.Id == instanceId);
fromHand = unit is not null;
}
if (unit is null)
{
unit = p.Drop.FirstOrDefault(c => c.Id == instanceId);
fromDrop = unit is not null;
}
if (unit is null)
return CommandResult.Reject("Карта не на поле, в руке или в сбросе.");
var nameId = State.Catalog.CardOf(unit.PrintingId).NameId;
if (!_scripts.TryGetValue(nameId, out var script))
@ -297,10 +343,15 @@ public sealed class GameEngine
Phase = State.Phase,
};
var ability = script.Activated.FirstOrDefault(a => a.CanActivate(context));
var ability = script.Activated.FirstOrDefault(a =>
(fromHand ? a.FromHand : fromDrop ? a.FromDropZone : !a.FromHand && !a.FromDropZone)
&& a.CanActivate(context));
if (ability is null)
return CommandResult.Reject("Нет доступной способности.");
if ((fromHand || fromDrop) && (ability.RestsSelf || ability.RetiresSelf))
return CommandResult.Reject("Способность из этой зоны не оплачивается поворотом или уничтожением.");
if (ability.RestsSelf && unit.Rested)
return CommandResult.Reject("Карта уже повёрнута.");
@ -734,6 +785,9 @@ public sealed class GameEngine
State.Players[player].RearGuards[slot] = null;
State.Players[player].Drop.Add(unit);
_events.Add(new UnitRetiredEvent(player, slot));
TriggerFromCard(player, unit, AbilityTrigger.OnRetireFromRC, null);
TriggerOpponentRetire(player);
}
// ---------- эффекты карт ----------
@ -1022,7 +1076,18 @@ public sealed class GameEngine
}
/// <summary>Райд картой не из руки (superior ride).</summary>
public void RideFromDeck(CardInstance card, int player)
public void RideFromDeck(CardInstance card, int player) => RideUnit(card, player);
/// <summary>Райд картой из сброса (Monster Frank).</summary>
public void RideFromDrop(CardInstance card, int player)
{
if (!State.Players[player].Drop.Remove(card))
return;
RideUnit(card, player);
}
private void RideUnit(CardInstance card, int player)
{
var p = State.Players[player];
var riddenOver = p.Vanguard;
@ -1076,6 +1141,70 @@ public sealed class GameEngine
return vanguard is null ? Clan.RoyalPaladin : State.Catalog.CardOf(vanguard.PrintingId).Clan;
}
public int VanguardGrade(int player) =>
State.Players[player].Vanguard is { } vanguard
? State.Catalog.CardOf(vanguard.PrintingId).Grade
: 0;
/// <summary>Есть ли на своих кругах Арьергарда по одной карте каждого имени.</summary>
public bool HasFieldUnits(int player, IReadOnlyList<string> nameIds)
{
var slots = new HashSet<int>();
foreach (var nameId in nameIds)
{
var found = false;
for (var slot = 0; slot < PlayerState.RearGuardSlots; slot++)
{
if (slots.Contains(slot))
continue;
var unit = State.Players[player].RearGuards[slot];
if (unit is null || State.Catalog.CardOf(unit.PrintingId).NameId != nameId)
continue;
slots.Add(slot);
found = true;
break;
}
if (!found)
return false;
}
return true;
}
/// <summary>Положить по одной карте каждого имени со своих кругов в душу.</summary>
public bool PutFieldUnitsToSoul(int player, IReadOnlyList<string> nameIds)
{
if (!HasFieldUnits(player, nameIds))
return false;
var p = State.Players[player];
var used = new HashSet<int>();
foreach (var nameId in nameIds)
{
for (var slot = 0; slot < PlayerState.RearGuardSlots; slot++)
{
if (used.Contains(slot))
continue;
var unit = p.RearGuards[slot];
if (unit is null || State.Catalog.CardOf(unit.PrintingId).NameId != nameId)
continue;
used.Add(slot);
p.RearGuards[slot] = null;
p.Soul.Add(unit);
_events.Add(new CardMovedToSoulEvent(player, unit.PrintingId));
break;
}
}
return true;
}
// ---------- старт партии ----------
public void Start()
@ -1385,6 +1514,7 @@ public sealed class GameEngine
AttackerPlayer = attackerPlayer,
AttackerSlot = attackerSlot,
TargetSlot = targetSlot,
BoosterSlot = boosterSlot,
};
var defenderIndex = 1 - attackerPlayer;
@ -1494,6 +1624,8 @@ public sealed class GameEngine
_events.Add(new PerfectGuardEvent(defenderIndex, card.PrintingId, discarded.PrintingId));
}
TriggerFromCard(defenderIndex, card, AbilityTrigger.OnGuard, null);
return CommandResult.Ok();
}
@ -1829,6 +1961,44 @@ public sealed class GameEngine
if (hit)
{
// попадание: сначала «атака, которую поддержала эта карта, попала»,
// затем «эта карта попала»; каждый шаг может встать на выбор игрока
void AttackerHitTriggers(CardInstance? hitUnit, Action after)
{
TriggerFromCard(battle.AttackerPlayer, attacker, AbilityTrigger.OnAttackHit, hitUnit);
if (State.Step is Step.Ability or Step.Choice)
{
State.Continuation = after;
return;
}
after();
}
void BoosterHitTriggers(CardInstance? hitUnit, Action after)
{
var booster = battle.BoosterSlot >= 0
? State.GetUnit(battle.AttackerPlayer, battle.BoosterSlot)
: null;
if (booster is null)
{
AttackerHitTriggers(hitUnit, after);
return;
}
TriggerFromCard(battle.AttackerPlayer, booster, AbilityTrigger.OnBoostedHit, hitUnit);
if (State.Step is Step.Ability or Step.Choice)
{
State.Continuation = () => AttackerHitTriggers(hitUnit, after);
return;
}
AttackerHitTriggers(hitUnit, after);
}
if (battle.Interceptor is not null)
{
// перехватчик уже вышел из круга и уйдёт в сброс при закрытии боя,
@ -1839,18 +2009,7 @@ public sealed class GameEngine
{
var damage = EffectiveCritical(attacker);
void AfterDamage()
{
TriggerFromCard(battle.AttackerPlayer, attacker, AbilityTrigger.OnAttackHit, null);
if (State.Step is Step.Ability or Step.Choice)
{
State.Continuation = () => ClearBattle(battle);
return;
}
ClearBattle(battle);
}
void AfterDamage() => BoosterHitTriggers(null, () => ClearBattle(battle));
void ResolveDamage()
{
@ -1875,13 +2034,29 @@ public sealed class GameEngine
State.Players[defenderIndex].Drop.Add(target);
_events.Add(new UnitRetiredEvent(defenderIndex, battle.TargetSlot));
TriggerFromCard(battle.AttackerPlayer, attacker, AbilityTrigger.OnAttackHit, target);
void AfterRetire()
{
TriggerOpponentRetire(defenderIndex);
if (State.Step is Step.Ability or Step.Choice)
{
State.Continuation = () => ClearBattle(battle);
State.Continuation = () => BoosterHitTriggers(target, () => ClearBattle(battle));
return;
}
BoosterHitTriggers(target, () => ClearBattle(battle));
}
TriggerFromCard(defenderIndex, target, AbilityTrigger.OnRetireFromRC, null);
if (State.Step is Step.Ability or Step.Choice)
{
State.Continuation = AfterRetire;
return;
}
AfterRetire();
return;
}
}

View File

@ -22,6 +22,9 @@ public sealed class BattleState
/// <summary>Карта, вышедшая на перехват. Сила сравнения берётся по ней.</summary>
public CardInstance? Interceptor { get; set; }
/// <summary>Круг карты поддержки (-1 — поддержки не было).</summary>
public int BoosterSlot { get; set; } = -1;
public List<CardInstance> GuardCards { get; } = new();
}

View File

@ -36,6 +36,20 @@ public class GameEngineTests
private const string Juggernaut = "BT01/020EN"; // Spike Brothers, возврат в колоду
private const string RockTheWall = "BT01/073EN";
private const string GuidingZombie = "BT01/078EN";
private const string Blightops = "BT01/034EN";
private const string MonsterFrank = "BT01/037EN";
private const string Shieldon = "BT01/067EN";
private const string Dka = "BT01/014EN";
private const string Bahr = "BT01/048EN";
private const string Tahr = "BT01/024EN";
private const string AlephVictory = "BT01/005EN";
private const string Gancelot = "BT01/010EN";
private const string Voidmaster = "BT01/035EN";
private const string BlasterBladePrint = "BT01/002EN";
private const string Bors = "TD01/002EN";
private const string Unicorn = "TD01/010EN";
private const string Goku = "TD02/002EN";
private const string Joka = "TD02/010EN";
[Fact]
public void Start_Places_Starting_Vanguard_And_Draws_Five()
@ -1359,6 +1373,214 @@ public class GameEngineTests
Assert.Equal(3, deckBefore - engine.State.Players[0].Deck.Count);
}
[Fact]
public void Monster_Frank_Rides_From_Drop_Zone()
{
var engine = InMainPhase();
engine.State.Players[0].Vanguard = MakeCard(Gallatin);
GiveDamage(engine, 0, 3);
var frank = MakeCard(MonsterFrank);
engine.State.Players[0].Drop.Add(frank);
Assert.True(engine.Execute(new ActivateAbilityCommand(frank.Id)).Accepted);
Assert.Same(frank, engine.State.Players[0].Vanguard);
Assert.DoesNotContain(engine.State.Players[0].Drop, c => c.Id == frank.Id);
Assert.Equal(3, engine.State.Players[0].Damage.Count(c => c.FaceUp));
}
[Fact]
public void Gancelot_Hand_Ability_Puts_Itself_On_Top_And_Searches_Blaster_Blade()
{
var engine = InMainPhase();
KeepOnly(engine, 0, "blaster-blade", 0);
var gancelot = MakeCard(Gancelot);
engine.State.Players[0].Hand.Add(gancelot);
var blade = MakeCard(BlasterBladePrint);
engine.State.Players[0].Deck.Insert(0, blade);
Assert.True(engine.Execute(new ActivateAbilityCommand(gancelot.Id)).Accepted);
Assert.Contains(engine.State.Players[0].Hand, c => c.PrintingId == BlasterBladePrint);
Assert.DoesNotContain(engine.State.Players[0].Hand, c => c.Id == gancelot.Id);
}
[Fact]
public void Blightops_Searches_Shieldon_When_Retired()
{
var engine = InMainPhase();
KeepOnly(engine, 0, "ironclad-dragon-shieldon", 0);
var blightops = MakeCard(Blightops);
engine.State.Players[0].RearGuards[0] = blightops;
GiveDamage(engine, 0, 1);
engine.State.Players[0].Deck.Insert(0, MakeCard(Shieldon));
engine.RetireRearGuard(0, 0);
Assert.NotNull(engine.State.PendingAbility);
Assert.True(engine.Execute(new ResolveAbilityCommand(true)).Accepted);
Assert.Contains(engine.State.Players[0].Hand, c => c.PrintingId == Shieldon);
Assert.Equal(1, engine.State.Players[0].Damage.Count(c => c.FaceUp));
}
[Fact]
public void Dragon_Knight_Aleph_Rides_Aleph_After_Putting_Cost_Into_Soul()
{
var engine = InMainPhase();
KeepOnly(engine, 0, "embodiment-of-victory-aleph", 0);
var dka = MakeCard(Dka);
engine.State.Players[0].Vanguard = dka;
var bahr = MakeCard(Bahr);
var tahr = MakeCard(Tahr);
engine.State.Players[0].RearGuards[0] = bahr;
engine.State.Players[0].RearGuards[1] = tahr;
GiveDamage(engine, 0, 1);
var aleph = MakeCard(AlephVictory);
engine.State.Players[0].Deck.Insert(0, aleph);
Assert.True(engine.Execute(new ActivateAbilityCommand(dka.Id)).Accepted);
Assert.Same(aleph, engine.State.Players[0].Vanguard);
Assert.Contains(engine.State.Players[0].Soul, c => c.Id == bahr.Id);
Assert.Contains(engine.State.Players[0].Soul, c => c.Id == tahr.Id);
Assert.Null(engine.State.Players[0].RearGuards[0]);
Assert.Null(engine.State.Players[0].RearGuards[1]);
}
[Fact]
public void Voidmaster_Makes_Opponent_Discard_When_Hand_Is_Smaller()
{
var engine = StartedEngine();
engine.Execute(new SkipRideCommand());
var voidmaster = MakeCard(Voidmaster);
engine.State.Players[0].Vanguard = voidmaster;
var p0 = engine.State.Players[0];
var p1 = engine.State.Players[1];
while (p0.Hand.Count > 0)
{
p0.Drop.Add(p0.Hand[0]);
p0.Hand.RemoveAt(0);
}
p1.Hand.Add(MakeCard(Gallatin));
p1.Hand.Add(MakeCard(Gallatin));
GiveDamage(engine, 0, 1);
p0.Deck.Insert(0, MakeCard(Gallatin));
p0.Deck.Insert(1, MakeCard(Gallatin));
p1.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.PendingAbility);
Assert.True(engine.Execute(new ResolveAbilityCommand(true)).Accepted);
Assert.NotNull(engine.State.PendingChoice);
var handBefore = p1.Hand.Count;
Assert.True(engine.Execute(new ChooseCommand(0)).Accepted);
Assert.Equal(handBefore - 1, p1.Hand.Count);
Assert.Equal(1, p0.Damage.Count(c => c.FaceUp));
}
[Fact]
public void Bors_Gets_Power_For_Counter_Blast()
{
var engine = StartedEngine();
engine.Execute(new SkipRideCommand());
var bors = MakeCard(Bors);
engine.State.Players[0].Vanguard = bors;
GiveDamage(engine, 0, 1);
engine.Execute(new EnterBattleCommand());
Assert.True(engine.Execute(new AttackCommand(-1, -1)).Accepted);
Assert.NotNull(engine.State.PendingAbility);
Assert.True(engine.Execute(new ResolveAbilityCommand(true)).Accepted);
Assert.Equal(3000, bors.Modifiers.Where(m => m.EffectId == "ability").Sum(m => m.Power));
}
[Fact]
public void Goku_Retires_Small_RearGuard_After_Grade3_Drive_Check()
{
var engine = StartedEngine();
engine.Execute(new SkipRideCommand());
var goku = MakeCard(Goku);
engine.State.Players[0].Vanguard = goku;
var opponent = engine.State.Players[1];
opponent.RearGuards[0] = MakeCard(Marron);
engine.State.Players[0].Deck.Insert(0, MakeCard(Overlord)); // Kagero 3 ранга
engine.State.Players[0].Deck.Insert(1, MakeCard(Gallatin));
opponent.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.Null(opponent.RearGuards[0]);
}
[Fact]
public void Starlight_Unicorn_Gives_Another_Royal_Paladin_2000()
{
var engine = InMainPhase();
engine.State.Players[0].Vanguard = MakeCard(Marron);
var target = MakeCard(Gallatin);
engine.State.Players[0].RearGuards[0] = target;
engine.State.Players[0].Hand.Add(MakeCard(Unicorn));
Assert.True(engine.Execute(new CallCommand(Unicorn, 1)).Accepted);
Assert.NotNull(engine.State.PendingChoice);
Assert.True(engine.Execute(new ChooseCommand(1)).Accepted); // Авангард, затем Gallatin
Assert.Equal(
2000,
target.Modifiers.Where(m => m.EffectId == "starlight-unicorn").Sum(m => m.Power));
}
[Fact]
public void Joka_Gets_Power_When_Opponent_RearGuard_Is_Retired()
{
var engine = InMainPhase();
var joka = MakeCard(Joka);
engine.State.Players[0].RearGuards[0] = joka;
engine.State.Players[1].RearGuards[1] = MakeCard(Gallatin);
engine.RetireRearGuard(1, 1);
Assert.Equal(
3000,
joka.Modifiers.Where(m => m.EffectId == "opponent-retire").Sum(m => m.Power));
}
[Fact]
public void ResolveTrigger_Rejects_When_Nothing_Is_Pending()
{