using Vanguard.Rules.Cards; using Vanguard.Rules.Decks; using Vanguard.Rules.Engine; using Vanguard.Rules.Replay; using Vanguard.Shared.Projection; using Vanguard.Shared.Protocol; using Xunit; namespace Vanguard.Rules.Tests; public class ProtocolTests { private const string Conroe = "BT01/016EN"; private const string Marron = "BT01/042EN"; private static readonly ClockView Clock = new() { DecisionPlayer = 0, TurnDeadlineMs = 1_760_000_090_000, Bank0Ms = ProtocolConstants.TurnBankSeconds * 1000L, Bank1Ms = ProtocolConstants.TurnBankSeconds * 1000L, IsRunning = true, ServerTimeMs = 1_760_000_000_000, }; // ---------- сериализация ---------- [Fact] public void Client_Frames_Survive_MessagePack_RoundTrip() { var frames = new ClientFrame[] { new ClientHello { ProtocolVersion = ProtocolConstants.Version, Ticket = "ticket-1", ClientBuild = "0.1.0", ResumeFromSeq = 42, }, new ClientIntent { IntentId = "intent-1", BasedOnSeq = 42, Intent = new CallIntent { PrintingId = Marron, Slot = 3 }, }, new ClientResync { FromSeq = 43, FullSnapshot = true }, new ClientPing { Nonce = 9, ClientTimeMs = 1_760_000_000_000 }, new ClientPong { Nonce = 10 }, new ClientLeave { Reason = "exit" }, }; foreach (var frame in frames) { var bytes = ProtocolSerializer.Encode(frame); var decoded = ProtocolSerializer.DecodeClient(bytes); Assert.Equal(frame.GetType(), decoded.GetType()); } var hello = (ClientHello)ProtocolSerializer.DecodeClient( ProtocolSerializer.Encode(frames[0])); Assert.Equal("ticket-1", hello.Ticket); Assert.Equal(42, hello.ResumeFromSeq); var intent = (ClientIntent)ProtocolSerializer.DecodeClient( ProtocolSerializer.Encode(frames[1])); var call = Assert.IsType(intent.Intent); Assert.Equal(Marron, call.PrintingId); Assert.Equal(3, call.Slot); Assert.Equal("intent-1", intent.IntentId); } [Fact] public void Server_Frames_With_Nested_Unions_Survive_MessagePack_RoundTrip() { var view = new MatchView { MatchId = "match-1", EventSeq = 7, You = 0, ActivePlayer = 0, TurnNumber = 2, Phase = GamePhase.Main, Step = GameStep.None, Winner = -1, Clock = Clock, Players = { new PlayerView { Index = 0, Name = "Player 1", HandCount = 2, DeckCount = 30, Hand = { new CardView { InstanceId = 1, PrintingId = Marron, Power = 8000 } }, RearGuards = new CardView?[] { new() { InstanceId = 2, PrintingId = Marron }, null, null, null, null, }, }, new PlayerView { Index = 1, Name = "Player 2", HandCount = 3, DeckCount = 29 }, }, LegalIntents = { new ConcedeIntent(), new AttackIntent { AttackerSlot = -1, TargetSlot = -1, BoosterSlot = -1 }, }, }; var frames = new ServerFrame[] { new ServerHello { ProtocolVersion = ProtocolConstants.Version, MatchId = "match-1", PlayerIndex = 0, Resumed = true, EventSeq = 7, ServerTimeMs = Clock.ServerTimeMs, }, new ServerSnapshot { View = view }, new ServerEvents { FromSeq = 4, ToSeq = 5, Events = { new EventEnvelope { Seq = 4, Event = new UnitCalledView { Player = 0, Slot = 3, PrintingId = Marron }, }, new EventEnvelope { Seq = 5, Event = new DriveCheckedView { Player = 0, PrintingId = "BT01/056EN", Trigger = TriggerKind.Critical, }, }, }, }, new ServerIntentResult { IntentId = "intent-1", Accepted = false, Code = IntentRejectCode.Illegal, Reason = "Нет цели.", EventSeq = 7, }, new ServerClock { Clock = Clock, EventSeq = 7 }, new ServerPing { Nonce = 1, ServerTimeMs = Clock.ServerTimeMs }, new ServerPong { Nonce = 2, ServerTimeMs = Clock.ServerTimeMs }, new ServerError { Code = ProtocolErrorCode.InvalidTicket, Message = "bad ticket", Fatal = true, EventSeq = 7, }, }; foreach (var frame in frames) { var bytes = ProtocolSerializer.Encode(frame); var decoded = ProtocolSerializer.DecodeServer(bytes); Assert.Equal(frame.GetType(), decoded.GetType()); } var snapshot = (ServerSnapshot)ProtocolSerializer.DecodeServer( ProtocolSerializer.Encode(frames[1])); Assert.Equal("match-1", snapshot.View.MatchId); Assert.Equal(7, snapshot.View.EventSeq); Assert.Equal(2, snapshot.View.Players.Count); Assert.Single(snapshot.View.Players[0].Hand); Assert.Null(snapshot.View.Players[0].RearGuards[1]); Assert.IsType(snapshot.View.LegalIntents[1]); var events = (ServerEvents)ProtocolSerializer.DecodeServer(ProtocolSerializer.Encode(frames[2])); Assert.Equal(4, events.FromSeq); Assert.Equal(5, events.ToSeq); Assert.IsType(events.Events[0].Event); Assert.IsType(events.Events[1].Event); } [Fact] public void Decode_Rejects_Empty_And_Oversized_Frames() { Assert.Throws(() => ProtocolSerializer.DecodeClient(ReadOnlyMemory.Empty)); var oversized = new byte[ProtocolConstants.MaxFrameBytes + 1]; Assert.Throws(() => ProtocolSerializer.DecodeClient(oversized)); var garbage = new byte[] { 0xC1, 0xFF, 0x00 }; Assert.Throws(() => ProtocolSerializer.DecodeClient(garbage)); } // ---------- интенты ---------- [Fact] public void Every_Game_Command_Maps_To_Intent_And_Back() { var commands = new GameCommand[] { new MulliganCommand(new[] { 0, 2 }), new FinishMulliganCommand(), new RideCommand(Marron), new SkipRideCommand(), new CallCommand(Marron, 3), new MoveCommand(3, 0), new EnterBattleCommand(), new AttackCommand(3, -1, 0), new GuardCommand("BT01/011EN", "BT01/042EN"), new InterceptCommand(3), new PassGuardCommand(), new ResolveTriggerCommand(-1, 3), new SelectTriggerCommand(1), new ResolveAbilityCommand(true), new ChooseCommand(2), new ActivateAbilityCommand(12, Marron), new EndTurnCommand(), }; foreach (var command in commands) { var intent = IntentMapping.FromCommand(command); var restored = IntentMapping.ToCommand(intent); Assert.NotNull(restored); Assert.Equal(CommandCodec.Encode(command), CommandCodec.Encode(restored!)); } } [Fact] public void Concede_Has_No_Game_Command() { Assert.Null(IntentMapping.ToCommand(new ConcedeIntent())); } // ---------- проекция ---------- [Fact] public void Projection_Hides_Opponent_Hand_And_Both_Decks() { var engine = StartedEngine(); var view = MatchProjection.ForViewer(engine, 0, "match-1", engine.Events.Count, Clock); Assert.Equal(engine.State.Players[0].Hand.Count, view.Players[0].Hand.Count); Assert.Equal(engine.State.Players[1].Hand.Count, view.Players[1].HandCount); Assert.Empty(view.Players[1].Hand); Assert.Equal(engine.State.Players[1].Deck.Count, view.Players[1].DeckCount); Assert.Equal(engine.State.Players[0].Deck.Count, view.Players[0].DeckCount); // ни одного printingId из чужой руки var ownIds = view.Players[0].Hand.Select(c => c.PrintingId).ToHashSet(); var opponentHandIds = engine.State.Players[1].Hand.Select(c => c.PrintingId).ToHashSet(); Assert.DoesNotContain(view.Players[1].Hand, card => opponentHandIds.Contains(card.PrintingId)); Assert.True(ownIds.Count > 0); } [Fact] public void Projection_Shows_FaceDown_Damage_Only_As_Count() { var engine = StartedEngine(); var damage = engine.State.Players[0].Damage; var hidden = TakeFromDeck(engine.State.Players[0]); hidden.FaceUp = false; damage.Add(hidden); var visible = TakeFromDeck(engine.State.Players[0]); visible.FaceUp = true; damage.Add(visible); var ownView = MatchProjection.ForViewer(engine, 0, "match-1", engine.Events.Count, Clock); var opponentView = MatchProjection.ForViewer(engine, 1, "match-1", engine.Events.Count, Clock); Assert.Equal(1, ownView.Players[0].FaceDownDamage); Assert.Single(ownView.Players[0].FaceUpDamage); Assert.Equal(visible.PrintingId, ownView.Players[0].FaceUpDamage[0].PrintingId); Assert.Equal(1, opponentView.Players[0].FaceDownDamage); Assert.Single(opponentView.Players[0].FaceUpDamage); Assert.Equal(visible.PrintingId, opponentView.Players[0].FaceUpDamage[0].PrintingId); } [Fact] public void Projection_Does_Not_Contain_Seed_Or_Rng() { var names = typeof(MatchView).Assembly.GetTypes() .Where(type => type.Namespace == "Vanguard.Shared.Protocol") .SelectMany(type => type.GetProperties()) .Select(property => property.Name) .ToList(); Assert.DoesNotContain(names, name => name.Contains("Seed", StringComparison.Ordinal)); Assert.DoesNotContain(names, name => name.Contains("Rng", StringComparison.Ordinal)); } [Fact] public void Legal_Intents_Belong_Only_To_The_Deciding_Player() { var engine = StartedEngine(); Give(engine, 0, Marron); engine.Execute(new RideCommand(Marron)); engine.Execute(new EnterBattleCommand()); Assert.True(engine.Execute(new AttackCommand(-1, -1)).Accepted); Assert.Equal(1, engine.State.ActivePlayer); Assert.Equal(Step.Guard, engine.State.Step); var attackerView = MatchProjection.ForViewer(engine, 0, "match-1", engine.Events.Count, Clock); var defenderView = MatchProjection.ForViewer(engine, 1, "match-1", engine.Events.Count, Clock); // наблюдатель может только сдаться Assert.Single(attackerView.LegalIntents); Assert.IsType(attackerView.LegalIntents[0]); // обороняющийся видит свои варианты защиты, но не чужую руку Assert.Contains(defenderView.LegalIntents, intent => intent is PassGuardIntent); Assert.Empty(attackerView.Players[1].Hand); Assert.Equal(engine.State.Players[1].Hand.Count, attackerView.Players[1].HandCount); } // ---------- события ---------- [Fact] public void Search_Events_Are_Hidden_From_Opponent() { var searched = new CardSearchedEvent(0, "BT01/001EN"); var failed = new SearchFailedEvent(0, " grade 3"); Assert.IsType(EventProjection.ForViewer(searched, 0)); Assert.Null(EventProjection.ForViewer(searched, 1)); Assert.IsType(EventProjection.ForViewer(failed, 0)); Assert.Null(EventProjection.ForViewer(failed, 1)); } [Fact] public void Open_Events_Are_Visible_To_Both() { var called = new UnitCalledEvent(0, 3, Marron); Assert.IsType(EventProjection.ForViewer(called, 0)); Assert.IsType(EventProjection.ForViewer(called, 1)); } [Fact] public void Projected_Event_Range_Keeps_Global_Sequence_Numbers() { var events = new GameEvent[] { new GameStartedEvent(0), new CardSearchedEvent(0, "BT01/001EN"), // скрыто от игрока 1 new CardDrawnEvent(0), }; var forOpponent = EventProjection.ProjectRange(events, 1, fromSeq: 1); Assert.Equal(2, forOpponent.Count); Assert.Equal(1, forOpponent[0].Seq); Assert.Equal(3, forOpponent[1].Seq); Assert.IsType(forOpponent[0].Event); Assert.IsType(forOpponent[1].Event); var fromSecond = EventProjection.ProjectRange(events, 0, fromSeq: 2); Assert.Equal(2, fromSecond.Count); Assert.Equal(2, fromSecond[0].Seq); Assert.IsType(fromSecond[0].Event); } // ---------- обвязка ---------- private static GameEngine StartedEngine() { var engine = NewEngine(); engine.Start(firstPlayer: 0); engine.Execute(new FinishMulliganCommand()); engine.Execute(new FinishMulliganCommand()); // фиксируем стартовый Авангард, чтобы Barcgal не срабатывал var player = engine.State.Players[0]; var card = TakeFromDeck(player, Conroe); player.Vanguard = card; return engine; } private static GameEngine NewEngine() => new( TestCatalog.Instance, 7, BuildDeck(), BuildDeck(), enforceFirstTurnAttackRule: false, requireLegalDeckTriggers: false); private static List BuildDeck() { var deck = new List(); // гарантируем карты, которые используются в сценариях foreach (var printingId in new[] { Conroe, Marron }) deck.AddRange(Enumerable.Repeat(printingId, 4)); var seeded = new[] { Conroe, Marron } .Select(id => TestCatalog.Instance.CardOf(id).NameId) .ToHashSet(StringComparer.Ordinal); foreach (var group in TestCatalog.Instance.Printings .Where(p => !p.IsParallel) .GroupBy(p => p.NameId) .OrderBy(g => g.Key, StringComparer.Ordinal)) { if (deck.Count >= 50) break; if (seeded.Contains(group.Key)) continue; var card = TestCatalog.Instance.Card(group.Key); if (card.IsSentinel || card.Trigger == TriggerType.Heal) continue; var printingId = group.First().PrintingId; for (var i = 0; i < 4 && deck.Count < 50; i++) deck.Add(printingId); } Assert.Equal(50, deck.Count); return deck; } private static void Give(GameEngine engine, int player, string printingId) { var hand = engine.State.Players[player].Hand; hand.Add(TakeFromDeck(engine.State.Players[player], printingId)); } private static CardInstance TakeFromDeck(PlayerState player, string printingId) { var index = player.Deck.FindIndex(c => c.PrintingId == printingId); Assert.True(index >= 0, $"В колоде нет {printingId}"); var card = player.Deck[index]; player.Deck.RemoveAt(index); return card; } private static CardInstance TakeFromDeck(PlayerState player) { Assert.NotEmpty(player.Deck); var card = player.Deck[0]; player.Deck.RemoveAt(0); return card; } }