Finish the card set: Tejas, Yaksha, back-row attacks and replay

Normal units can no longer attack the back row; Wyvern Strike, Tejas attacks the back row of its own column. Demonic Dragon Berserker, Yaksha rides itself from hand when an opponent's rear-guard is retired during the main phase. Match replay: CommandCodec, MatchRecord, MatchRecorder, ReplayRunner and an FNV event hash; a JSON round-trip replays a full bot game bit-for-bit. Three tests; 116 total.
This commit is contained in:
MpakobecEx 2026-10-21 14:00:16 +05:00
parent 4524c0f19f
commit 66d52169fc
14 changed files with 382 additions and 27 deletions

View File

@ -133,13 +133,14 @@
- [x] Список легальных интентов на каждый момент (для UI и античита). - [x] Список легальных интентов на каждый момент (для UI и античита).
— `GameEngine.LegalCommands()` покрывает фазы, выборы, триггеры и активируемые способности. — `GameEngine.LegalCommands()` покрывает фазы, выборы, триггеры и активируемые способности.
- [~] Детерминированный RNG: один поток на партию, состояние в `GameState`. - [~] Детерминированный RNG: один поток на партию, состояние в `GameState`.
- [~] Журнал событий + seed; сериализуемый реплей ещё не сделан. - [x] Журнал событий + seed; сериализуемый реплей.
— `MatchRecord`, `MatchRecorder`, `CommandCodec`, `ReplayRunner`, `EventHasher`; запись переживает JSON и воспроизводится бит-в-бит по хэшу событий.
- [~] Защита от бесконечных циклов и дедлоков. - [~] Защита от бесконечных циклов и дедлоков.
— 50 партий случайного бота в тестах; если команд нет, тест падает. Глобального лимита шагов в движке нет. — 50 партий случайного бота в тестах; если команд нет, тест падает. Глобального лимита шагов в движке нет.
- [x] Инварианты партии: 50 карт в зонах каждого игрока, уникальные `instanceId`, урон ≤ 6. - [x] Инварианты партии: 50 карт в зонах каждого игрока, уникальные `instanceId`, урон ≤ 6.
- [x] Карты «в пути» между зонами (`GameState.InTransit`) — например, найденная карта ждёт выбора круга. - [x] Карты «в пути» между зонами (`GameState.InTransit`) — например, найденная карта ждёт выбора круга.
**Сделано:** каркас движка (`Engine/`), все фазы, бой, способности, 58 карт со скриптами, `LegalCommands`, случайный бот на 50 партиях. Всего 112 тестов. **Сделано:** каркас движка (`Engine/`), все фазы, бой, способности, 66/66 карт, `LegalCommands`, случайный бот на 50 партиях, реплей с хэшем событий. Всего 116 тестов.
**Готово, когда:** два бота случайными легальными интентами доигрывают 1000 партий без падений, инварианты (сумма карт, счётчики ≥ 0) держатся, реплей воспроизводится бит-в-бит. **Готово, когда:** два бота случайными легальными интентами доигрывают 1000 партий без падений, инварианты (сумма карт, счётчики ≥ 0) держатся, реплей воспроизводится бит-в-бит.
@ -155,12 +156,12 @@
- [x] RU/EN тексты для всех 94. - [x] RU/EN тексты для всех 94.
- [x] Сила/щит для 14 карт, которых нет в BT01. - [x] Сила/щит для 14 карт, которых нет в BT01.
- [ ] Дашборд покрытия: `scripted / tested / total`. - [ ] Дашборд покрытия: `scripted / tested / total`.
— сейчас заскриптовано **58** карт из 66 с текстом. Остались: Tejas (нужно правило атаки заднего ряда) и Yaksha (триггер из руки). Шесть карт — только напоминания о Sentinel/Heal, им скрипт не нужен. — **66 из 66** карт с текстом закрыты: 59 скриптов + Tejas (флаг атаки заднего ряда) + 6 карт Sentinel/Heal, которые обрабатывает движок. Оставшихся карт нет.
- [x] Триггеры `OnDriveCheck` (Asura Kaiser, Brigitte, Tristan, Goku), `OnBoostedHit` (Dreadmaster, Aermo), `OnGuard` (Hagakure), `OnRetireFromRC` (Blightops), `OnOpponentRetire` (Joka, Rakshasa). - [x] Триггеры `OnDriveCheck` (Asura Kaiser, Brigitte, Tristan, Goku), `OnBoostedHit` (Dreadmaster, Aermo), `OnGuard` (Hagakure), `OnRetireFromRC` (Blightops), `OnOpponentRetire` (Joka, Rakshasa, Yaksha).
- [x] Способности из руки (Gancelot) и из сброса (Monster Frank). - [x] Способности из руки (Gancelot, Yaksha) и из сброса (Monster Frank).
- [x] Возврат карты с поля в колоду: после боя (Juggernaut, Brakki) и в конце хода (Lozenge, Battleraizer). - [x] Возврат карты с поля в колоду: после боя (Juggernaut, Brakki) и в конце хода (Lozenge, Battleraizer).
- [x] Триггер `OnLeaveGuardian` (Rock the Wall, Redshoe Milly). - [x] Триггер `OnLeaveGuardian` (Rock the Wall, Redshoe Milly).
- [ ] Wyvern Strike, Tejas: атака заднего ряда не выделена — движок разрешает атаковать любой круг Арьергарда. - [x] Wyvern Strike, Tejas: атака заднего ряда только из своей колонки; обычные карты атакуют только передний ряд.
**Готово, когда:** 94/94 заскриптованы, 94/94 покрыты тестом, приёмка пройдена по чеклисту карты. **Готово, когда:** 94/94 заскриптованы, 94/94 покрыты тестом, приёмка пройдена по чеклисту карты.

View File

@ -231,6 +231,7 @@ cards:
critical: 1 critical: 1
trigger: None trigger: None
clan: Kagero clan: Kagero
snipeBackRow: true
textEn: "[CONT](VC/RC):If this unit would attack, it may instead attack an opponent's unit in the back row of the same column as this unit." textEn: "[CONT](VC/RC):If this unit would attack, it may instead attack an opponent's unit in the back row of the same column as this unit."
- nameId: embodiment-of-spear-tahr - nameId: embodiment-of-spear-tahr
nameEn: "Embodiment of Spear, Tahr" nameEn: "Embodiment of Spear, Tahr"

View File

@ -24,6 +24,7 @@ public sealed class CardEntry
public Clan Clan { get; set; } public Clan Clan { get; set; }
public bool Sentinel { get; set; } public bool Sentinel { get; set; }
public bool Restraint { get; set; } public bool Restraint { get; set; }
public bool SnipeBackRow { get; set; }
/// <summary>null — по умолчанию (0 и 1 ранг поддерживают).</summary> /// <summary>null — по умолчанию (0 и 1 ранг поддерживают).</summary>
public bool? Boost { get; set; } public bool? Boost { get; set; }
@ -44,6 +45,7 @@ public sealed class CardEntry
Clan = Clan, Clan = Clan,
IsSentinel = Sentinel, IsSentinel = Sentinel,
IsRestraint = Restraint, IsRestraint = Restraint,
CanSnipeBackRow = SnipeBackRow,
BoostOverride = Boost, BoostOverride = Boost,
TextEn = TextEn, TextEn = TextEn,
TextRu = TextRu, TextRu = TextRu,

View File

@ -40,6 +40,11 @@ public sealed record CardDefinition
/// <summary>Печатный перехват: по умолчанию у карт 2 ранга.</summary> /// <summary>Печатный перехват: по умолчанию у карт 2 ранга.</summary>
public bool HasIntercept => InterceptOverride ?? Grade == 2; public bool HasIntercept => InterceptOverride ?? Grade == 2;
/// <summary>
/// Может атаковать Арьергард в заднем ряду той же колонки (Wyvern Strike, Tejas).
/// </summary>
public bool CanSnipeBackRow { get; init; }
/// <summary> /// <summary>
/// Печатное число проверок при атаке из круга Авангарда: /// Печатное число проверок при атаке из круга Авангарда:
/// 0–2 ранг — одна, 3 ранг и выше — двойная. /// 0–2 ранг — одна, 3 ранг и выше — двойная.

View File

@ -66,6 +66,7 @@ public static class CardScripts
["flame-of-hope-aermo"] = new AermoScript(), ["flame-of-hope-aermo"] = new AermoScript(),
["demonic-dragon-madonna-joka"] = new OpponentRetireBonusScript("demonic-dragon-madonna-joka"), ["demonic-dragon-madonna-joka"] = new OpponentRetireBonusScript("demonic-dragon-madonna-joka"),
["demonic-dragon-mage-rakshasa"] = new OpponentRetireBonusScript("demonic-dragon-mage-rakshasa"), ["demonic-dragon-mage-rakshasa"] = new OpponentRetireBonusScript("demonic-dragon-mage-rakshasa"),
["demonic-dragon-berserker-yaksha"] = new YakshaScript(),
}; };
} }

View File

@ -2,6 +2,32 @@ using Vanguard.Rules.Cards;
namespace Vanguard.Rules.Engine.Abilities; namespace Vanguard.Rules.Engine.Abilities;
/// <summary>
/// Demonic Dragon Berserker, Yaksha: в Основную фазу, когда Арьергард противника
/// уходит в сброс, при Авангарде 2 ранга можно показать эту карту и совершить райд.
/// </summary>
public sealed class YakshaScript : CardScript
{
public override string NameId => "demonic-dragon-berserker-yaksha";
public override IEnumerable<AbilityDefinition> Abilities
{
get
{
yield return new AbilityDefinition
{
Trigger = AbilityTrigger.OnOpponentRetire,
Optional = true,
Condition = ctx =>
ctx.Engine.IsInHand(ctx.Owner, ctx.Source) &&
ctx.Engine.VanguardGrade(ctx.Owner) == 2 &&
ctx.Phase == Phase.Main,
Resolve = ctx => ctx.Engine.RideFromHand(ctx.Source, ctx.Owner),
};
}
}
}
/// <summary> /// <summary>
/// «Когда проверка открывает карту 3 ранга нужного клана, эта карта получает /// «Когда проверка открывает карту 3 ранга нужного клана, эта карта получает
/// +5000 до конца боя»: Crimson Butterfly, Brigitte и Knight of the Harp, Tristan. /// +5000 до конца боя»: Crimson Butterfly, Brigitte и Knight of the Harp, Tristan.

View File

@ -204,6 +204,23 @@ public sealed class GameEngine
.Select(a => new TriggeredAbility { Definition = a, Context = context })); .Select(a => new TriggeredAbility { Definition = a, Context = context }));
} }
// способности из руки (Yaksha): «покажи и соверши райд»
foreach (var card in State.Players[owner].Hand)
{
var context = new AbilityContext
{
Engine = this,
Source = card,
Owner = owner,
Phase = State.Phase,
};
triggered.AddRange(AbilitiesOf(card)
.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) if (triggered.Count == 0)
return; return;
@ -1106,6 +1123,18 @@ public sealed class GameEngine
RideUnit(card, player); RideUnit(card, player);
} }
/// <summary>Райд картой из руки по эффекту (Demonic Dragon Berserker, Yaksha).</summary>
public void RideFromHand(CardInstance card, int player)
{
if (!State.Players[player].Hand.Remove(card))
return;
RideUnit(card, player);
}
public bool IsInHand(int player, CardInstance card) =>
State.Players[player].Hand.Contains(card);
private void RideUnit(CardInstance card, int player) private void RideUnit(CardInstance card, int player)
{ {
var p = State.Players[player]; var p = State.Players[player];
@ -1387,7 +1416,13 @@ public sealed class GameEngine
continue; continue;
var targets = new List<int> { -1 }; var targets = new List<int> { -1 };
targets.AddRange(UnitSlots(1 - player).Where(s => s >= 0)); var canSnipe = State.Catalog.CardOf(attacker.PrintingId).CanSnipeBackRow;
targets.AddRange(UnitSlots(1 - player).Where(slot => slot >= 0
&& (slot is 3 or 4
|| (canSnipe
&& attackerSlot is 3 or 4
&& SameColumn(attackerSlot, slot)))));
foreach (var target in targets) foreach (var target in targets)
commands.Add(new AttackCommand(attackerSlot, target)); commands.Add(new AttackCommand(attackerSlot, target));
@ -1774,6 +1809,17 @@ public sealed class GameEngine
if (target is null) if (target is null)
return CommandResult.Reject("Нет цели."); return CommandResult.Reject("Нет цели.");
// Задний ряд защищён: атаковать его можно только способностью
// и только из переднего ряда своей колонки.
if (targetSlot is 0 or 1 or 2)
{
if (!State.Catalog.CardOf(attacker.PrintingId).CanSnipeBackRow)
return CommandResult.Reject("Арьергард в заднем ряду нельзя атаковать без способности.");
if (attackerSlot is not (3 or 4) || !SameColumn(attackerSlot, targetSlot))
return CommandResult.Reject("Атаковать задний ряд можно только из своей колонки.");
}
CardInstance? booster = null; CardInstance? booster = null;
if (boosterSlot >= 0) if (boosterSlot >= 0)
{ {

View File

@ -33,6 +33,7 @@ public sealed class GameState
public GameState(CardCatalog catalog, ulong seed, string firstPlayerName, string secondPlayerName) public GameState(CardCatalog catalog, ulong seed, string firstPlayerName, string secondPlayerName)
{ {
Catalog = catalog; Catalog = catalog;
Seed = seed;
RngState = seed; RngState = seed;
Players[0] = new PlayerState(firstPlayerName); Players[0] = new PlayerState(firstPlayerName);
Players[1] = new PlayerState(secondPlayerName); Players[1] = new PlayerState(secondPlayerName);
@ -47,6 +48,9 @@ public sealed class GameState
public int TurnNumber { get; internal set; } public int TurnNumber { get; internal set; }
public ulong RngState { get; internal set; } public ulong RngState { get; internal set; }
/// <summary>Исходное зерно партии: нужно для записи и воспроизведения реплея.</summary>
public ulong Seed { get; }
public int NextInstanceId { get; internal set; } = 1; public int NextInstanceId { get; internal set; } = 1;
public BattleState? Battle { get; internal set; } public BattleState? Battle { get; internal set; }

View File

@ -0,0 +1,72 @@
using System.Globalization;
using Vanguard.Rules.Engine;
namespace Vanguard.Rules.Replay;
/// <summary>
/// Компактное текстовое представление команд. Формат стабильный и годится
/// как для реплея, так и для журнала матча в БД.
/// </summary>
public static class CommandCodec
{
private const char Separator = '|';
public static string Encode(GameCommand command) => command switch
{
MulliganCommand c => $"mulligan{Separator}{string.Join(',', c.HandIndexes)}",
FinishMulliganCommand => "finishMulligan",
RideCommand c => $"ride{Separator}{c.PrintingId}",
SkipRideCommand => "skipRide",
CallCommand c => $"call{Separator}{c.PrintingId}{Separator}{c.Slot}",
MoveCommand c => $"move{Separator}{c.FromSlot}{Separator}{c.ToSlot}",
EnterBattleCommand => "enterBattle",
AttackCommand c => $"attack{Separator}{c.AttackerSlot}{Separator}{c.TargetSlot}{Separator}{c.BoosterSlot}",
GuardCommand c => $"guard{Separator}{c.PrintingId}{Separator}{c.DiscardPrintingId}",
InterceptCommand c => $"intercept{Separator}{c.Slot}",
PassGuardCommand => "passGuard",
ResolveTriggerCommand c => $"resolveTrigger{Separator}{c.PowerTarget}{Separator}{c.EffectTarget}",
SelectTriggerCommand c => $"selectTrigger{Separator}{c.Index}",
ResolveAbilityCommand c => $"resolveAbility{Separator}{(c.Accept ? 1 : 0)}",
ChooseCommand c => $"choose{Separator}{c.Index}",
ActivateAbilityCommand c => $"activate{Separator}{c.InstanceId}{Separator}{c.DiscardPrintingId}",
EndTurnCommand => "endTurn",
_ => throw new ArgumentOutOfRangeException(nameof(command), command, "Неизвестная команда."),
};
public static GameCommand Decode(string encoded)
{
var parts = encoded.Split(Separator);
var name = parts[0];
return name switch
{
"mulligan" => new MulliganCommand(
parts[1].Length == 0
? Array.Empty<int>()
: parts[1].Split(',').Select(ParseInt).ToArray()),
"finishMulligan" => new FinishMulliganCommand(),
"ride" => new RideCommand(parts[1]),
"skipRide" => new SkipRideCommand(),
"call" => new CallCommand(parts[1], ParseInt(parts[2])),
"move" => new MoveCommand(ParseInt(parts[1]), ParseInt(parts[2])),
"enterBattle" => new EnterBattleCommand(),
"attack" => new AttackCommand(ParseInt(parts[1]), ParseInt(parts[2]), ParseInt(parts[3])),
"guard" => new GuardCommand(parts[1], Nullable(parts[2])),
"intercept" => new InterceptCommand(ParseInt(parts[1])),
"passGuard" => new PassGuardCommand(),
"resolveTrigger" => new ResolveTriggerCommand(ParseInt(parts[1]), ParseInt(parts[2])),
"selectTrigger" => new SelectTriggerCommand(ParseInt(parts[1])),
"resolveAbility" => new ResolveAbilityCommand(parts[1] == "1"),
"choose" => new ChooseCommand(ParseInt(parts[1])),
"activate" => new ActivateAbilityCommand(ParseInt(parts[1]), Nullable(parts[2])),
"endTurn" => new EndTurnCommand(),
_ => throw new FormatException($"Неизвестная команда: {encoded}"),
};
}
private static int ParseInt(string value) =>
int.Parse(value, CultureInfo.InvariantCulture);
private static string? Nullable(string value) =>
value.Length == 0 ? null : value;
}

View File

@ -0,0 +1,32 @@
using Vanguard.Rules.Engine;
namespace Vanguard.Rules.Replay;
/// <summary>FNV-1a по тексту событий. Не криптография, а быстрая проверка расхождений.</summary>
public static class EventHasher
{
private const ulong Offset = 14695981039346656037UL;
private const ulong Prime = 1099511628211UL;
public static string Hash(IEnumerable<GameEvent> events)
{
unchecked
{
var hash = Offset;
foreach (var gameEvent in events)
{
foreach (var ch in gameEvent.ToString() ?? string.Empty)
{
hash ^= ch;
hash *= Prime;
}
hash ^= '\n';
hash *= Prime;
}
return hash.ToString("x16");
}
}
}

View File

@ -0,0 +1,13 @@
namespace Vanguard.Rules.Replay;
/// <summary>Запись матча: зерно, колоды, команды и хэш итоговых событий.</summary>
public sealed class MatchRecord
{
public ulong Seed { get; init; }
public List<string> Deck0 { get; init; } = new();
public List<string> Deck1 { get; init; } = new();
public List<string> Commands { get; init; } = new();
/// <summary>Хэш событий всей партии: реплей должен дать точно такой же.</summary>
public string EventHash { get; init; } = string.Empty;
}

View File

@ -0,0 +1,38 @@
using Vanguard.Rules.Engine;
namespace Vanguard.Rules.Replay;
/// <summary>Играет матч и записывает только принятые команды.</summary>
public sealed class MatchRecorder
{
private readonly GameEngine _engine;
private readonly List<string> _commands = new();
public MatchRecorder(GameEngine engine, IReadOnlyList<string> deck0, IReadOnlyList<string> deck1)
{
_engine = engine;
Deck0 = deck0.ToList();
Deck1 = deck1.ToList();
}
public List<string> Deck0 { get; }
public List<string> Deck1 { get; }
public CommandResult Execute(GameCommand command)
{
var result = _engine.Execute(command);
if (result.Accepted)
_commands.Add(CommandCodec.Encode(command));
return result;
}
public MatchRecord ToRecord() => new()
{
Seed = _engine.State.Seed,
Deck0 = Deck0,
Deck1 = Deck1,
Commands = _commands.ToList(),
EventHash = EventHasher.Hash(_engine.Events),
};
}

View File

@ -0,0 +1,37 @@
using Vanguard.Rules.Cards;
using Vanguard.Rules.Engine;
namespace Vanguard.Rules.Replay;
/// <summary>Воспроизводит запись матча и проверяет, что события совпали.</summary>
public static class ReplayRunner
{
public static GameEngine Replay(CardCatalog catalog, MatchRecord record)
{
var engine = new GameEngine(catalog, record.Seed, record.Deck0, record.Deck1);
engine.Start();
foreach (var encoded in record.Commands)
{
var command = CommandCodec.Decode(encoded);
var result = engine.Execute(command);
if (!result.Accepted)
{
throw new InvalidOperationException(
$"Реплей разошёлся на команде «{encoded}»: {result.Reason}");
}
}
return engine;
}
public static void Verify(CardCatalog catalog, MatchRecord record)
{
var engine = Replay(catalog, record);
var actual = EventHasher.Hash(engine.Events);
if (!string.Equals(actual, record.EventHash, StringComparison.Ordinal))
throw new InvalidOperationException($"Хэш событий не совпал: {actual} != {record.EventHash}");
}
}

View File

@ -1,6 +1,8 @@
using System.Text.Json;
using Vanguard.Rules.Cards; using Vanguard.Rules.Cards;
using Vanguard.Rules.Engine; using Vanguard.Rules.Engine;
using Vanguard.Rules.Engine.Abilities; using Vanguard.Rules.Engine.Abilities;
using Vanguard.Rules.Replay;
using Xunit; using Xunit;
namespace Vanguard.Rules.Tests; namespace Vanguard.Rules.Tests;
@ -50,6 +52,8 @@ public class GameEngineTests
private const string Unicorn = "TD01/010EN"; private const string Unicorn = "TD01/010EN";
private const string Goku = "TD02/002EN"; private const string Goku = "TD02/002EN";
private const string Joka = "TD02/010EN"; private const string Joka = "TD02/010EN";
private const string Tejas = "BT01/023EN";
private const string Yaksha = "TD02/003EN";
[Fact] [Fact]
public void Start_Places_Starting_Vanguard_And_Draws_Five() public void Start_Places_Starting_Vanguard_And_Draws_Five()
@ -353,12 +357,12 @@ public class GameEngineTests
{ {
var engine = ReadyToAttackRearGuard(); var engine = ReadyToAttackRearGuard();
Assert.True(engine.Execute(new AttackCommand(3, 0)).Accepted); Assert.True(engine.Execute(new AttackCommand(3, 4)).Accepted);
Assert.True(engine.Execute(new InterceptCommand(3)).Accepted); Assert.True(engine.Execute(new InterceptCommand(3)).Accepted);
Assert.True(engine.Execute(new PassGuardCommand()).Accepted); Assert.True(engine.Execute(new PassGuardCommand()).Accepted);
var defender = engine.State.Players[1]; var defender = engine.State.Players[1];
Assert.NotNull(defender.RearGuards[0]); // цель выжила Assert.NotNull(defender.RearGuards[4]); // цель выжила
Assert.Null(defender.RearGuards[3]); // перехватчик покинул круг Assert.Null(defender.RearGuards[3]); // перехватчик покинул круг
Assert.Contains(defender.Drop, c => c.PrintingId == Gallatin); Assert.Contains(defender.Drop, c => c.PrintingId == Gallatin);
Assert.Contains(engine.Events, e => e is InterceptedEvent); Assert.Contains(engine.Events, e => e is InterceptedEvent);
@ -370,11 +374,11 @@ public class GameEngineTests
{ {
var engine = ReadyToAttackRearGuard(); var engine = ReadyToAttackRearGuard();
Assert.True(engine.Execute(new AttackCommand(3, 0)).Accepted); Assert.True(engine.Execute(new AttackCommand(3, 4)).Accepted);
Assert.True(engine.Execute(new PassGuardCommand()).Accepted); Assert.True(engine.Execute(new PassGuardCommand()).Accepted);
var defender = engine.State.Players[1]; var defender = engine.State.Players[1];
Assert.Null(defender.RearGuards[0]); Assert.Null(defender.RearGuards[4]);
Assert.Contains(defender.Drop, c => c.PrintingId == Marron); Assert.Contains(defender.Drop, c => c.PrintingId == Marron);
Assert.Contains(engine.Events, e => e is UnitRetiredEvent); Assert.Contains(engine.Events, e => e is UnitRetiredEvent);
} }
@ -386,7 +390,7 @@ public class GameEngineTests
var defender = engine.State.Players[1]; var defender = engine.State.Players[1];
defender.RearGuards[3] = TakeFromDeck(defender, Marron); defender.RearGuards[3] = TakeFromDeck(defender, Marron);
Assert.True(engine.Execute(new AttackCommand(3, 0)).Accepted); Assert.True(engine.Execute(new AttackCommand(3, 4)).Accepted);
Assert.False(engine.Execute(new InterceptCommand(3)).Accepted); Assert.False(engine.Execute(new InterceptCommand(3)).Accepted);
} }
@ -397,7 +401,7 @@ public class GameEngineTests
var defender = engine.State.Players[1]; var defender = engine.State.Players[1];
defender.RearGuards[1] = TakeFromDeck(defender, Gallatin); defender.RearGuards[1] = TakeFromDeck(defender, Gallatin);
Assert.True(engine.Execute(new AttackCommand(3, 0)).Accepted); Assert.True(engine.Execute(new AttackCommand(3, 4)).Accepted);
Assert.False(engine.Execute(new InterceptCommand(1)).Accepted); Assert.False(engine.Execute(new InterceptCommand(1)).Accepted);
} }
@ -411,7 +415,7 @@ public class GameEngineTests
unit.InterceptFromBackRow = true; unit.InterceptFromBackRow = true;
defender.RearGuards[1] = unit; defender.RearGuards[1] = unit;
Assert.True(engine.Execute(new AttackCommand(3, 0)).Accepted); Assert.True(engine.Execute(new AttackCommand(3, 4)).Accepted);
Assert.True(engine.Execute(new InterceptCommand(1)).Accepted); Assert.True(engine.Execute(new InterceptCommand(1)).Accepted);
} }
@ -444,8 +448,8 @@ public class GameEngineTests
{ {
var engine = ReadyToAttackRearGuard(); var engine = ReadyToAttackRearGuard();
Assert.True(engine.Execute(new AttackCommand(3, 0)).Accepted); Assert.True(engine.Execute(new AttackCommand(3, 4)).Accepted);
Assert.False(engine.Execute(new InterceptCommand(0)).Accepted); Assert.False(engine.Execute(new InterceptCommand(4)).Accepted);
} }
[Fact] [Fact]
@ -554,7 +558,7 @@ public class GameEngineTests
{ {
var engine = ReadyToAttackRearGuard(); var engine = ReadyToAttackRearGuard();
engine.Execute(new AttackCommand(3, 0)); engine.Execute(new AttackCommand(3, 4));
engine.Execute(new PassGuardCommand()); engine.Execute(new PassGuardCommand());
Assert.Empty(engine.Events.OfType<DriveCheckedEvent>()); Assert.Empty(engine.Events.OfType<DriveCheckedEvent>());
@ -567,7 +571,7 @@ public class GameEngineTests
engine.State.Players[0].RearGuards[3]!.DriveChecksOverride = 1; engine.State.Players[0].RearGuards[3]!.DriveChecksOverride = 1;
StackDeck(engine.State.Players[0], Gallatin); StackDeck(engine.State.Players[0], Gallatin);
engine.Execute(new AttackCommand(3, 0)); engine.Execute(new AttackCommand(3, 4));
engine.Execute(new PassGuardCommand()); engine.Execute(new PassGuardCommand());
Assert.Single(engine.Events.OfType<DriveCheckedEvent>()); Assert.Single(engine.Events.OfType<DriveCheckedEvent>());
@ -645,12 +649,12 @@ public class GameEngineTests
var engine = ReadyToAttackRearGuard(); var engine = ReadyToAttackRearGuard();
Give(engine, 1, Barcgal); Give(engine, 1, Barcgal);
Assert.True(engine.Execute(new AttackCommand(3, 0)).Accepted); Assert.True(engine.Execute(new AttackCommand(3, 4)).Accepted);
Assert.True(engine.Execute(new GuardCommand(Barcgal)).Accepted); Assert.True(engine.Execute(new GuardCommand(Barcgal)).Accepted);
Assert.True(engine.Execute(new PassGuardCommand()).Accepted); Assert.True(engine.Execute(new PassGuardCommand()).Accepted);
var defender = engine.State.Players[1]; var defender = engine.State.Players[1];
Assert.NotNull(defender.RearGuards[0]); Assert.NotNull(defender.RearGuards[4]);
Assert.Contains(defender.Drop, c => c.PrintingId == Barcgal); Assert.Contains(defender.Drop, c => c.PrintingId == Barcgal);
Assert.DoesNotContain(engine.Events, e => e is UnitRetiredEvent); Assert.DoesNotContain(engine.Events, e => e is UnitRetiredEvent);
} }
@ -1182,10 +1186,10 @@ public class GameEngineTests
"test", "test",
ModifierDuration.UntilEndOfTurn); ModifierDuration.UntilEndOfTurn);
engine.Execute(new AttackCommand(3, 0)); engine.Execute(new AttackCommand(3, 4));
engine.Execute(new PassGuardCommand()); engine.Execute(new PassGuardCommand());
Assert.Null(engine.State.Players[1].RearGuards[0]); Assert.Null(engine.State.Players[1].RearGuards[4]);
Assert.Equal(2000, attacker.Modifiers.Where(m => m.EffectId == "hit").Sum(m => m.Power)); Assert.Equal(2000, attacker.Modifiers.Where(m => m.EffectId == "hit").Sum(m => m.Power));
} }
@ -1275,13 +1279,13 @@ public class GameEngineTests
engine.Execute(new EnterBattleCommand()); engine.Execute(new EnterBattleCommand());
var opponent = engine.State.Players[1]; var opponent = engine.State.Players[1];
opponent.RearGuards[0] = MakeCard(Marron); opponent.RearGuards[4] = MakeCard(Marron);
Assert.True(engine.Execute(new AttackCommand(-1, 0)).Accepted); Assert.True(engine.Execute(new AttackCommand(-1, 4)).Accepted);
Assert.True(engine.Execute(new PassGuardCommand()).Accepted); Assert.True(engine.Execute(new PassGuardCommand()).Accepted);
Assert.False(overlord.Rested); Assert.False(overlord.Rested);
Assert.Null(opponent.RearGuards[0]); Assert.Null(opponent.RearGuards[4]);
engine.Execute(new EndTurnCommand()); engine.Execute(new EndTurnCommand());
Assert.Null(overlord.DriveChecksOverride); Assert.Null(overlord.DriveChecksOverride);
@ -1581,6 +1585,79 @@ public class GameEngineTests
joka.Modifiers.Where(m => m.EffectId == "opponent-retire").Sum(m => m.Power)); joka.Modifiers.Where(m => m.EffectId == "opponent-retire").Sum(m => m.Power));
} }
[Fact]
public void Rear_Guard_In_Back_Row_Cannot_Be_Attacked_Normally()
{
var engine = ReadyToAttackRearGuard();
engine.State.Players[1].RearGuards[0] = MakeCard(Marron);
Assert.False(engine.Execute(new AttackCommand(3, 0)).Accepted);
}
[Fact]
public void Tejas_Attacks_Back_Row_In_Its_Column()
{
var engine = ReadyToAttackRearGuard();
var tejas = MakeCard(Tejas);
engine.State.Players[0].RearGuards[3] = tejas;
tejas.Rested = false;
var defender = engine.State.Players[1];
defender.RearGuards[0] = MakeCard(Barcgal);
Assert.True(engine.Execute(new AttackCommand(3, 0)).Accepted);
Assert.True(engine.Execute(new PassGuardCommand()).Accepted);
Assert.Null(defender.RearGuards[0]);
}
[Fact]
public void Yaksha_Rides_From_Hand_When_Opponent_RearGuard_Is_Retired()
{
var engine = InMainPhase();
var oldVanguard = MakeCard(Gallatin);
engine.State.Players[0].Vanguard = oldVanguard;
var yaksha = MakeCard(Yaksha);
engine.State.Players[0].Hand.Add(yaksha);
engine.State.Players[1].RearGuards[0] = MakeCard(Marron);
engine.RetireRearGuard(1, 0);
Assert.NotNull(engine.State.PendingAbility);
Assert.True(engine.Execute(new ResolveAbilityCommand(true)).Accepted);
Assert.Same(yaksha, engine.State.Players[0].Vanguard);
Assert.DoesNotContain(engine.State.Players[0].Hand, c => c.Id == yaksha.Id);
Assert.Contains(engine.State.Players[0].Soul, c => c.Id == oldVanguard.Id);
}
[Fact]
public void Replay_Restores_The_Match_And_Event_Hash()
{
var engine = NewEngine(777);
var recorder = new MatchRecorder(engine, BuildDeck(), BuildDeck());
engine.Start();
var random = new Random(777);
for (var step = 0; step < 3000 && !engine.State.IsFinished; step++)
{
var legal = engine.LegalCommands();
recorder.Execute(legal[random.Next(legal.Count)]);
}
var record = recorder.ToRecord();
var json = JsonSerializer.Serialize(record);
var restored = JsonSerializer.Deserialize<MatchRecord>(json);
Assert.NotNull(restored);
ReplayRunner.Verify(_catalog, restored);
Assert.Equal(record.EventHash, restored.EventHash);
}
[Fact] [Fact]
public void ResolveTrigger_Rejects_When_Nothing_Is_Pending() public void ResolveTrigger_Rejects_When_Nothing_Is_Pending()
{ {
@ -1777,8 +1854,8 @@ public class GameEngineTests
engine.State.Players[0].RearGuards[3]!.Rested = false; engine.State.Players[0].RearGuards[3]!.Rested = false;
var defender = engine.State.Players[1]; var defender = engine.State.Players[1];
defender.RearGuards[0] = TakeFromDeck(defender, Marron); defender.RearGuards[4] = TakeFromDeck(defender, Marron); // цель в переднем ряду
defender.RearGuards[3] = TakeFromDeck(defender, Gallatin); defender.RearGuards[3] = TakeFromDeck(defender, Gallatin); // перехватчик
engine.Execute(new EnterBattleCommand()); engine.Execute(new EnterBattleCommand());
return engine; return engine;