VanguardReMaster/server/tests/Vanguard.Server.Tests/MatchGrainTests.cs

357 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));
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;
}
}