diff --git a/IMPLEMENTATION_CHECKLIST.md b/IMPLEMENTATION_CHECKLIST.md
index c8d801e..6f580d4 100644
--- a/IMPLEMENTATION_CHECKLIST.md
+++ b/IMPLEMENTATION_CHECKLIST.md
@@ -133,13 +133,14 @@
- [x] Список легальных интентов на каждый момент (для UI и античита).
— `GameEngine.LegalCommands()` покрывает фазы, выборы, триггеры и активируемые способности.
- [~] Детерминированный RNG: один поток на партию, состояние в `GameState`.
-- [~] Журнал событий + seed; сериализуемый реплей ещё не сделан.
+- [x] Журнал событий + seed; сериализуемый реплей.
+ — `MatchRecord`, `MatchRecorder`, `CommandCodec`, `ReplayRunner`, `EventHasher`; запись переживает JSON и воспроизводится бит-в-бит по хэшу событий.
- [~] Защита от бесконечных циклов и дедлоков.
— 50 партий случайного бота в тестах; если команд нет, тест падает. Глобального лимита шагов в движке нет.
- [x] Инварианты партии: 50 карт в зонах каждого игрока, уникальные `instanceId`, урон ≤ 6.
- [x] Карты «в пути» между зонами (`GameState.InTransit`) — например, найденная карта ждёт выбора круга.
-**Сделано:** каркас движка (`Engine/`), все фазы, бой, способности, 58 карт со скриптами, `LegalCommands`, случайный бот на 50 партиях. Всего 112 тестов.
+**Сделано:** каркас движка (`Engine/`), все фазы, бой, способности, 66/66 карт, `LegalCommands`, случайный бот на 50 партиях, реплей с хэшем событий. Всего 116 тестов.
**Готово, когда:** два бота случайными легальными интентами доигрывают 1000 партий без падений, инварианты (сумма карт, счётчики ≥ 0) держатся, реплей воспроизводится бит-в-бит.
@@ -155,12 +156,12 @@
- [x] RU/EN тексты для всех 94.
- [x] Сила/щит для 14 карт, которых нет в BT01.
- [ ] Дашборд покрытия: `scripted / tested / total`.
- — сейчас заскриптовано **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).
+ — **66 из 66** карт с текстом закрыты: 59 скриптов + Tejas (флаг атаки заднего ряда) + 6 карт Sentinel/Heal, которые обрабатывает движок. Оставшихся карт нет.
+- [x] Триггеры `OnDriveCheck` (Asura Kaiser, Brigitte, Tristan, Goku), `OnBoostedHit` (Dreadmaster, Aermo), `OnGuard` (Hagakure), `OnRetireFromRC` (Blightops), `OnOpponentRetire` (Joka, Rakshasa, Yaksha).
+- [x] Способности из руки (Gancelot, Yaksha) и из сброса (Monster Frank).
- [x] Возврат карты с поля в колоду: после боя (Juggernaut, Brakki) и в конце хода (Lozenge, Battleraizer).
- [x] Триггер `OnLeaveGuardian` (Rock the Wall, Redshoe Milly).
-- [ ] Wyvern Strike, Tejas: атака заднего ряда не выделена — движок разрешает атаковать любой круг Арьергарда.
+- [x] Wyvern Strike, Tejas: атака заднего ряда только из своей колонки; обычные карты атакуют только передний ряд.
**Готово, когда:** 94/94 заскриптованы, 94/94 покрыты тестом, приёмка пройдена по чеклисту карты.
diff --git a/content/sets/BT01.yaml b/content/sets/BT01.yaml
index 088f44e..c0708ae 100644
--- a/content/sets/BT01.yaml
+++ b/content/sets/BT01.yaml
@@ -231,6 +231,7 @@ cards:
critical: 1
trigger: None
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."
- nameId: embodiment-of-spear-tahr
nameEn: "Embodiment of Spear, Tahr"
diff --git a/server/src/Vanguard.Content/CardSetFile.cs b/server/src/Vanguard.Content/CardSetFile.cs
index adbaf7a..3b438eb 100644
--- a/server/src/Vanguard.Content/CardSetFile.cs
+++ b/server/src/Vanguard.Content/CardSetFile.cs
@@ -24,6 +24,7 @@ public sealed class CardEntry
public Clan Clan { get; set; }
public bool Sentinel { get; set; }
public bool Restraint { get; set; }
+ public bool SnipeBackRow { get; set; }
/// null — по умолчанию (0 и 1 ранг поддерживают).
public bool? Boost { get; set; }
@@ -44,6 +45,7 @@ public sealed class CardEntry
Clan = Clan,
IsSentinel = Sentinel,
IsRestraint = Restraint,
+ CanSnipeBackRow = SnipeBackRow,
BoostOverride = Boost,
TextEn = TextEn,
TextRu = TextRu,
diff --git a/server/src/Vanguard.Rules/Cards/CardDefinition.cs b/server/src/Vanguard.Rules/Cards/CardDefinition.cs
index 86e2420..9c5f6b7 100644
--- a/server/src/Vanguard.Rules/Cards/CardDefinition.cs
+++ b/server/src/Vanguard.Rules/Cards/CardDefinition.cs
@@ -40,6 +40,11 @@ public sealed record CardDefinition
/// Печатный перехват: по умолчанию у карт 2 ранга.
public bool HasIntercept => InterceptOverride ?? Grade == 2;
+ ///
+ /// Может атаковать Арьергард в заднем ряду той же колонки (Wyvern Strike, Tejas).
+ ///
+ public bool CanSnipeBackRow { get; init; }
+
///
/// Печатное число проверок при атаке из круга Авангарда:
/// 0–2 ранг — одна, 3 ранг и выше — двойная.
diff --git a/server/src/Vanguard.Rules/Engine/Abilities/CardScripts.cs b/server/src/Vanguard.Rules/Engine/Abilities/CardScripts.cs
index 06c383b..ac16cdf 100644
--- a/server/src/Vanguard.Rules/Engine/Abilities/CardScripts.cs
+++ b/server/src/Vanguard.Rules/Engine/Abilities/CardScripts.cs
@@ -66,6 +66,7 @@ public static class CardScripts
["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"),
+ ["demonic-dragon-berserker-yaksha"] = new YakshaScript(),
};
}
diff --git a/server/src/Vanguard.Rules/Engine/Abilities/CardScriptsTD.cs b/server/src/Vanguard.Rules/Engine/Abilities/CardScriptsTD.cs
index 5c118ba..53f753f 100644
--- a/server/src/Vanguard.Rules/Engine/Abilities/CardScriptsTD.cs
+++ b/server/src/Vanguard.Rules/Engine/Abilities/CardScriptsTD.cs
@@ -2,6 +2,32 @@ using Vanguard.Rules.Cards;
namespace Vanguard.Rules.Engine.Abilities;
+///
+/// Demonic Dragon Berserker, Yaksha: в Основную фазу, когда Арьергард противника
+/// уходит в сброс, при Авангарде 2 ранга можно показать эту карту и совершить райд.
+///
+public sealed class YakshaScript : CardScript
+{
+ public override string NameId => "demonic-dragon-berserker-yaksha";
+
+ public override IEnumerable 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),
+ };
+ }
+ }
+}
+
///
/// «Когда проверка открывает карту 3 ранга нужного клана, эта карта получает
/// +5000 до конца боя»: Crimson Butterfly, Brigitte и Knight of the Harp, Tristan.
diff --git a/server/src/Vanguard.Rules/Engine/GameEngine.cs b/server/src/Vanguard.Rules/Engine/GameEngine.cs
index 64c50cf..e9d8c3e 100644
--- a/server/src/Vanguard.Rules/Engine/GameEngine.cs
+++ b/server/src/Vanguard.Rules/Engine/GameEngine.cs
@@ -204,6 +204,23 @@ public sealed class GameEngine
.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)
return;
@@ -1106,6 +1123,18 @@ public sealed class GameEngine
RideUnit(card, player);
}
+ /// Райд картой из руки по эффекту (Demonic Dragon Berserker, Yaksha).
+ 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)
{
var p = State.Players[player];
@@ -1387,7 +1416,13 @@ public sealed class GameEngine
continue;
var targets = new List { -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)
commands.Add(new AttackCommand(attackerSlot, target));
@@ -1774,6 +1809,17 @@ public sealed class GameEngine
if (target is null)
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;
if (boosterSlot >= 0)
{
diff --git a/server/src/Vanguard.Rules/Engine/GameState.cs b/server/src/Vanguard.Rules/Engine/GameState.cs
index 6809890..ee9bb3f 100644
--- a/server/src/Vanguard.Rules/Engine/GameState.cs
+++ b/server/src/Vanguard.Rules/Engine/GameState.cs
@@ -33,6 +33,7 @@ public sealed class GameState
public GameState(CardCatalog catalog, ulong seed, string firstPlayerName, string secondPlayerName)
{
Catalog = catalog;
+ Seed = seed;
RngState = seed;
Players[0] = new PlayerState(firstPlayerName);
Players[1] = new PlayerState(secondPlayerName);
@@ -47,6 +48,9 @@ public sealed class GameState
public int TurnNumber { get; internal set; }
public ulong RngState { get; internal set; }
+
+ /// Исходное зерно партии: нужно для записи и воспроизведения реплея.
+ public ulong Seed { get; }
public int NextInstanceId { get; internal set; } = 1;
public BattleState? Battle { get; internal set; }
diff --git a/server/src/Vanguard.Rules/Replay/CommandCodec.cs b/server/src/Vanguard.Rules/Replay/CommandCodec.cs
new file mode 100644
index 0000000..884eb22
--- /dev/null
+++ b/server/src/Vanguard.Rules/Replay/CommandCodec.cs
@@ -0,0 +1,72 @@
+using System.Globalization;
+using Vanguard.Rules.Engine;
+
+namespace Vanguard.Rules.Replay;
+
+///
+/// Компактное текстовое представление команд. Формат стабильный и годится
+/// как для реплея, так и для журнала матча в БД.
+///
+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()
+ : 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;
+}
diff --git a/server/src/Vanguard.Rules/Replay/EventHasher.cs b/server/src/Vanguard.Rules/Replay/EventHasher.cs
new file mode 100644
index 0000000..e2da59e
--- /dev/null
+++ b/server/src/Vanguard.Rules/Replay/EventHasher.cs
@@ -0,0 +1,32 @@
+using Vanguard.Rules.Engine;
+
+namespace Vanguard.Rules.Replay;
+
+/// FNV-1a по тексту событий. Не криптография, а быстрая проверка расхождений.
+public static class EventHasher
+{
+ private const ulong Offset = 14695981039346656037UL;
+ private const ulong Prime = 1099511628211UL;
+
+ public static string Hash(IEnumerable 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");
+ }
+ }
+}
diff --git a/server/src/Vanguard.Rules/Replay/MatchRecord.cs b/server/src/Vanguard.Rules/Replay/MatchRecord.cs
new file mode 100644
index 0000000..b38c6d0
--- /dev/null
+++ b/server/src/Vanguard.Rules/Replay/MatchRecord.cs
@@ -0,0 +1,13 @@
+namespace Vanguard.Rules.Replay;
+
+/// Запись матча: зерно, колоды, команды и хэш итоговых событий.
+public sealed class MatchRecord
+{
+ public ulong Seed { get; init; }
+ public List Deck0 { get; init; } = new();
+ public List Deck1 { get; init; } = new();
+ public List Commands { get; init; } = new();
+
+ /// Хэш событий всей партии: реплей должен дать точно такой же.
+ public string EventHash { get; init; } = string.Empty;
+}
diff --git a/server/src/Vanguard.Rules/Replay/MatchRecorder.cs b/server/src/Vanguard.Rules/Replay/MatchRecorder.cs
new file mode 100644
index 0000000..6dc522b
--- /dev/null
+++ b/server/src/Vanguard.Rules/Replay/MatchRecorder.cs
@@ -0,0 +1,38 @@
+using Vanguard.Rules.Engine;
+
+namespace Vanguard.Rules.Replay;
+
+/// Играет матч и записывает только принятые команды.
+public sealed class MatchRecorder
+{
+ private readonly GameEngine _engine;
+ private readonly List _commands = new();
+
+ public MatchRecorder(GameEngine engine, IReadOnlyList deck0, IReadOnlyList deck1)
+ {
+ _engine = engine;
+ Deck0 = deck0.ToList();
+ Deck1 = deck1.ToList();
+ }
+
+ public List Deck0 { get; }
+ public List 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),
+ };
+}
diff --git a/server/src/Vanguard.Rules/Replay/ReplayRunner.cs b/server/src/Vanguard.Rules/Replay/ReplayRunner.cs
new file mode 100644
index 0000000..efed369
--- /dev/null
+++ b/server/src/Vanguard.Rules/Replay/ReplayRunner.cs
@@ -0,0 +1,37 @@
+using Vanguard.Rules.Cards;
+using Vanguard.Rules.Engine;
+
+namespace Vanguard.Rules.Replay;
+
+/// Воспроизводит запись матча и проверяет, что события совпали.
+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}");
+ }
+}
diff --git a/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs b/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs
index 39ed9c9..83307de 100644
--- a/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs
+++ b/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs
@@ -1,6 +1,8 @@
+using System.Text.Json;
using Vanguard.Rules.Cards;
using Vanguard.Rules.Engine;
using Vanguard.Rules.Engine.Abilities;
+using Vanguard.Rules.Replay;
using Xunit;
namespace Vanguard.Rules.Tests;
@@ -50,6 +52,8 @@ public class GameEngineTests
private const string Unicorn = "TD01/010EN";
private const string Goku = "TD02/002EN";
private const string Joka = "TD02/010EN";
+ private const string Tejas = "BT01/023EN";
+ private const string Yaksha = "TD02/003EN";
[Fact]
public void Start_Places_Starting_Vanguard_And_Draws_Five()
@@ -353,12 +357,12 @@ public class GameEngineTests
{
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 PassGuardCommand()).Accepted);
var defender = engine.State.Players[1];
- Assert.NotNull(defender.RearGuards[0]); // цель выжила
+ Assert.NotNull(defender.RearGuards[4]); // цель выжила
Assert.Null(defender.RearGuards[3]); // перехватчик покинул круг
Assert.Contains(defender.Drop, c => c.PrintingId == Gallatin);
Assert.Contains(engine.Events, e => e is InterceptedEvent);
@@ -370,11 +374,11 @@ public class GameEngineTests
{
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);
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(engine.Events, e => e is UnitRetiredEvent);
}
@@ -386,7 +390,7 @@ public class GameEngineTests
var defender = engine.State.Players[1];
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);
}
@@ -397,7 +401,7 @@ public class GameEngineTests
var defender = engine.State.Players[1];
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);
}
@@ -411,7 +415,7 @@ public class GameEngineTests
unit.InterceptFromBackRow = true;
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);
}
@@ -444,8 +448,8 @@ public class GameEngineTests
{
var engine = ReadyToAttackRearGuard();
- Assert.True(engine.Execute(new AttackCommand(3, 0)).Accepted);
- Assert.False(engine.Execute(new InterceptCommand(0)).Accepted);
+ Assert.True(engine.Execute(new AttackCommand(3, 4)).Accepted);
+ Assert.False(engine.Execute(new InterceptCommand(4)).Accepted);
}
[Fact]
@@ -554,7 +558,7 @@ public class GameEngineTests
{
var engine = ReadyToAttackRearGuard();
- engine.Execute(new AttackCommand(3, 0));
+ engine.Execute(new AttackCommand(3, 4));
engine.Execute(new PassGuardCommand());
Assert.Empty(engine.Events.OfType());
@@ -567,7 +571,7 @@ public class GameEngineTests
engine.State.Players[0].RearGuards[3]!.DriveChecksOverride = 1;
StackDeck(engine.State.Players[0], Gallatin);
- engine.Execute(new AttackCommand(3, 0));
+ engine.Execute(new AttackCommand(3, 4));
engine.Execute(new PassGuardCommand());
Assert.Single(engine.Events.OfType());
@@ -645,12 +649,12 @@ public class GameEngineTests
var engine = ReadyToAttackRearGuard();
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 PassGuardCommand()).Accepted);
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.DoesNotContain(engine.Events, e => e is UnitRetiredEvent);
}
@@ -1182,10 +1186,10 @@ public class GameEngineTests
"test",
ModifierDuration.UntilEndOfTurn);
- engine.Execute(new AttackCommand(3, 0));
+ engine.Execute(new AttackCommand(3, 4));
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));
}
@@ -1275,13 +1279,13 @@ public class GameEngineTests
engine.Execute(new EnterBattleCommand());
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.False(overlord.Rested);
- Assert.Null(opponent.RearGuards[0]);
+ Assert.Null(opponent.RearGuards[4]);
engine.Execute(new EndTurnCommand());
Assert.Null(overlord.DriveChecksOverride);
@@ -1581,6 +1585,79 @@ public class GameEngineTests
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(json);
+
+ Assert.NotNull(restored);
+ ReplayRunner.Verify(_catalog, restored);
+ Assert.Equal(record.EventHash, restored.EventHash);
+ }
+
[Fact]
public void ResolveTrigger_Rejects_When_Nothing_Is_Pending()
{
@@ -1777,8 +1854,8 @@ public class GameEngineTests
engine.State.Players[0].RearGuards[3]!.Rested = false;
var defender = engine.State.Players[1];
- defender.RearGuards[0] = TakeFromDeck(defender, Marron);
- defender.RearGuards[3] = TakeFromDeck(defender, Gallatin);
+ defender.RearGuards[4] = TakeFromDeck(defender, Marron); // цель в переднем ряду
+ defender.RearGuards[3] = TakeFromDeck(defender, Gallatin); // перехватчик
engine.Execute(new EnterBattleCommand());
return engine;