VanguardReMaster/server/tests/Vanguard.Rules.Tests/ProtocolTests.cs

456 lines
16 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using Vanguard.Rules.Cards;
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<CallIntent>(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<AttackIntent>(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<UnitCalledView>(events.Events[0].Event);
Assert.IsType<DriveCheckedView>(events.Events[1].Event);
}
[Fact]
public void Decode_Rejects_Empty_And_Oversized_Frames()
{
Assert.Throws<ProtocolFormatException>(() => ProtocolSerializer.DecodeClient(ReadOnlyMemory<byte>.Empty));
var oversized = new byte[ProtocolConstants.MaxFrameBytes + 1];
Assert.Throws<ProtocolFormatException>(() => ProtocolSerializer.DecodeClient(oversized));
var garbage = new byte[] { 0xC1, 0xFF, 0x00 };
Assert.Throws<ProtocolFormatException>(() => 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<ConcedeIntent>(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, "<Royal Paladin> grade 3");
Assert.IsType<CardSearchedView>(EventProjection.ForViewer(searched, 0));
Assert.Null(EventProjection.ForViewer(searched, 1));
Assert.IsType<SearchFailedView>(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<UnitCalledView>(EventProjection.ForViewer(called, 0));
Assert.IsType<UnitCalledView>(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<GameStartedView>(forOpponent[0].Event);
Assert.IsType<CardDrawnView>(forOpponent[1].Event);
var fromSecond = EventProjection.ProjectRange(events, 0, fromSeq: 2);
Assert.Equal(2, fromSecond.Count);
Assert.Equal(2, fromSecond[0].Seq);
Assert.IsType<CardSearchedView>(fromSecond[0].Event);
}
// ---------- обвязка ----------
private static GameEngine StartedEngine()
{
var engine = NewEngine();
engine.Start();
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());
private static List<string> BuildDeck()
{
var deck = new List<string>();
// гарантируем карты, которые используются в сценариях
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;
}
}