From 66d52169fc94e67f486054dfc82841a8483d3e0e Mon Sep 17 00:00:00 2001 From: MpakobecEx Date: Wed, 21 Oct 2026 14:00:16 +0500 Subject: [PATCH] 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. --- IMPLEMENTATION_CHECKLIST.md | 13 +- content/sets/BT01.yaml | 1 + server/src/Vanguard.Content/CardSetFile.cs | 2 + .../Vanguard.Rules/Cards/CardDefinition.cs | 5 + .../Engine/Abilities/CardScripts.cs | 1 + .../Engine/Abilities/CardScriptsTD.cs | 26 ++++ .../src/Vanguard.Rules/Engine/GameEngine.cs | 48 ++++++- server/src/Vanguard.Rules/Engine/GameState.cs | 4 + .../src/Vanguard.Rules/Replay/CommandCodec.cs | 72 +++++++++++ .../src/Vanguard.Rules/Replay/EventHasher.cs | 32 +++++ .../src/Vanguard.Rules/Replay/MatchRecord.cs | 13 ++ .../Vanguard.Rules/Replay/MatchRecorder.cs | 38 ++++++ .../src/Vanguard.Rules/Replay/ReplayRunner.cs | 37 ++++++ .../Vanguard.Rules.Tests/GameEngineTests.cs | 117 +++++++++++++++--- 14 files changed, 382 insertions(+), 27 deletions(-) create mode 100644 server/src/Vanguard.Rules/Replay/CommandCodec.cs create mode 100644 server/src/Vanguard.Rules/Replay/EventHasher.cs create mode 100644 server/src/Vanguard.Rules/Replay/MatchRecord.cs create mode 100644 server/src/Vanguard.Rules/Replay/MatchRecorder.cs create mode 100644 server/src/Vanguard.Rules/Replay/ReplayRunner.cs 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;