358 lines
12 KiB
C#
358 lines
12 KiB
C#
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<ServerHello>().Single();
|
|
Assert.Equal(0, hello.PlayerIndex);
|
|
Assert.False(hello.Resumed);
|
|
|
|
var snapshot = first.All<ServerSnapshot>().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<ServerClock>().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<ServerIntentResult>();
|
|
Assert.True(accepted.Accepted);
|
|
Assert.False(accepted.Duplicate);
|
|
|
|
var seqAfterFirst = grain.Summary.EventSeq;
|
|
var eventsAfterFirst = first.All<ServerEvents>().Count;
|
|
|
|
await grain.SubmitAsync(0, intent);
|
|
|
|
var duplicate = first.Last<ServerIntentResult>();
|
|
Assert.True(duplicate.Accepted);
|
|
Assert.True(duplicate.Duplicate);
|
|
Assert.Equal(seqAfterFirst, grain.Summary.EventSeq);
|
|
Assert.Equal(eventsAfterFirst, first.All<ServerEvents>().Count);
|
|
Assert.True(second.All<ServerIntentResult>().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<ServerIntentResult>();
|
|
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<ServerIntentResult>();
|
|
Assert.True(result.Accepted);
|
|
Assert.True(grain.Summary.IsFinished);
|
|
Assert.Equal(0, grain.Summary.Winner);
|
|
|
|
Assert.Contains(first.All<ServerEvents>().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<ServerIntentResult>();
|
|
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<ServerClock>().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<ServerEvents>().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<ServerEvents>().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<ServerClock>().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<ServerClock>().Clock.IsRunning);
|
|
Assert.True(second.Last<ServerClock>().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<ServerHello>().Single();
|
|
Assert.True(hello.Resumed);
|
|
Assert.Equal(seq, hello.EventSeq);
|
|
|
|
var events = reconnected.All<ServerEvents>().Single();
|
|
Assert.Equal(seq + 1, events.FromSeq);
|
|
Assert.Equal(seq, events.ToSeq);
|
|
Assert.Empty(events.Events);
|
|
Assert.Empty(reconnected.All<ServerSnapshot>());
|
|
|
|
// без resumeFromSeq приходит полный снимок
|
|
var fresh = new FakeMatchConnection();
|
|
Assert.True(await grain.AttachAsync(1, "p2", fresh, 0));
|
|
Assert.False(fresh.All<ServerHello>().Single().Resumed);
|
|
Assert.Single(fresh.All<ServerSnapshot>());
|
|
Assert.NotEmpty(second.All<ServerSnapshot>());
|
|
}
|
|
|
|
[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<ServerHello>());
|
|
}
|
|
|
|
[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<ServerEvents>().SelectMany(e => e.Events),
|
|
e => e.Event is GameFinishedView { Winner: 0 });
|
|
Assert.Contains(first.All<ServerEvents>().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),
|
|
FirstPlayer: 0);
|
|
|
|
return new MatchGrain(
|
|
setup,
|
|
ServerTestData.Catalog,
|
|
options ?? ServerTestData.SmallOptions(),
|
|
time,
|
|
sink ?? new RecordingResultSink(),
|
|
new NullMatchStore(),
|
|
new MatchConnectionRegistry(),
|
|
NullLogger<MatchGrain>.Instance);
|
|
}
|
|
|
|
private static async Task<FakeMatchConnection> Attach(MatchGrain grain, int seat, string playerId)
|
|
{
|
|
var connection = new FakeMatchConnection();
|
|
Assert.True(await grain.AttachAsync(seat, playerId, connection, 0));
|
|
return connection;
|
|
}
|
|
}
|