465 lines
16 KiB
C#
465 lines
16 KiB
C#
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<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(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<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;
|
||
}
|
||
}
|