using Microsoft.Extensions.Logging.Abstractions; using Vanguard.Rules.Engine; using Vanguard.Server.Configuration; using Vanguard.Server.Matches; using Vanguard.Server.Storage; using Vanguard.Shared.Protocol; namespace Vanguard.Server.Tests; public class MatchGrainTests { private static readonly DateTimeOffset Start = new(2026, 10, 21, 12, 0, 0, TimeSpan.Zero); [Fact] public async Task Attach_Sends_Hello_Snapshot_And_Hides_Opponent_Hand() { var time = new FakeTimeProvider(Start); var grain = NewGrain(time); var first = new FakeMatchConnection(); var second = new FakeMatchConnection(); Assert.True(await grain.AttachAsync(0, "p1", first, 0)); Assert.True(await grain.AttachAsync(1, "p2", second, 0)); var hello = first.All().Single(); Assert.Equal(0, hello.PlayerIndex); Assert.False(hello.Resumed); var snapshot = first.All().Single().View; Assert.Equal(5, snapshot.Players[0].Hand.Count); Assert.Equal(5, snapshot.Players[1].HandCount); Assert.Empty(snapshot.Players[1].Hand); var clock = second.Last().Clock; Assert.True(clock.IsRunning); Assert.Equal(0, clock.DecisionPlayer); } [Fact] public async Task Wrong_Player_Cannot_Take_Seat() { var grain = NewGrain(new FakeTimeProvider(Start)); Assert.False(await grain.AttachAsync(0, "intruder", new FakeMatchConnection(), 0)); Assert.False(await grain.AttachAsync(2, "p1", new FakeMatchConnection(), 0)); } [Fact] public async Task Duplicate_Intent_Is_Not_Applied_Twice() { var time = new FakeTimeProvider(Start); var grain = NewGrain(time); var first = await Attach(grain, 0, "p1"); var second = await Attach(grain, 1, "p2"); var intent = new ClientIntent { IntentId = "intent-1", BasedOnSeq = grain.Summary.EventSeq, Intent = new FinishMulliganIntent(), }; await grain.SubmitAsync(0, intent); var accepted = first.Last(); Assert.True(accepted.Accepted); Assert.False(accepted.Duplicate); var seqAfterFirst = grain.Summary.EventSeq; var eventsAfterFirst = first.All().Count; await grain.SubmitAsync(0, intent); var duplicate = first.Last(); Assert.True(duplicate.Accepted); Assert.True(duplicate.Duplicate); Assert.Equal(seqAfterFirst, grain.Summary.EventSeq); Assert.Equal(eventsAfterFirst, first.All().Count); Assert.True(second.All().Count == 0); } [Fact] public async Task Non_Active_Player_Is_Rejected() { var grain = NewGrain(new FakeTimeProvider(Start)); var first = await Attach(grain, 0, "p1"); var second = await Attach(grain, 1, "p2"); var seqBefore = grain.Summary.EventSeq; await grain.SubmitAsync(1, new ClientIntent { IntentId = "p2-1", BasedOnSeq = grain.Summary.EventSeq, Intent = new FinishMulliganIntent(), }); var result = second.Last(); Assert.False(result.Accepted); Assert.Equal(IntentRejectCode.NotYourTurn, result.Code); Assert.Equal(seqBefore, grain.Summary.EventSeq); } [Fact] public async Task Concede_Works_Regardless_Of_Turn_And_Finishes_Match() { var sink = new RecordingResultSink(); var grain = NewGrain(new FakeTimeProvider(Start), sink); var first = await Attach(grain, 0, "p1"); var second = await Attach(grain, 1, "p2"); // сейчас маллиган игрока 0 — сдаётся игрок 1, он не активен await grain.SubmitAsync(1, new ClientIntent { IntentId = "concede", BasedOnSeq = grain.Summary.EventSeq, Intent = new ConcedeIntent(), }); var result = second.Last(); Assert.True(result.Accepted); Assert.True(grain.Summary.IsFinished); Assert.Equal(0, grain.Summary.Winner); Assert.Contains(first.All().SelectMany(e => e.Events), e => e.Event is GameFinishedView { Winner: 0 }); var recorded = Assert.Single(sink.Results); Assert.Equal("p1", recorded.WinnerPlayerId); Assert.Equal(MatchFinishReason.Concede, recorded.FinishReason); var loserId = recorded.Player0Id == recorded.WinnerPlayerId ? recorded.Player1Id : recorded.Player0Id; Assert.Equal("p2", loserId); } [Fact] public async Task Intents_After_Finish_Are_Rejected() { var grain = NewGrain(new FakeTimeProvider(Start)); var first = await Attach(grain, 0, "p1"); await Attach(grain, 1, "p2"); await grain.SubmitAsync(1, new ClientIntent { IntentId = "concede", BasedOnSeq = 1, Intent = new ConcedeIntent(), }); await grain.SubmitAsync(0, new ClientIntent { IntentId = "after-finish", BasedOnSeq = grain.Summary.EventSeq, Intent = new FinishMulliganIntent(), }); var result = first.Last(); Assert.False(result.Accepted); Assert.Equal(IntentRejectCode.MatchFinished, result.Code); } [Fact] public async Task Turn_Clock_And_Bank_Drain_To_Defeat() { var time = new FakeTimeProvider(Start); var options = ServerTestData.SmallOptions(); options.TurnSeconds = 10; options.BankSeconds = 20; var sink = new RecordingResultSink(); var grain = NewGrain(time, sink, options); var first = await Attach(grain, 0, "p1"); await Attach(grain, 1, "p2"); // 10 секунд бесплатно + 15 из банка: банк ещё жив time.Advance(TimeSpan.FromSeconds(25)); await grain.TickAsync(time.GetUtcNow()); Assert.False(grain.Summary.IsFinished); Assert.Equal(5_000, first.Last().Clock.Bank0Ms); // ещё 6 секунд: банк кончился, игрок 0 проигрывает time.Advance(TimeSpan.FromSeconds(6)); await grain.TickAsync(time.GetUtcNow()); Assert.True(grain.Summary.IsFinished); Assert.Equal(1, grain.Summary.Winner); Assert.Equal(MatchFinishReason.Timeout, Assert.Single(sink.Results).FinishReason); Assert.Contains(first.All().SelectMany(e => e.Events), e => e.Event is GameFinishedView { Winner: 1 }); } [Fact] public async Task Absence_Of_Opponent_Ends_Match() { var time = new FakeTimeProvider(Start); var options = ServerTestData.SmallOptions(); options.AbsenceSeconds = 30; var sink = new RecordingResultSink(); var grain = NewGrain(time, sink, options); var first = await Attach(grain, 0, "p1"); time.Advance(TimeSpan.FromSeconds(31)); await grain.TickAsync(time.GetUtcNow()); Assert.True(grain.Summary.IsFinished); Assert.Equal(0, grain.Summary.Winner); Assert.Equal(MatchFinishReason.Absence, Assert.Single(sink.Results).FinishReason); Assert.Contains(first.All().SelectMany(e => e.Events), e => e.Event is GameFinishedView { Winner: 0 }); } [Fact] public async Task Clock_Does_Not_Start_Until_Both_Seats_Connected() { var time = new FakeTimeProvider(Start); var grain = NewGrain(time); var first = await Attach(grain, 0, "p1"); time.Advance(TimeSpan.FromSeconds(20)); await grain.TickAsync(time.GetUtcNow()); Assert.False(first.Last().Clock.IsRunning); Assert.False(grain.Summary.IsFinished); var second = await Attach(grain, 1, "p2"); time.Advance(TimeSpan.FromSeconds(1)); await grain.TickAsync(time.GetUtcNow()); Assert.True(first.Last().Clock.IsRunning); Assert.True(second.Last().Clock.IsRunning); } [Fact] public async Task Reconnect_Resumes_From_Event_Seq() { var time = new FakeTimeProvider(Start); var grain = NewGrain(time); var first = await Attach(grain, 0, "p1"); var second = await Attach(grain, 1, "p2"); await grain.SubmitAsync(0, new ClientIntent { IntentId = "finish-0", BasedOnSeq = grain.Summary.EventSeq, Intent = new FinishMulliganIntent(), }); var seq = grain.Summary.EventSeq; await grain.DetachAsync(0, first); var reconnected = new FakeMatchConnection(); Assert.True(await grain.AttachAsync(0, "p1", reconnected, seq)); var hello = reconnected.All().Single(); Assert.True(hello.Resumed); Assert.Equal(seq, hello.EventSeq); var events = reconnected.All().Single(); Assert.Equal(seq + 1, events.FromSeq); Assert.Equal(seq, events.ToSeq); Assert.Empty(events.Events); Assert.Empty(reconnected.All()); // без resumeFromSeq приходит полный снимок var fresh = new FakeMatchConnection(); Assert.True(await grain.AttachAsync(1, "p2", fresh, 0)); Assert.False(fresh.All().Single().Resumed); Assert.Single(fresh.All()); Assert.NotEmpty(second.All()); } [Fact] public async Task New_Connection_Replaces_The_Old_One() { var grain = NewGrain(new FakeTimeProvider(Start)); var old = await Attach(grain, 0, "p1"); await Attach(grain, 1, "p2"); var replacement = new FakeMatchConnection(); Assert.True(await grain.AttachAsync(0, "p1", replacement, grain.Summary.EventSeq)); Assert.True(old.IsClosed); Assert.Equal(4409, old.CloseCode); Assert.Single(replacement.All()); } [Fact] public async Task Leave_Ends_Match_As_Loss() { var sink = new RecordingResultSink(); var grain = NewGrain(new FakeTimeProvider(Start), sink); var first = await Attach(grain, 0, "p1"); var second = await Attach(grain, 1, "p2"); await grain.LeaveAsync(1); Assert.True(grain.Summary.IsFinished); Assert.Equal(0, grain.Summary.Winner); var result = Assert.Single(sink.Results); Assert.Equal(MatchFinishReason.Leave, result.FinishReason); Assert.Equal("p1", result.WinnerPlayerId); Assert.Contains(second.All().SelectMany(e => e.Events), e => e.Event is GameFinishedView { Winner: 0 }); Assert.Contains(first.All().SelectMany(e => e.Events), e => e.Event is GameFinishedView { Winner: 0 }); } [Fact] public async Task Leave_After_Finish_Is_NoOp() { var grain = NewGrain(new FakeTimeProvider(Start)); var first = await Attach(grain, 0, "p1"); await Attach(grain, 1, "p2"); await grain.LeaveAsync(0); var seq = grain.Summary.EventSeq; await grain.LeaveAsync(1); Assert.Equal(seq, grain.Summary.EventSeq); Assert.Equal(1, grain.Summary.Winner); } private static MatchGrain NewGrain( FakeTimeProvider time, RecordingResultSink? sink = null, GameOptions? options = null) { var setup = new MatchSetup( "match-1", GameMode.ClassicRanked, new MatchSeatSetup("p1", ServerTestData.BuildDeck(), 1000), new MatchSeatSetup("p2", ServerTestData.BuildDeck(), 1000)); return new MatchGrain( setup, ServerTestData.Catalog, options ?? ServerTestData.SmallOptions(), time, sink ?? new RecordingResultSink(), new NullMatchStore(), new MatchConnectionRegistry(), NullLogger.Instance); } private static async Task Attach(MatchGrain grain, int seat, string playerId) { var connection = new FakeMatchConnection(); Assert.True(await grain.AttachAsync(seat, playerId, connection, 0)); return connection; } }