diff --git a/.gitignore b/.gitignore index bed9ef98..13a81591 100644 --- a/.gitignore +++ b/.gitignore @@ -129,6 +129,7 @@ Data.Tests.* # Temporary files *.tmp +*.csproj.lscache # Logs */logs/* diff --git a/Sunrise.API/Controllers/ScoreController.cs b/Sunrise.API/Controllers/ScoreController.cs index 2e1ec4ff..50be3c8f 100644 --- a/Sunrise.API/Controllers/ScoreController.cs +++ b/Sunrise.API/Controllers/ScoreController.cs @@ -37,7 +37,7 @@ public async Task GetScore([Range(1, int.MaxValue)] int id, Cance { QueryModifier = query => query.Cast().IncludeUser() }, - ct); + ct: ct); if (score == null) return Problem(ApiErrorResponse.Detail.ScoreNotFound, statusCode: StatusCodes.Status404NotFound); @@ -58,10 +58,12 @@ public async Task GetScoreReplay([Range(1, int.MaxValue)] int id, { var session = HttpContext.GetCurrentSession(); - var score = await database.Scores.GetScore(id, new QueryOptions(true) - { - QueryModifier = query => query.Cast().IncludeUser() - }, ct); + var score = await database.Scores.GetScore(id, + new QueryOptions(true) + { + QueryModifier = query => query.Cast().IncludeUser() + }, + ct: ct); if (score == null) return Problem(ApiErrorResponse.Detail.ScoreNotFound, statusCode: StatusCodes.Status404NotFound); diff --git a/Sunrise.API/Serializable/Response/UserResponse.cs b/Sunrise.API/Serializable/Response/UserResponse.cs index 345c19a6..be5a36c9 100644 --- a/Sunrise.API/Serializable/Response/UserResponse.cs +++ b/Sunrise.API/Serializable/Response/UserResponse.cs @@ -20,6 +20,9 @@ public UserResponse() public UserResponse(SessionRepository sessionRepository, User user) { + if (user == null) + throw new ArgumentNullException(nameof(user), "User must be loaded before constructing UserResponse."); + var session = sessionRepository.GetSession(userId: user.Id); Id = user.Id; diff --git a/Sunrise.API/Services/UserService.cs b/Sunrise.API/Services/UserService.cs index acd108c5..ba221f3f 100644 --- a/Sunrise.API/Services/UserService.cs +++ b/Sunrise.API/Services/UserService.cs @@ -150,7 +150,7 @@ public async Task SetUserAvatar( if (!isSet || error != null) { - SunriseMetrics.RequestReturnedErrorCounterInc(RequestType.AvatarUpload, null, error); + SunriseMetrics.RequestReturnedErrorCounterInc(RequestType.AvatarUpload, eventAction.TargetUserId, error); return new ObjectResult(new ProblemDetails { Title = ApiErrorResponse.Title.UnableToChangeAvatar, @@ -185,7 +185,7 @@ public async Task SetUserBanner( if (!isSet || error != null) { - SunriseMetrics.RequestReturnedErrorCounterInc(RequestType.BannerUpload, null, error); + SunriseMetrics.RequestReturnedErrorCounterInc(RequestType.BannerUpload, eventAction.TargetUserId, error); return new ObjectResult(new ProblemDetails { Title = ApiErrorResponse.Title.UnableToChangeBanner, @@ -626,4 +626,4 @@ public static List GetUserBadges(User user) return badges; } -} \ No newline at end of file +} diff --git a/Sunrise.Processing.Tests/DatabaseFixture.cs b/Sunrise.Processing.Tests/DatabaseFixture.cs new file mode 100644 index 00000000..752ad852 --- /dev/null +++ b/Sunrise.Processing.Tests/DatabaseFixture.cs @@ -0,0 +1,12 @@ +using Xunit; + +namespace Sunrise.Processing.Tests; + +public class IntegrationDatabaseFixture : Sunrise.Tests.IntegrationDatabaseFixture +{ +} + +[CollectionDefinition("Integration tests collection")] +public class DatabaseTestCollection : ICollectionFixture +{ +} \ No newline at end of file diff --git a/Sunrise.Processing.Tests/Scores/Handlers/ScoreDeletionHandlerTests.cs b/Sunrise.Processing.Tests/Scores/Handlers/ScoreDeletionHandlerTests.cs new file mode 100644 index 00000000..8e7ffde0 --- /dev/null +++ b/Sunrise.Processing.Tests/Scores/Handlers/ScoreDeletionHandlerTests.cs @@ -0,0 +1,96 @@ +using Microsoft.Extensions.DependencyInjection; +using Sunrise.Processing.Scores.Handlers; +using Sunrise.Shared.Database.Models.Scores; +using Sunrise.Shared.Enums.Scores; +using Sunrise.Tests.Abstracts; +using Sunrise.Tests.Extensions; +using Sunrise.Tests.Services.Mock; +using Xunit; +using SubmissionStatus = Sunrise.Shared.Enums.Scores.SubmissionStatus; + +namespace Sunrise.Processing.Tests.Scores.Handlers; + +[Collection("Integration tests collection")] +public class ScoreDeletionHandlerTests(IntegrationDatabaseFixture fixture) : DatabaseTest(fixture) +{ + private readonly MockService _mocker = new(); + + [Fact] + public async Task TestPrepareAsyncWithMissingScoreReturnsUnexpectedError() + { + // Arrange + var handler = (ScoreDeletionHandler)Scope.ServiceProvider + .GetRequiredKeyedService(ScoreTaskType.Delete); + + // Act + var result = await handler.PrepareAsync(new ScoreProcessingTask + { + TaskType = ScoreTaskType.Delete, + ScoreId = 999_999 + }, + CancellationToken.None); + + // Assert + Assert.True(result.IsFailure); + Assert.Equal(ScoreProcessingErrorCode.Unexpected, result.Error.Code); + } + + [Fact] + public async Task TestPrepareAsyncWithAlreadyDeletedScoreReturnsFailure() + { + // Arrange + var user = await CreateTestUser(); + var score = _mocker.Score.GetBestScoreableRandomScore(); + score.EnrichWithUserData(user); + + score.SubmissionStatus = SubmissionStatus.Deleted; + + score = await CreateTestScore(score); + + var handler = (ScoreDeletionHandler)Scope.ServiceProvider + .GetRequiredKeyedService(ScoreTaskType.Delete); + + // Act + var result = await handler.PrepareAsync(new ScoreProcessingTask + { + TaskType = ScoreTaskType.Delete, + ScoreId = score.Id + }, + CancellationToken.None); + + // Assert + Assert.True(result.IsFailure); + Assert.Equal(ScoreProcessingErrorCode.InvalidScoreState, result.Error.Code); + } + + [Fact] + public async Task TestPrepareAsyncWithValidScoreReturnsDeletionContext() + { + // Arrange + var user = await CreateTestUser(); + var score = _mocker.Score.GetBestScoreableRandomScore(); + score.EnrichWithUserData(user); + + score.SubmissionStatus = SubmissionStatus.Submitted; + + score = await CreateTestScore(score); + + var handler = (ScoreDeletionHandler)Scope.ServiceProvider + .GetRequiredKeyedService(ScoreTaskType.Delete); + + // Act + var result = await handler.PrepareAsync(new ScoreProcessingTask + { + TaskType = ScoreTaskType.Delete, + ScoreId = score.Id + }, + CancellationToken.None); + + // Assert + Assert.True(result.IsSuccess); + Assert.Equal(score.Id, result.Value.Score.Id); + Assert.Equal(user.Id, result.Value.User.Id); + Assert.Equal(user.Id, result.Value.UserStats.UserId); + Assert.Equal(user.Id, result.Value.UserGrades.UserId); + } +} \ No newline at end of file diff --git a/Sunrise.Processing.Tests/Scores/Handlers/ScoreRecalculationHandlerTests.cs b/Sunrise.Processing.Tests/Scores/Handlers/ScoreRecalculationHandlerTests.cs new file mode 100644 index 00000000..d2c83d6c --- /dev/null +++ b/Sunrise.Processing.Tests/Scores/Handlers/ScoreRecalculationHandlerTests.cs @@ -0,0 +1,187 @@ +using Microsoft.Extensions.DependencyInjection; +using Sunrise.Processing.Scores.Handlers; +using Sunrise.Shared.Database.Models.Scores; +using Sunrise.Shared.Enums.Scores; +using Sunrise.Tests.Abstracts; +using Sunrise.Tests.Extensions; +using Sunrise.Tests.Services.Mock; +using Xunit; + +namespace Sunrise.Processing.Tests.Scores.Handlers; + +[Collection("Integration tests collection")] +public class ScoreRecalculationHandlerTests(IntegrationDatabaseFixture fixture) : DatabaseTest(fixture) +{ + private readonly MockService _mocker = new(); + + [Fact] + public async Task TestPrepareAsyncWithMissingScoreReturnsUnexpectedError() + { + // Arrange + var handler = (ScoreRecalculationHandler)Scope.ServiceProvider + .GetRequiredKeyedService(ScoreTaskType.Recalculation); + + // Act + var result = await handler.PrepareAsync(new ScoreProcessingTask + { + TaskType = ScoreTaskType.Recalculation, + ScoreId = 999_999 + }, + CancellationToken.None); + + // Assert + Assert.True(result.IsFailure); + Assert.Equal(ScoreProcessingErrorCode.Unexpected, result.Error.Code); + Assert.Equal(ScoreProcessingDisposition.Permanent, result.Error.Disposition); + } + + [Fact] + public async Task TestPrepareAsyncWithDeletedScoreReturnsUnexpectedError() + { + // Arrange + var user = await CreateTestUser(); + var score = _mocker.Score.GetBestScoreableRandomScore(); + score.EnrichWithUserData(user); + + score.SubmissionStatus = SubmissionStatus.Deleted; + + score = await CreateTestScore(score); + + var handler = (ScoreRecalculationHandler)Scope.ServiceProvider + .GetRequiredKeyedService(ScoreTaskType.Recalculation); + + // Act + var result = await handler.PrepareAsync(new ScoreProcessingTask + { + TaskType = ScoreTaskType.Recalculation, + ScoreId = score.Id + }, + CancellationToken.None); + + // Assert + Assert.True(result.IsFailure); + Assert.Equal(ScoreProcessingErrorCode.InvalidScoreState, result.Error.Code); + Assert.Equal(ScoreProcessingDisposition.Permanent, result.Error.Disposition); + } + + [Fact] + public async Task TestPrepareAsyncWithServerErrorResponseForBeatmapReturnsBeatmapNotFoundRetryable() + { + // Arrange + var user = await CreateTestUser(); + var score = _mocker.Score.GetBestScoreableRandomScore(); + score.EnrichWithUserData(user); + score = await CreateTestScore(score); + + App.MockHttpClient?.MockBeatmapSetByHashInternalServerError(); + + var handler = (ScoreRecalculationHandler)Scope.ServiceProvider + .GetRequiredKeyedService(ScoreTaskType.Recalculation); + + // Act + var result = await handler.PrepareAsync(new ScoreProcessingTask + { + TaskType = ScoreTaskType.Recalculation, + ScoreId = score.Id + }, + CancellationToken.None); + + // Assert + Assert.True(result.IsFailure); + Assert.Equal(ScoreProcessingErrorCode.BeatmapNotFound, result.Error.Code); + Assert.Equal(ScoreProcessingDisposition.Retryable, result.Error.Disposition); + } + + [Fact] + public async Task TestPrepareAsyncWithMissingBeatmapReturnsBeatmapNotFoundPermanent() + { + // Arrange + var user = await CreateTestUser(); + var score = _mocker.Score.GetBestScoreableRandomScore(); + score.EnrichWithUserData(user); + score = await CreateTestScore(score); + + App.MockHttpClient?.MockBeatmapSetByBeatmapIdNotFound(score.BeatmapId); + + var handler = (ScoreRecalculationHandler)Scope.ServiceProvider + .GetRequiredKeyedService(ScoreTaskType.Recalculation); + + // Act + var result = await handler.PrepareAsync(new ScoreProcessingTask + { + TaskType = ScoreTaskType.Recalculation, + ScoreId = score.Id + }, + CancellationToken.None); + + // Assert + Assert.True(result.IsFailure); + Assert.Equal(ScoreProcessingErrorCode.BeatmapNotFound, result.Error.Code); + Assert.Equal(ScoreProcessingDisposition.Permanent, result.Error.Disposition); + } + + [Fact] + public async Task TestPrepareAsyncWithFailedPpCalculationReturnsPpCalculationFailed() + { + // Arrange + var user = await CreateTestUser(); + var score = _mocker.Score.GetBestScoreableRandomScore(); + score.EnrichWithUserData(user); + score = await CreateTestScore(score); + + await _mocker.Beatmap.MockRankedBeatmapWithSetForScore(score); + + var handler = (ScoreRecalculationHandler)Scope.ServiceProvider + .GetRequiredKeyedService(ScoreTaskType.Recalculation); + + // Act + var result = await handler.PrepareAsync(new ScoreProcessingTask + { + TaskType = ScoreTaskType.Recalculation, + ScoreId = score.Id + }, + CancellationToken.None); + + // Assert + Assert.True(result.IsFailure); + Assert.Equal(ScoreProcessingErrorCode.PpCalculationFailed, result.Error.Code); + Assert.Equal(ScoreProcessingDisposition.Retryable, result.Error.Disposition); + } + + [Fact] + public async Task TestPrepareAsyncWithExistingScoreReturnsContextWithRecalculatedPerformance() + { + // Arrange + var user = await CreateTestUser(); + var score = _mocker.Score.GetBestScoreableRandomScore(); + score.EnrichWithUserData(user); + + score.PerformancePoints = 123; + + score = await CreateTestScore(score); + + var (_, beatmap) = await _mocker.Beatmap.MockRankedBeatmapWithSetForScore(score); + App.MockHttpClient?.MockPerformanceCalculation(performancePoints: 321); + + var handler = (ScoreRecalculationHandler)Scope.ServiceProvider + .GetRequiredKeyedService(ScoreTaskType.Recalculation); + + // Act + var result = await handler.PrepareAsync(new ScoreProcessingTask + { + TaskType = ScoreTaskType.Recalculation, + ScoreId = score.Id + }, + CancellationToken.None); + + // Assert + Assert.True(result.IsSuccess); + + Assert.Equal(ScoreTaskType.Recalculation, result.Value.TaskType); + Assert.Equal(score.Id, result.Value.Score.Id); + Assert.Equal(321, result.Value.Score.PerformancePoints); + + Assert.NotNull(result.Value.Beatmap); + Assert.Equal(beatmap.Checksum, result.Value.Beatmap!.Checksum); + } +} \ No newline at end of file diff --git a/Sunrise.Processing.Tests/Scores/Handlers/ScoreRestorationHandlerTests.cs b/Sunrise.Processing.Tests/Scores/Handlers/ScoreRestorationHandlerTests.cs new file mode 100644 index 00000000..981b7a63 --- /dev/null +++ b/Sunrise.Processing.Tests/Scores/Handlers/ScoreRestorationHandlerTests.cs @@ -0,0 +1,96 @@ +using Microsoft.Extensions.DependencyInjection; +using Sunrise.Processing.Scores.Handlers; +using Sunrise.Shared.Database.Models.Scores; +using Sunrise.Shared.Enums.Scores; +using Sunrise.Tests.Abstracts; +using Sunrise.Tests.Extensions; +using Sunrise.Tests.Services.Mock; +using Xunit; + +namespace Sunrise.Processing.Tests.Scores.Handlers; + +[Collection("Integration tests collection")] +public class ScoreRestorationHandlerTests(IntegrationDatabaseFixture fixture) : DatabaseTest(fixture) +{ + private readonly MockService _mocker = new(); + + [Fact] + public async Task TestPrepareAsyncWithMissingScoreReturnsUnexpectedError() + { + // Arrange + var handler = (ScoreRestorationHandler)Scope.ServiceProvider + .GetRequiredKeyedService(ScoreTaskType.Restore); + + // Act + var result = await handler.PrepareAsync(new ScoreProcessingTask + { + TaskType = ScoreTaskType.Restore, + ScoreId = 999_999 + }, + CancellationToken.None); + + // Assert + Assert.True(result.IsFailure); + Assert.Equal(ScoreProcessingErrorCode.Unexpected, result.Error.Code); + } + + [Fact] + public async Task TestPrepareAsyncWithActiveScoreReturnsUnexpectedError() + { + // Arrange + var user = await CreateTestUser(); + var score = _mocker.Score.GetBestScoreableRandomScore(); + score.EnrichWithUserData(user); + + score.SubmissionStatus = SubmissionStatus.Submitted; + + score = await CreateTestScore(score); + + var handler = (ScoreRestorationHandler)Scope.ServiceProvider + .GetRequiredKeyedService(ScoreTaskType.Restore); + + // Act + var result = await handler.PrepareAsync(new ScoreProcessingTask + { + TaskType = ScoreTaskType.Restore, + ScoreId = score.Id + }, + CancellationToken.None); + + // Assert + Assert.True(result.IsFailure); + Assert.Equal(ScoreProcessingErrorCode.InvalidScoreState, result.Error.Code); + } + + [Fact] + public async Task TestPrepareAsyncWithDeletedScoreReturnsRestoreContext() + { + // Arrange + var user = await CreateTestUser(); + var score = _mocker.Score.GetBestScoreableRandomScore(); + score.EnrichWithUserData(user); + + score.SubmissionStatus = SubmissionStatus.Deleted; + + score = await CreateTestScore(score); + + var handler = (ScoreRestorationHandler)Scope.ServiceProvider + .GetRequiredKeyedService(ScoreTaskType.Restore); + + // Act + var result = await handler.PrepareAsync(new ScoreProcessingTask + { + TaskType = ScoreTaskType.Restore, + ScoreId = score.Id + }, + CancellationToken.None); + + // Assert + Assert.True(result.IsSuccess); + Assert.Equal(ScoreTaskType.Restore, result.Value.TaskType); + Assert.Equal(score.Id, result.Value.Score.Id); + Assert.Equal(user.Id, result.Value.User.Id); + Assert.Equal(user.Id, result.Value.UserStats.UserId); + Assert.Equal(user.Id, result.Value.UserGrades.UserId); + } +} \ No newline at end of file diff --git a/Sunrise.Processing.Tests/Scores/Handlers/ScoreSubmissionHandlerTests.cs b/Sunrise.Processing.Tests/Scores/Handlers/ScoreSubmissionHandlerTests.cs new file mode 100644 index 00000000..03b20595 --- /dev/null +++ b/Sunrise.Processing.Tests/Scores/Handlers/ScoreSubmissionHandlerTests.cs @@ -0,0 +1,681 @@ +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using osu.Shared; +using Sunrise.Processing.Scores.Handlers; +using Sunrise.Shared.Database.Models.Scores; +using Sunrise.Shared.Enums.Scores; +using Sunrise.Shared.Extensions; +using Sunrise.Shared.Extensions.Beatmaps; +using Sunrise.Shared.Objects.Sessions; +using Sunrise.Tests.Abstracts; +using Sunrise.Tests.Extensions; +using Sunrise.Tests.Services.Mock; +using Sunrise.Tests.Utils.Processing; +using Xunit; +using GameMode = Sunrise.Shared.Enums.Beatmaps.GameMode; + +namespace Sunrise.Processing.Tests.Scores.Handlers; + +[Collection("Integration tests collection")] +public class ScoreSubmissionHandlerTests(IntegrationDatabaseFixture fixture) : DatabaseTest(fixture) +{ + private readonly MockService _mocker = new(); + + [Fact] + public async Task TestPrepareAsyncWithMissingPayloadReferenceReturnsUnexpectedError() + { + // Arrange + var handler = (ScoreSubmissionHandler)Scope.ServiceProvider + .GetRequiredKeyedService(ScoreTaskType.Submission); + + // Act + var result = await handler.PrepareAsync(new ScoreProcessingTask + { + TaskType = ScoreTaskType.Submission + }, + CancellationToken.None); + + // Assert + Assert.True(result.IsFailure); + Assert.Equal(ScoreProcessingErrorCode.Unexpected, result.Error.Code); + } + + [Fact] + public async Task TestPrepareAsyncWithMissingPayloadReturnsUnexpectedError() + { + // Arrange + var handler = (ScoreSubmissionHandler)Scope.ServiceProvider + .GetRequiredKeyedService(ScoreTaskType.Submission); + + // Act + var result = await handler.PrepareAsync(new ScoreProcessingTask + { + TaskType = ScoreTaskType.Submission, + ScoreSubmissionRequestId = 999_999 + }, + CancellationToken.None); + + // Assert + Assert.True(result.IsFailure); + Assert.Equal(ScoreProcessingErrorCode.Unexpected, result.Error.Code); + } + + + [Fact] + public async Task TestPrepareAsyncWithServerErrorResponseForBeatmapReturnsBeatmapNotFoundRetryable() + { + // Arrange + var user = await CreateTestUser(); + var score = _mocker.Score.GetBestScoreableRandomScore(); + score.EnrichWithUserData(user); + var queueEntry = await CreateTestScoreSubmissionRequest(score, user); + + App.MockHttpClient?.MockBeatmapSetByHashInternalServerError(); + + var handler = (ScoreSubmissionHandler)Scope.ServiceProvider + .GetRequiredKeyedService(ScoreTaskType.Submission); + + // Act + var result = await handler.PrepareAsync(new ScoreProcessingTask + { + TaskType = ScoreTaskType.Submission, + ScoreSubmissionRequestId = queueEntry.Id + }, + CancellationToken.None); + + // Assert + Assert.True(result.IsFailure); + Assert.Equal(ScoreProcessingErrorCode.BeatmapNotFound, result.Error.Code); + Assert.Equal(ScoreProcessingDisposition.Retryable, result.Error.Disposition); + } + + [Fact] + public async Task TestPrepareAsyncWithMissingBeatmapReturnsBeatmapNotFoundPermanent() + { + // Arrange + var user = await CreateTestUser(); + var score = _mocker.Score.GetBestScoreableRandomScore(); + score.EnrichWithUserData(user); + var queueEntry = await CreateTestScoreSubmissionRequest(score, user); + + App.MockHttpClient?.MockBeatmapSetByBeatmapIdNotFound(score.BeatmapId); + + var handler = (ScoreSubmissionHandler)Scope.ServiceProvider + .GetRequiredKeyedService(ScoreTaskType.Submission); + + // Act + var result = await handler.PrepareAsync(new ScoreProcessingTask + { + TaskType = ScoreTaskType.Submission, + ScoreSubmissionRequestId = queueEntry.Id + }, + CancellationToken.None); + + // Assert + Assert.True(result.IsFailure); + Assert.Equal(ScoreProcessingErrorCode.BeatmapNotFound, result.Error.Code); + Assert.Equal(ScoreProcessingDisposition.Permanent, result.Error.Disposition); + } + + [Fact] + public async Task TestPrepareAsyncWithMissingReplayReturnsReplayMissing() + { + // Arrange + var user = await CreateTestUser(); + var score = _mocker.Score.GetBestScoreableRandomScore(); + score.EnrichWithUserData(user); + + var queueEntry = await CreateTestScoreSubmissionRequest(score, user, false); + + await _mocker.Beatmap.MockRankedBeatmapWithSetForScore(score); + + var handler = (ScoreSubmissionHandler)Scope.ServiceProvider + .GetRequiredKeyedService(ScoreTaskType.Submission); + + // Act + var result = await handler.PrepareAsync(new ScoreProcessingTask + { + TaskType = ScoreTaskType.Submission, + ScoreSubmissionRequestId = queueEntry.Id + }, + CancellationToken.None); + + // Assert + Assert.True(result.IsFailure); + Assert.Equal(ScoreProcessingErrorCode.ReplayMissing, result.Error.Code); + Assert.Equal(ScoreProcessingDisposition.Permanent, result.Error.Disposition); + } + + [Theory] + [InlineData(Mods.DoubleTime | Mods.HalfTime, ScoreProcessingErrorCode.InvalidMods)] + [InlineData(Mods.Relax | Mods.Relax2, ScoreProcessingErrorCode.InvalidMods)] + [InlineData(Mods.Target, ScoreProcessingErrorCode.InvalidMods)] + [InlineData(Mods.Key1, ScoreProcessingErrorCode.InvalidMods, GameMode.Standard)] + public async Task TestPrepareAsyncWithInvalidModsReturnsInvalidMods(Mods mods, ScoreProcessingErrorCode expectedErrorCode, GameMode? gamemodeOverride = null) + { + // Arrange + var user = await CreateTestUser(); + var score = _mocker.Score.GetBestScoreableRandomScore(); + score.EnrichWithUserData(user); + + score.Mods = mods; + + if (gamemodeOverride.HasValue) + score.GameMode = gamemodeOverride.Value; + + score.GameMode.EnrichWithMods(score.Mods); + + var queueEntry = await CreateTestScoreSubmissionRequest(score, user); + + await _mocker.Beatmap.MockRankedBeatmapWithSetForScore(score); + + var handler = (ScoreSubmissionHandler)Scope.ServiceProvider + .GetRequiredKeyedService(ScoreTaskType.Submission); + + // Act + var result = await handler.PrepareAsync(new ScoreProcessingTask + { + TaskType = ScoreTaskType.Submission, + ScoreSubmissionRequestId = queueEntry.Id + }, + CancellationToken.None); + + // Assert + Assert.True(result.IsFailure); + Assert.Equal(expectedErrorCode, result.Error.Code); + Assert.Equal(ScoreProcessingDisposition.Permanent, result.Error.Disposition); + } + + [Fact] + public async Task TestPrepareAsyncWithInvalidChecksumsReturnsInvalidChecksums() + { + // Arrange + var user = await CreateTestUser(); + var score = _mocker.Score.GetBestScoreableRandomScore(); + score.EnrichWithUserData(user); + + var replayFileId = await CreateReplayFileId(user.Id); + var queueEntry = ScoreSubmissionRequestTestDataFactory.CreateQueueEntry(score, user.Username, replayFileId: replayFileId); + + queueEntry.ClientHash = "invalid-client-hash"; + queueEntry.ScoreHash = "invalid-score-hash"; + + await Database.ScoreSubmissionRequests.AddQueueEntry(queueEntry); + + await _mocker.Beatmap.MockRankedBeatmapWithSetForScore(score); + + var handler = (ScoreSubmissionHandler)Scope.ServiceProvider + .GetRequiredKeyedService(ScoreTaskType.Submission); + + // Act + var result = await handler.PrepareAsync(new ScoreProcessingTask + { + TaskType = ScoreTaskType.Submission, + ScoreSubmissionRequestId = queueEntry.Id + }, + CancellationToken.None); + + // Assert + Assert.True(result.IsFailure); + Assert.Equal(ScoreProcessingErrorCode.InvalidChecksums, result.Error.Code); + Assert.Equal(ScoreProcessingDisposition.Permanent, result.Error.Disposition); + } + + [Fact] + public async Task TestPrepareAsyncWithFailedPpCalculationReturnsPpCalculationFailed() + { + // Arrange + var user = await CreateTestUser(); + var score = _mocker.Score.GetBestScoreableRandomScore(); + score.EnrichWithUserData(user); + var queueEntry = await CreateTestScoreSubmissionRequest(score, user); + + await _mocker.Beatmap.MockRankedBeatmapWithSetForScore(score); + + var handler = (ScoreSubmissionHandler)Scope.ServiceProvider + .GetRequiredKeyedService(ScoreTaskType.Submission); + + // Act + var result = await handler.PrepareAsync(new ScoreProcessingTask + { + TaskType = ScoreTaskType.Submission, + ScoreSubmissionRequestId = queueEntry.Id + }, + CancellationToken.None); + + // Assert + Assert.True(result.IsFailure); + Assert.Equal(ScoreProcessingErrorCode.PpCalculationFailed, result.Error.Code); + Assert.Equal(ScoreProcessingDisposition.Retryable, result.Error.Disposition); + } + + [Fact] + public async Task TestPrepareAsyncWithPpCalculationBeyondBannableThresholdReturnsBannablePpThreshold() + { + // Arrange + var user = await CreateTestUser(); + var score = _mocker.Score.GetBestScoreableRandomScore(); + score.GameMode = GameMode.Standard; + score.Mods = Mods.None; + score.EnrichWithUserData(user); + var queueEntry = await CreateTestScoreSubmissionRequest(score, user); + + await _mocker.Beatmap.MockRankedBeatmapWithSetForScore(score); + App.MockHttpClient?.MockPerformanceCalculation(performancePoints: 999999); + + var handler = (ScoreSubmissionHandler)Scope.ServiceProvider + .GetRequiredKeyedService(ScoreTaskType.Submission); + + // Act + var result = await handler.PrepareAsync(new ScoreProcessingTask + { + TaskType = ScoreTaskType.Submission, + ScoreSubmissionRequestId = queueEntry.Id + }, + CancellationToken.None); + + // Assert + Assert.True(result.IsFailure); + Assert.Equal(ScoreProcessingErrorCode.BannablePpThreshold, result.Error.Code); + Assert.Equal(ScoreProcessingDisposition.Permanent, result.Error.Disposition); + } + + [Fact] + public async Task TestPrepareAsyncWithSubmissionRequestReturnsSubmissionContext() + { + // Arrange + var user = await CreateTestUser(); + var score = _mocker.Score.GetBestScoreableRandomScore(); + score.EnrichWithUserData(user); + var queueEntry = await CreateTestScoreSubmissionRequest(score, user); + + await _mocker.Beatmap.MockRankedBeatmapWithSetForScore(score); + App.MockHttpClient?.MockPerformanceCalculation(); + + var handler = (ScoreSubmissionHandler)Scope.ServiceProvider + .GetRequiredKeyedService(ScoreTaskType.Submission); + + // Act + var result = await handler.PrepareAsync(new ScoreProcessingTask + { + TaskType = ScoreTaskType.Restore, + ScoreSubmissionRequestId = queueEntry.Id + }, + CancellationToken.None); + + // Assert + Assert.True(result.IsSuccess); + Assert.Equal(ScoreTaskType.Submission, result.Value.TaskType); + Assert.Equal(user.Id, result.Value.User.Id); + Assert.Equal(user.Id, result.Value.UserStats.UserId); + Assert.Equal(user.Id, result.Value.UserGrades.UserId); + } + + [Fact] + public async Task TestOnCommittedWithSubmissionRequestAchievesMedals() + { + // Arrange + var user = await CreateTestUser(); + var score = _mocker.Score.GetBestScoreableRandomScore(); + score.EnrichWithUserData(user); + score.GameMode = GameMode.Standard; + score.Mods = Mods.DoubleTime; + var userStats = await Database.Users.Stats.GetUserStats(user.Id, GameMode.Standard); + Assert.NotNull(userStats); + var userGrades = await Database.Users.Grades.GetUserGrades(user.Id, GameMode.Standard); + Assert.NotNull(userGrades); + var (beatmapSet, beatmap) = await _mocker.Beatmap.MockRankedBeatmapWithSetForScore(score); + + var ctx = ScoreCommitContextFactory.Create(ScoreTaskType.Submission, score, user, userStats, userGrades, beatmap, beatmapSet); + + await _mocker.Beatmap.MockRankedBeatmapWithSetForScore(score); + App.MockHttpClient?.MockPerformanceCalculation(); + + + var handler = (ScoreSubmissionHandler)Scope.ServiceProvider + .GetRequiredKeyedService(ScoreTaskType.Submission); + + // Act + await handler.OnCommitted(ctx, CancellationToken.None); + + // Assert + var userMedals = await Database.Users.Medals.GetUserMedals(user.Id, GameMode.Standard); + + Assert.NotNull(userMedals); + Assert.NotNull(userMedals.FirstOrDefault(m => m.MedalId == 92)); // Intro Medal for the DoubleTime mod + } +} + +[Collection("Integration tests collection")] +public class ScoreSubmissionInlineHandlerTests(IntegrationDatabaseFixture fixture) : DatabaseTest(fixture) +{ + private readonly MockService _mocker = new(); + + [Fact] + public async Task TestPrepareInlineSubmissionAsyncWithServerErrorResponseForBeatmapReturnsBeatmapNotFoundRetryable() + { + // Arrange + var (session, user) = await CreateTestSession(); + var score = _mocker.Score.GetBestScoreableRandomScore(); + score.EnrichWithUserData(user); + var queueEntry = await CreateTestScoreSubmissionRequest(score, user); + + App.MockHttpClient?.MockBeatmapSetByHashInternalServerError(); + + var handler = (ScoreSubmissionHandler)Scope.ServiceProvider + .GetRequiredKeyedService(ScoreTaskType.Submission); + + // Act + var result = await handler.PrepareInlineSubmissionAsync(session, queueEntry, CancellationToken.None); + + // Assert + Assert.True(result.IsFailure); + Assert.Equal(ScoreProcessingErrorCode.BeatmapNotFound, result.Error.Code); + Assert.Equal(ScoreProcessingDisposition.Retryable, result.Error.Disposition); + } + + [Fact] + public async Task TestPrepareInlineSubmissionAsyncWithMissingBeatmapReturnsBeatmapNotFoundPermanent() + { + // Arrange + var (session, user) = await CreateTestSession(); + var score = _mocker.Score.GetBestScoreableRandomScore(); + score.EnrichWithUserData(user); + var queueEntry = await CreateTestScoreSubmissionRequest(score, user); + + App.MockHttpClient?.MockBeatmapSetByBeatmapIdNotFound(score.BeatmapId); + + var handler = (ScoreSubmissionHandler)Scope.ServiceProvider + .GetRequiredKeyedService(ScoreTaskType.Submission); + + // Act + var result = await handler.PrepareInlineSubmissionAsync(session, queueEntry, CancellationToken.None); + + // Assert + Assert.True(result.IsFailure); + Assert.Equal(ScoreProcessingErrorCode.BeatmapNotFound, result.Error.Code); + Assert.Equal(ScoreProcessingDisposition.Permanent, result.Error.Disposition); + } + + [Fact] + public async Task TestPrepareInlineSubmissionAsyncWithMissingReplayReturnsReplayMissing() + { + // Arrange + var (session, user) = await CreateTestSession(); + var score = _mocker.Score.GetBestScoreableRandomScore(); + score.EnrichWithUserData(user); + + var queueEntry = await CreateTestScoreSubmissionRequest(score, user, false); + + await _mocker.Beatmap.MockRankedBeatmapWithSetForScore(score); + + var handler = (ScoreSubmissionHandler)Scope.ServiceProvider + .GetRequiredKeyedService(ScoreTaskType.Submission); + + // Act + var result = await handler.PrepareInlineSubmissionAsync(session, queueEntry, CancellationToken.None); + + // Assert + Assert.True(result.IsFailure); + Assert.Equal(ScoreProcessingErrorCode.ReplayMissing, result.Error.Code); + Assert.Equal(ScoreProcessingDisposition.Permanent, result.Error.Disposition); + } + + [Theory] + [InlineData(Mods.DoubleTime | Mods.HalfTime, ScoreProcessingErrorCode.InvalidMods)] + [InlineData(Mods.Relax | Mods.Relax2, ScoreProcessingErrorCode.InvalidMods)] + [InlineData(Mods.Target, ScoreProcessingErrorCode.InvalidMods)] + [InlineData(Mods.Key1, ScoreProcessingErrorCode.InvalidMods, GameMode.Standard)] + public async Task TestPrepareInlineSubmissionAsyncWithInvalidModsReturnsInvalidMods(Mods mods, ScoreProcessingErrorCode expectedErrorCode, GameMode? gamemodeOverride = null) + { + // Arrange + var (session, user) = await CreateTestSession(); + var score = _mocker.Score.GetBestScoreableRandomScore(); + score.EnrichWithUserData(user); + + score.Mods = mods; + + if (gamemodeOverride.HasValue) + score.GameMode = gamemodeOverride.Value; + + score.GameMode.EnrichWithMods(score.Mods); + + var queueEntry = await CreateTestScoreSubmissionRequest(score, user); + + await _mocker.Beatmap.MockRankedBeatmapWithSetForScore(score); + + var handler = (ScoreSubmissionHandler)Scope.ServiceProvider + .GetRequiredKeyedService(ScoreTaskType.Submission); + + // Act + var result = await handler.PrepareInlineSubmissionAsync(session, queueEntry, CancellationToken.None); + + // Assert + Assert.True(result.IsFailure); + Assert.Equal(expectedErrorCode, result.Error.Code); + Assert.Equal(ScoreProcessingDisposition.Permanent, result.Error.Disposition); + } + + [Fact] + public async Task TestPrepareInlineSubmissionAsyncWithInvalidChecksumsReturnsInvalidChecksums() + { + // Arrange + var (session, user) = await CreateTestSession(); + var score = _mocker.Score.GetBestScoreableRandomScore(); + score.EnrichWithUserData(user); + + var replayFileId = await CreateReplayFileId(user.Id); + var queueEntry = ScoreSubmissionRequestTestDataFactory.CreateQueueEntry(score, user.Username, replayFileId: replayFileId); + + queueEntry.ClientHash = "invalid-client-hash"; + queueEntry.ScoreHash = "invalid-score-hash"; + + await Database.ScoreSubmissionRequests.AddQueueEntry(queueEntry); + + await _mocker.Beatmap.MockRankedBeatmapWithSetForScore(score); + + var handler = (ScoreSubmissionHandler)Scope.ServiceProvider + .GetRequiredKeyedService(ScoreTaskType.Submission); + + // Act + var result = await handler.PrepareInlineSubmissionAsync(session, queueEntry, CancellationToken.None); + + // Assert + Assert.True(result.IsFailure); + Assert.Equal(ScoreProcessingErrorCode.InvalidChecksums, result.Error.Code); + Assert.Equal(ScoreProcessingDisposition.Permanent, result.Error.Disposition); + } + + [Fact] + public async Task TestPrepareInlineSubmissionAsyncWithFailedPpCalculationReturnsPpCalculationFailed() + { + // Arrange + var (session, user) = await CreateTestSession(); + var score = _mocker.Score.GetBestScoreableRandomScore(); + score.EnrichWithUserData(user); + var queueEntry = await CreateTestScoreSubmissionRequest(score, user); + + await _mocker.Beatmap.MockRankedBeatmapWithSetForScore(score); + + var handler = (ScoreSubmissionHandler)Scope.ServiceProvider + .GetRequiredKeyedService(ScoreTaskType.Submission); + + // Act + var result = await handler.PrepareInlineSubmissionAsync(session, queueEntry, CancellationToken.None); + + // Assert + Assert.True(result.IsFailure); + Assert.Equal(ScoreProcessingErrorCode.PpCalculationFailed, result.Error.Code); + Assert.Equal(ScoreProcessingDisposition.Retryable, result.Error.Disposition); + } + + [Fact] + public async Task TestPrepareInlineSubmissionAsyncWithPpCalculationBeyondBannableThresholdReturnsBannablePpThreshold() + { + // Arrange + var (session, user) = await CreateTestSession(); + var score = _mocker.Score.GetBestScoreableRandomScore(); + score.GameMode = GameMode.Standard; + score.Mods = Mods.None; + score.EnrichWithUserData(user); + var queueEntry = await CreateTestScoreSubmissionRequest(score, user); + + await _mocker.Beatmap.MockRankedBeatmapWithSetForScore(score); + App.MockHttpClient?.MockPerformanceCalculation(performancePoints: 999999); + + var handler = (ScoreSubmissionHandler)Scope.ServiceProvider + .GetRequiredKeyedService(ScoreTaskType.Submission); + + // Act + var result = await handler.PrepareInlineSubmissionAsync(session, queueEntry, CancellationToken.None); + + // Assert + Assert.True(result.IsFailure); + Assert.Equal(ScoreProcessingErrorCode.BannablePpThreshold, result.Error.Code); + Assert.Equal(ScoreProcessingDisposition.Permanent, result.Error.Disposition); + } + + [Fact] + public async Task TestPrepareInlineSubmissionAsyncWithSubmissionRequestReturnsSubmissionContext() + { + // Arrange + var (session, user) = await CreateTestSession(); + var score = _mocker.Score.GetBestScoreableRandomScore(); + score.EnrichWithUserData(user); + var queueEntry = await CreateTestScoreSubmissionRequest(score, user); + + await _mocker.Beatmap.MockRankedBeatmapWithSetForScore(score); + App.MockHttpClient?.MockPerformanceCalculation(); + + var handler = (ScoreSubmissionHandler)Scope.ServiceProvider + .GetRequiredKeyedService(ScoreTaskType.Submission); + + // Act + var result = await handler.PrepareInlineSubmissionAsync(session, queueEntry, CancellationToken.None); + + // Assert + Assert.True(result.IsSuccess); + Assert.Equal(ScoreTaskType.Submission, result.Value.TaskType); + Assert.Equal(user.Id, result.Value.User.Id); + Assert.Equal(user.Id, result.Value.UserStats.UserId); + Assert.Equal(user.Id, result.Value.UserGrades.UserId); + } + + [Fact] + public async Task TestExecuteInlineSubmissionWithSubmissionRequestAchievesMedals() + { + // Arrange + var (session, user) = await CreateTestSession(); + var score = _mocker.Score.GetBestScoreableRandomScore(); + score.EnrichWithUserData(user); + score.GameMode = GameMode.Standard; + score.Mods = Mods.DoubleTime; + + var queueEntry = await CreateTestScoreSubmissionRequest(score, user); + + await _mocker.Beatmap.MockRankedBeatmapWithSetForScore(score); + App.MockHttpClient?.MockPerformanceCalculation(); + + var handler = Scope.ServiceProvider.GetRequiredService(); + + // Act + await handler.ExecuteInlineSubmission(session, queueEntry, CancellationToken.None); + + // Assert + var userMedals = await Database.Users.Medals.GetUserMedals(user.Id, GameMode.Standard); + + Assert.NotNull(userMedals); + Assert.NotNull(userMedals.FirstOrDefault(m => m.MedalId == 92)); // Intro Medal for the DoubleTime mod + } + + [Fact] + public async Task TestExecuteInlineSubmissionWithSubmissionRequestPersistsScoreAndReturnsScoreStringForScoreableScore() + { + // Arrange + var (session, user) = await CreateTestSession(); + var score = _mocker.Score.GetBestScoreableRandomScore(); + score.EnrichWithUserData(user); + var queueEntry = await CreateTestScoreSubmissionRequest(score, user); + + await _mocker.Beatmap.MockRankedBeatmapWithSetForScore(score); + App.MockHttpClient?.MockPerformanceCalculation(); + + var handler = Scope.ServiceProvider.GetRequiredService(); + + // Act + var executeInlineSubmissionResult = await handler.ExecuteInlineSubmission(session, queueEntry, CancellationToken.None); + + // Assert + Assert.True(executeInlineSubmissionResult.IsSuccess); + + var persistedScore = await Database.Scores.GetScore(score.ScoreHash); + Assert.NotNull(persistedScore); + Assert.Equal(user.Id, persistedScore.UserId); + + executeInlineSubmissionResult.Value.Should().NotBeNull(); + } + + [Fact] + public async Task TestExecuteInlineSubmissionWithSubmissionRequestPersistsScoreAndReturnsNullForNonScoreableScore() + { + // Arrange + var (session, user) = await CreateTestSession(); + var score = _mocker.Score.GetBestScoreableRandomScore(); + score.EnrichWithUserData(user); + var queueEntry = await CreateTestScoreSubmissionRequest(score, user); + + EnvManager.Set("General:IgnoreBeatmapRanking", "false"); + await _mocker.Beatmap.MockGraveyardBeatmapWithSetForScore(score); // Overrides scoreable score status + + App.MockHttpClient?.MockPerformanceCalculation(); + + var handler = Scope.ServiceProvider.GetRequiredService(); + + // Act + var executeInlineSubmissionResult = await handler.ExecuteInlineSubmission(session, queueEntry, CancellationToken.None); + + // Assert + Assert.True(executeInlineSubmissionResult.IsSuccess); + + var persistedScore = await Database.Scores.GetScore(score.ScoreHash); + Assert.NotNull(persistedScore); + Assert.Equal(user.Id, persistedScore.UserId); + + executeInlineSubmissionResult.Value.Should().BeNull(); + } + + [Fact] + public async Task TestExecuteInlineSubmissionWithDuplicateScoreReturnsDuplicateScoreError() + { + // Arrange + var (session, user) = await CreateTestSession(); + var (replay, beatmapId) = GetValidTestReplay(); + var score = replay.GetScore(); + score.BeatmapId = beatmapId; + score.EnrichWithSessionData(session); + + var beatmapSet = _mocker.Beatmap.GetRandomBeatmapSet(); + beatmapSet.IgnoreBeatmapRanking(); + var beatmap = beatmapSet.Beatmaps!.First(); + beatmap.EnrichWithScoreData(score); + + var replayFileId = await CreateReplayFileId(user.Id); + var queueEntry = ScoreSubmissionRequestTestDataFactory.CreateQueueEntry(score, user.Username, replayFileId: replayFileId); + await _mocker.Beatmap.MockBeatmapSet(beatmapSet); + + using var scope = Scope; + var handler = scope.ServiceProvider.GetRequiredService(); + App.MockHttpClient?.MockPerformanceCalculation(performancePoints: 200); + + var initialResult = await handler.ExecuteInlineSubmission(BaseSession.GenerateServerSession(), queueEntry, CancellationToken.None); + Assert.True(initialResult.IsSuccess); + + // Act + var duplicateResult = await handler.ExecuteInlineSubmission(BaseSession.GenerateServerSession(), queueEntry, CancellationToken.None); + + // Assert + Assert.True(duplicateResult.IsFailure); + Assert.Equal(ScoreProcessingErrorCode.DuplicateScore, duplicateResult.Error.Code); + Assert.Equal("Score with same hash already exists", duplicateResult.Error.Message); + } +} \ No newline at end of file diff --git a/Sunrise.Processing.Tests/Scores/Jobs/ScoreDeletionProcessingJobTests.cs b/Sunrise.Processing.Tests/Scores/Jobs/ScoreDeletionProcessingJobTests.cs new file mode 100644 index 00000000..2b0a70fa --- /dev/null +++ b/Sunrise.Processing.Tests/Scores/Jobs/ScoreDeletionProcessingJobTests.cs @@ -0,0 +1,335 @@ +using Microsoft.Extensions.DependencyInjection; +using Sunrise.Processing.Scores.Handlers; +using Sunrise.Processing.Scores.Pipeline; +using Sunrise.Processing.Scores.Processors; +using Sunrise.Shared.Database.Models; +using Sunrise.Shared.Database.Models.Scores; +using Sunrise.Shared.Database.Models.Users; +using Sunrise.Shared.Enums.Scores; +using Sunrise.Shared.Extensions; +using Sunrise.Shared.Extensions.Beatmaps; +using Sunrise.Shared.Objects.Serializable; +using Sunrise.Shared.Services; +using Sunrise.Shared.Utils.Calculators; +using Sunrise.Tests.Abstracts; +using Sunrise.Tests.Extensions; +using Sunrise.Tests.Services.Mock; +using Xunit; +using Mods = osu.Shared.Mods; +using SubmissionStatus = Sunrise.Shared.Enums.Scores.SubmissionStatus; + +namespace Sunrise.Processing.Tests.Scores.Jobs; + +[Collection("Integration tests collection")] +public class ScoreDeletionProcessingJobTests(IntegrationDatabaseFixture fixture, bool reuseScopeInContext = true) : DatabaseTest(fixture, reuseScopeInContext) +{ + private readonly MockService _mocker = new(); + + [Fact] + public async Task TestDeletionWithMissingScoreReturnsError() + { + // Arrange + var handler = new ScoreDeletionHandler(Database, CreatePipeline()); + + // Act + var result = await handler.ExecuteAsync(new ScoreProcessingTask + { + TaskType = ScoreTaskType.Delete, + ScoreId = 999_999 + }, + CancellationToken.None); + + // Assert + Assert.True(result.IsFailure); + Assert.Equal(ScoreProcessingErrorCode.Unexpected, result.Error.Code); + } + + [Fact] + public async Task TestDeletionOfAlreadyDeletedScoreReturnsInvalidStateError() + { + // Arrange + var user = await CreateTestUser(); + var score = _mocker.Score.GetBestScoreableRandomScore(); + + score.SubmissionStatus = SubmissionStatus.Deleted; + + score.EnrichWithUserData(user); + score = await CreateTestScore(score); + + var handler = new ScoreDeletionHandler(Database, CreatePipeline()); + + // Act + var result = await handler.ExecuteAsync(new ScoreProcessingTask + { + TaskType = ScoreTaskType.Delete, + ScoreId = score.Id + }, + CancellationToken.None); + + // Assert + Assert.True(result.IsFailure); + Assert.Equal(ScoreProcessingErrorCode.InvalidScoreState, result.Error.Code); + } + + [Fact] + public async Task TestDeletionOfBestScorePromotesSameGamemodePeerAndUpdatesGameStatsAndGrades() + { + // Arrange + var user = await CreateTestUser(); + + var (beatmapSet, beatmap) = await _mocker.Beatmap.MockRandomBeatmapWithSet(); + beatmapSet.IgnoreBeatmapRanking(); + + var peerReplacement = await CreatePersistedScore(user, beatmap, 900, SubmissionStatus.Submitted, "S", 450); + + var userStats = await Database.Users.Stats.GetUserStats(user.Id, peerReplacement.GameMode); + Assert.NotNull(userStats); + + var userGrades = await Database.Users.Grades.GetUserGrades(user.Id, peerReplacement.GameMode); + Assert.NotNull(userGrades); + + userStats.UpdateWithDbScore(peerReplacement); + userGrades.UpdateWithDbScore(peerReplacement); + + var score = await CreatePersistedScore(user, beatmap, 1000, SubmissionStatus.Best, "A", 500); + + userStats.UpdateWithDbScore(score); + userGrades.UpdateWithDbScore(score); + + await Database.Users.Stats.UpdateUserStats(userStats, user); + await Database.Users.Grades.UpdateUserGrades(userGrades); + + var handler = new ScoreDeletionHandler(Database, CreatePipeline()); + + // Act + var result = await handler.ExecuteAsync(new ScoreProcessingTask + { + TaskType = ScoreTaskType.Delete, + ScoreId = score.Id + }, + CancellationToken.None); + + // Assert + Assert.True(result.IsSuccess); + + var persistedScore = await Database.Scores.GetScore(score.Id, filterValidScores: false); + Assert.NotNull(persistedScore); + Assert.Equal(SubmissionStatus.Deleted, persistedScore.SubmissionStatus); + + var persistedPeerReplacement = await Database.Scores.GetScore(peerReplacement.Id, filterValidScores: false); + Assert.NotNull(persistedPeerReplacement); + Assert.Equal(SubmissionStatus.Best, persistedPeerReplacement.SubmissionStatus); + + await Database.DbContext.Entry(userStats).ReloadAsync(); + await Database.DbContext.Entry(userGrades).ReloadAsync(); + + Assert.Equal(0, userGrades.GetGradeCount(score.Grade)); + Assert.Equal(1, userGrades.GetGradeCount(peerReplacement.Grade)); + + var (expectedWeightedPerformancePoints, expectedWeightedAccuracy) = (PerformanceCalculator.CalculateUserWeightedPerformance([peerReplacement]), PerformanceCalculator.CalculateUserWeightedAccuracy([peerReplacement])); + + Assert.Equal(peerReplacement.TotalScore, userStats.TotalScore); + Assert.Equal(peerReplacement.MaxCombo, userStats.MaxCombo); + Assert.Equal(peerReplacement.TotalScore, userStats.RankedScore); + Assert.Equal(expectedWeightedPerformancePoints, userStats.PerformancePoints, 6); + Assert.Equal(expectedWeightedAccuracy, userStats.Accuracy, 6); + } + + [Fact] + public async Task TestDeletionOfBestScorePromotesSameGamemodePeer() + { + // Arrange + var user = await CreateTestUser(); + var (beatmapSet, beatmap) = await _mocker.Beatmap.MockRandomBeatmapWithSet(); + beatmapSet.IgnoreBeatmapRanking(); + + var replacement = await CreatePersistedScore(user, beatmap, 900, SubmissionStatus.Submitted, "S", 450); + var score = await CreatePersistedScore(user, beatmap, 1000, SubmissionStatus.Best, "A", 500); + + var handler = new ScoreDeletionHandler(Database, CreatePipeline()); + + // Act + var result = await handler.ExecuteAsync(new ScoreProcessingTask + { + TaskType = ScoreTaskType.Delete, + ScoreId = score.Id + }, + CancellationToken.None); + + // Assert + Assert.True(result.IsSuccess); + + var persistedScore = await Database.Scores.GetScore(score.Id, filterValidScores: false); + Assert.NotNull(persistedScore); + Assert.Equal(SubmissionStatus.Deleted, persistedScore.SubmissionStatus); + + var persistedReplacement = await Database.Scores.GetScore(replacement.Id, filterValidScores: false); + Assert.NotNull(persistedReplacement); + Assert.Equal(SubmissionStatus.Best, persistedReplacement.SubmissionStatus); + } + + [Fact] + public async Task TestDeletionOfBestScoreDoesNotPromoteScoreInDifferentGamemode() + { + // Arrange + var user = await CreateTestUser(); + var (beatmapSet, beatmap) = await _mocker.Beatmap.MockRandomBeatmapWithSet(); + beatmapSet.IgnoreBeatmapRanking(); + + var standardScore = await CreatePersistedScore(user, beatmap, 1000, SubmissionStatus.Best, "S", 500); + var relaxScore = await CreatePersistedScore(user, beatmap, 800, SubmissionStatus.Submitted, "A", 400, Mods.Relax); + + var handler = new ScoreDeletionHandler(Database, CreatePipeline()); + + // Act + var result = await handler.ExecuteAsync(new ScoreProcessingTask + { + TaskType = ScoreTaskType.Delete, + ScoreId = standardScore.Id + }, + CancellationToken.None); + + // Assert + Assert.True(result.IsSuccess); + + var persistedStandard = await Database.Scores.GetScore(standardScore.Id, filterValidScores: false); + Assert.NotNull(persistedStandard); + Assert.Equal(SubmissionStatus.Deleted, persistedStandard.SubmissionStatus); + + var persistedRelax = await Database.Scores.GetScore(relaxScore.Id, filterValidScores: false); + Assert.NotNull(persistedRelax); + Assert.Equal(SubmissionStatus.Submitted, persistedRelax.SubmissionStatus); + } + + [Fact] + public async Task TestDeletionOfBestScoreWithMultipleSameGamemodePeersPromotesHighestScore() + { + // Arrange + var user = await CreateTestUser(); + var (beatmapSet, beatmap) = await _mocker.Beatmap.MockRandomBeatmapWithSet(); + beatmapSet.IgnoreBeatmapRanking(); + + var lowScore = await CreatePersistedScore(user, beatmap, 500, SubmissionStatus.Submitted, "B", 200); + var midScore = await CreatePersistedScore(user, beatmap, 800, SubmissionStatus.Submitted, "A", 400); + var bestScore = await CreatePersistedScore(user, beatmap, 1000, SubmissionStatus.Best, "S", 500); + + var handler = new ScoreDeletionHandler(Database, CreatePipeline()); + + // Act + var result = await handler.ExecuteAsync(new ScoreProcessingTask + { + TaskType = ScoreTaskType.Delete, + ScoreId = bestScore.Id + }, + CancellationToken.None); + + // Assert + Assert.True(result.IsSuccess); + + var persistedBest = await Database.Scores.GetScore(bestScore.Id, filterValidScores: false); + Assert.NotNull(persistedBest); + Assert.Equal(SubmissionStatus.Deleted, persistedBest.SubmissionStatus); + + var persistedMid = await Database.Scores.GetScore(midScore.Id, filterValidScores: false); + Assert.NotNull(persistedMid); + Assert.Equal(SubmissionStatus.Best, persistedMid.SubmissionStatus); + + var persistedLow = await Database.Scores.GetScore(lowScore.Id, filterValidScores: false); + Assert.NotNull(persistedLow); + Assert.Equal(SubmissionStatus.Submitted, persistedLow.SubmissionStatus); + } + + [Fact] + public async Task TestDeletionOfNonBestScoreDoesNotPromoteAnyPeer() + { + // Arrange + var user = await CreateTestUser(); + var (beatmapSet, beatmap) = await _mocker.Beatmap.MockRandomBeatmapWithSet(); + beatmapSet.IgnoreBeatmapRanking(); + + var possibleBestScore = await CreatePersistedScore(user, beatmap, 1000, SubmissionStatus.Submitted, "S", 500); + var submittedScore = await CreatePersistedScore(user, beatmap, 800, SubmissionStatus.Submitted, "A", 400); + + var handler = new ScoreDeletionHandler(Database, CreatePipeline()); + + // Act + var result = await handler.ExecuteAsync(new ScoreProcessingTask + { + TaskType = ScoreTaskType.Delete, + ScoreId = submittedScore.Id + }, + CancellationToken.None); + + // Assert + Assert.True(result.IsSuccess); + + var persistedPossibleBest = await Database.Scores.GetScore(possibleBestScore.Id, filterValidScores: false); + Assert.NotNull(persistedPossibleBest); + Assert.Equal(SubmissionStatus.Submitted, persistedPossibleBest.SubmissionStatus); + + var persistedSubmitted = await Database.Scores.GetScore(submittedScore.Id, filterValidScores: false); + Assert.NotNull(persistedSubmitted); + Assert.Equal(SubmissionStatus.Deleted, persistedSubmitted.SubmissionStatus); + } + + [Fact] + public async Task TestDeletionOfOnlyScoreOnBeatmap() + { + // Arrange + var user = await CreateTestUser(); + var (beatmapSet, beatmap) = await _mocker.Beatmap.MockRandomBeatmapWithSet(); + beatmapSet.IgnoreBeatmapRanking(); + + var score = await CreatePersistedScore(user, beatmap, 1000, SubmissionStatus.Best, "S", 500); + + var handler = new ScoreDeletionHandler(Database, CreatePipeline()); + + // Act + var result = await handler.ExecuteAsync(new ScoreProcessingTask + { + TaskType = ScoreTaskType.Delete, + ScoreId = score.Id + }, + CancellationToken.None); + + // Assert + Assert.True(result.IsSuccess); + + var persistedScore = await Database.Scores.GetScore(score.Id, filterValidScores: false); + Assert.NotNull(persistedScore); + Assert.Equal(SubmissionStatus.Deleted, persistedScore.SubmissionStatus); + } + + private ScoreCommitPipeline CreatePipeline() + { + return new ScoreCommitPipeline(Database, + [ + new LeaderboardProcessor(Database), + new UserGradesScoreProcessor(Database), + new UserStatsScoreProcessor(Database, Scope.ServiceProvider.GetRequiredService()) + ]); + } + + private async Task CreatePersistedScore( + User user, + Beatmap beatmap, + long totalScore, + SubmissionStatus submissionStatus, + string grade, + int maxCombo, + Mods mods = Mods.None) + { + var score = _mocker.Score.GetBestScoreableRandomScore(); + score.EnrichWithUserData(user); + score.TotalScore = totalScore; + score.Grade = grade; + score.MaxCombo = maxCombo; + score.Mods = mods; + score.EnrichWithBeatmapData(beatmap); + score.GameMode = score.GameMode.EnrichWithMods(score.Mods); + score.SubmissionStatus = submissionStatus; + score.LocalProperties = score.LocalProperties.FromScore(score); + + return await CreateTestScore(score); + } +} \ No newline at end of file diff --git a/Sunrise.Processing.Tests/Scores/Jobs/ScoreProcessingJobTests.cs b/Sunrise.Processing.Tests/Scores/Jobs/ScoreProcessingJobTests.cs new file mode 100644 index 00000000..8da16675 --- /dev/null +++ b/Sunrise.Processing.Tests/Scores/Jobs/ScoreProcessingJobTests.cs @@ -0,0 +1,181 @@ +using HOPEless.Bancho; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Sunrise.Processing.Scores.Jobs; +using Sunrise.Shared.Database.Models.Scores; +using Sunrise.Shared.Enums; +using Sunrise.Shared.Enums.Scores; +using Sunrise.Shared.Objects.Serializable.Performances; +using Sunrise.Shared.Objects.Sessions; +using Sunrise.Tests.Abstracts; +using Sunrise.Tests.Extensions; +using Sunrise.Tests.Services.Mock; +using Sunrise.Tests.Utils.Processing; +using Xunit; + +namespace Sunrise.Processing.Tests.Scores.Jobs; + +[Collection("Integration tests collection")] +public class ScoreProcessingJobTests(IntegrationDatabaseFixture fixture, bool reuseScopeInContext = true) : DatabaseTest(fixture, reuseScopeInContext) +{ + private readonly MockService _mocker = new(); + + [Fact] + public async Task TestProcessQueueWithPermanentSubmissionFailureMarksTaskFailedAndNotifiesUser() + { + // Arrange + var user = await CreateTestUser(); + var session = CreateTestSession(user); + session.GetContent(); + + var payload = new ScoreSubmissionRequest + { + UserId = user.Id, + ScoreHash = $"{Guid.NewGuid():N}", + ScoreSerialized = "unused", + BeatmapHash = "missing-job-beatmap", // Beatmap that won't be found, causing a permanent failure + TimeElapsed = 120, + OsuVersion = "b20260101.1", + ClientHash = "client-hash", + UserHash = "user-hash", + WhenPlayed = DateTime.UtcNow + }; + + await Database.ScoreSubmissionRequests.AddQueueEntry(payload); + + var task = await CreateTask(ScoreTaskType.Submission, scoreSubmissionRequestId: payload.Id); + + var job = Scope.ServiceProvider.GetRequiredService(); + + // Act + await job.ProcessQueue(CancellationToken.None); + + // Assert + var refreshedTask = await Database.DbContext.ScoreProcessingTasks.AsNoTracking().SingleAsync(x => x.Id == task.Id); + Assert.Equal(ScoreProcessingStatus.Failed, refreshedTask.Status); + Assert.Equal(ScoreProcessingErrorCode.BeatmapNotFound, refreshedTask.ErrorCode); + Assert.Null(refreshedTask.NextRetryAt); + Assert.Equal(1, refreshedTask.RetryCount); + + var notificationPacket = GetSessionPackets(session).FirstOrDefault(packet => packet.Type == PacketType.ServerNotification); + Assert.NotNull(notificationPacket); + } + + [Fact] + public async Task TestProcessQueueWithRetryableSubmissionFailureRequeuesTask() + { + // Arrange + var user = await CreateTestUser(); + var score = await CreateTestScore(user); + + var beatmapSet = _mocker.Beatmap.GetRandomBeatmapSet(); + var beatmap = beatmapSet.Beatmaps!.First(); + beatmap.EnrichWithScoreData(score); + + var replayFileId = await CreateReplayFileId(user.Id); + var payload = ScoreSubmissionRequestTestDataFactory.CreateQueueEntry(score, user.Username, replayFileId: replayFileId); + await Database.ScoreSubmissionRequests.AddQueueEntry(payload); + await _mocker.Beatmap.MockBeatmapSet(beatmapSet); + + App.MockHttpClient?.MockResponse(ApiType.CalculateScorePerformance, _ => throw new Exception("pp failed")); // Simulate a failure in performance calculation, which should be treated as a retryable error + + var task = await CreateTask(ScoreTaskType.Submission, scoreSubmissionRequestId: payload.Id); + var job = Scope.ServiceProvider.GetRequiredService(); + + // Act + await job.ProcessQueue(CancellationToken.None); + + // Assert + var refreshedTask = await Database.DbContext.ScoreProcessingTasks.AsNoTracking().SingleAsync(x => x.Id == task.Id); + Assert.Equal(ScoreProcessingStatus.Pending, refreshedTask.Status); + Assert.Equal(ScoreProcessingErrorCode.PpCalculationFailed, refreshedTask.ErrorCode); + Assert.NotNull(refreshedTask.NextRetryAt); + Assert.Equal(1, refreshedTask.RetryCount); + + var refreshedPayload = await Database.ScoreSubmissionRequests.GetById(payload.Id); + Assert.NotNull(refreshedPayload); + } + + [Fact] + public async Task TestProcessQueueWithDuplicateSubmissionCleansUpTaskAndPayloadWithoutCreatingSecondScore() + { + // Arrange + var user = await CreateTestUser(); + var replayFileId = await CreateReplayFileId(user.Id); + var score = _mocker.Score.GetBestScoreableRandomScore(); + score.EnrichWithUserData(user); + score.ReplayFileId = replayFileId; + + var beatmapSet = _mocker.Beatmap.GetRandomBeatmapSet(); + var beatmap = beatmapSet.Beatmaps!.First(); + beatmap.EnrichWithScoreData(score); + + var payload = ScoreSubmissionRequestTestDataFactory.CreateQueueEntry(score, user.Username, replayFileId: replayFileId); + score.ScoreHash = payload.ScoreHash; + score = await CreateTestScore(score); + + await Database.ScoreSubmissionRequests.AddQueueEntry(payload); + await _mocker.Beatmap.MockBeatmapSet(beatmapSet); + + App.MockHttpClient?.MockPerformanceCalculation(performancePoints: 200); + + var task = await CreateTask(ScoreTaskType.Submission, scoreSubmissionRequestId: payload.Id); + var job = Scope.ServiceProvider.GetRequiredService(); + + // Act + await job.ProcessQueue(CancellationToken.None); + + Database.DbContext.ChangeTracker.Clear(); + + // Assert + Assert.Null(await Database.DbContext.ScoreProcessingTasks.AsNoTracking().SingleOrDefaultAsync(x => x.Id == task.Id)); + Assert.Null(await Database.ScoreSubmissionRequests.GetById(payload.Id)); + + var persistedScore = await Database.Scores.GetScore(score.Id, filterValidScores: false); + Assert.NotNull(persistedScore); + Assert.Equal(payload.ScoreHash, persistedScore.ScoreHash); + Assert.Equal(SubmissionStatus.Best, persistedScore.SubmissionStatus); + Assert.Equal(1, await Database.DbContext.Scores.AsNoTracking().CountAsync(x => x.UserId == user.Id)); + } + + [Fact] + public async Task TestProcessQueueWithUnexpectedHandlerResolutionFailureMarksTaskAsUnexpected() + { + // Arrange + var score = await CreateTestScore(); + var task = await CreateTask((ScoreTaskType)999, score.Id); + + var job = Scope.ServiceProvider.GetRequiredService(); + + // Act + await job.ProcessQueue(CancellationToken.None); + + // Assert + var refreshedTask = await Database.DbContext.ScoreProcessingTasks.AsNoTracking().SingleAsync(x => x.Id == task.Id); + Assert.Equal(ScoreProcessingStatus.Pending, refreshedTask.Status); + Assert.Equal(ScoreProcessingErrorCode.Unexpected, refreshedTask.ErrorCode); + Assert.NotNull(refreshedTask.NextRetryAt); + Assert.Equal(1, refreshedTask.RetryCount); + } + + private async Task CreateTask(ScoreTaskType taskType, int? scoreId = null, int? scoreSubmissionRequestId = null) + { + var task = new ScoreProcessingTask + { + TaskType = taskType, + ScoreId = scoreId, + ScoreSubmissionRequestId = scoreSubmissionRequestId, + CreatedAt = DateTime.UtcNow + }; + + await Database.ScoreProcessingTasks.AddQueueEntry(task); + return task; + } + + private static List GetSessionPackets(Session session) + { + var content = session.GetContent(); + using var buffer = new MemoryStream(content); + return BanchoSerializer.DeserializePackets(buffer).ToList(); + } +} \ No newline at end of file diff --git a/Sunrise.Processing.Tests/Scores/Jobs/ScoreRecalculationProcessingJobTests.cs b/Sunrise.Processing.Tests/Scores/Jobs/ScoreRecalculationProcessingJobTests.cs new file mode 100644 index 00000000..245784e0 --- /dev/null +++ b/Sunrise.Processing.Tests/Scores/Jobs/ScoreRecalculationProcessingJobTests.cs @@ -0,0 +1,274 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Sunrise.Processing.Scores.Handlers; +using Sunrise.Processing.Scores.Pipeline; +using Sunrise.Processing.Scores.Processors; +using Sunrise.Shared.Database.Models; +using Sunrise.Shared.Database.Models.Scores; +using Sunrise.Shared.Database.Models.Users; +using Sunrise.Shared.Database.Objects; +using Sunrise.Shared.Enums.Beatmaps; +using Sunrise.Shared.Enums.Scores; +using Sunrise.Shared.Extensions; +using Sunrise.Shared.Objects.Serializable; +using Sunrise.Shared.Services; +using Sunrise.Shared.Utils.Calculators; +using Sunrise.Tests.Abstracts; +using Sunrise.Tests.Extensions; +using Sunrise.Tests.Services.Mock; +using Xunit; +using Mods = osu.Shared.Mods; +using SubmissionStatus = Sunrise.Shared.Enums.Scores.SubmissionStatus; + +namespace Sunrise.Processing.Tests.Scores.Jobs; + +[Collection("Integration tests collection")] +public class ScoreRecalculationProcessingJobTests(IntegrationDatabaseFixture fixture, bool reuseScopeInContext = true) : DatabaseTest(fixture, reuseScopeInContext) +{ + private readonly MockService _mocker = new(); + + [Fact] + public async Task TestRecalculationOfDeletedScoreReturnsInvalidStateError() + { + // Arrange + var user = await CreateTestUser(); + var score = _mocker.Score.GetBestScoreableRandomScore(); + score.EnrichWithUserData(user); + score.SubmissionStatus = SubmissionStatus.Deleted; + score = await CreateTestScore(score); + + var handler = new ScoreRecalculationHandler(Database, + CreatePipeline(), + Scope.ServiceProvider.GetRequiredService(), + Scope.ServiceProvider.GetRequiredService()); + + // Act + var result = await handler.ExecuteAsync(new ScoreProcessingTask + { + TaskType = ScoreTaskType.Recalculation, + ScoreId = score.Id + }, + CancellationToken.None); + + // Assert + Assert.True(result.IsFailure); + Assert.Equal(ScoreProcessingErrorCode.InvalidScoreState, result.Error.Code); + } + + [Fact] + public async Task TestRecalculationOfMissingScoreReturnsUnexpectedError() + { + // Arrange + var handler = new ScoreRecalculationHandler(Database, + CreatePipeline(), + Scope.ServiceProvider.GetRequiredService(), + Scope.ServiceProvider.GetRequiredService()); + + // Act + var result = await handler.ExecuteAsync(new ScoreProcessingTask + { + TaskType = ScoreTaskType.Recalculation, + ScoreId = 999_999 + }, + CancellationToken.None); + + // Assert + Assert.True(result.IsFailure); + Assert.Equal(ScoreProcessingErrorCode.Unexpected, result.Error.Code); + } + + [Fact] + public async Task TestRecalculationUpdatesPerformancePoints() + { + // Arrange + var user = await CreateTestUser(); + + var score = _mocker.Score.GetBestScoreableRandomScore(); + score.EnrichWithUserData(user); + score.PerformancePoints = 500; + score.GameMode = GameMode.Standard; + score.Mods = Mods.HardRock; + score = await CreateTestScore(score); + + var (_, __) = await _mocker.Beatmap.MockRankedBeatmapWithSetForScore(score); + + App.MockHttpClient?.MockPerformanceCalculation(performancePoints: 100); + + var handler = new ScoreRecalculationHandler(Database, + CreatePipeline(), + Scope.ServiceProvider.GetRequiredService(), + Scope.ServiceProvider.GetRequiredService()); + + // Act + var result = await handler.ExecuteAsync(new ScoreProcessingTask + { + TaskType = ScoreTaskType.Recalculation, + ScoreId = score.Id + }, + CancellationToken.None); + + // Assert + Assert.True(result.IsSuccess); + + Database.DbContext.Entry(score).State = EntityState.Detached; + var persistedScore = await Database.Scores.GetScore(score.Id, filterValidScores: false); + Assert.NotNull(persistedScore); + Assert.Equal(100, persistedScore.PerformancePoints); + } + + [Fact] + public async Task TestRecalculationUpdatesPerformancePointsAndUserStats() + { + // Arrange + var user = await CreateTestUser(); + + + var score = _mocker.Score.GetBestScoreableRandomScore(); + score.EnrichWithUserData(user); + score.PerformancePoints = 500; + score = await CreateTestScore(score); + + var userStats = await Database.Users.Stats.GetUserStats(user.Id, score.GameMode); + Assert.NotNull(userStats); + userStats.UpdateWithDbScore(score); + await Database.Users.Stats.UpdateUserStats(userStats, user); + + var (_, __) = await _mocker.Beatmap.MockRankedBeatmapWithSetForScore(score); + + const int expectedNewPerformancePoints = 300; + + App.MockHttpClient?.MockPerformanceCalculation(performancePoints: expectedNewPerformancePoints); + + var handler = new ScoreRecalculationHandler(Database, + CreatePipeline(), + Scope.ServiceProvider.GetRequiredService(), + Scope.ServiceProvider.GetRequiredService()); + + // Act + var result = await handler.ExecuteAsync(new ScoreProcessingTask + { + TaskType = ScoreTaskType.Recalculation, + ScoreId = score.Id + }, + CancellationToken.None); + + Database.DbContext.ChangeTracker.Clear(); + + // Assert + Assert.True(result.IsSuccess); + + var persistedScore = await Database.Scores.GetScore(score.Id, filterValidScores: false); + Assert.NotNull(persistedScore); + Assert.Equal(expectedNewPerformancePoints, persistedScore.PerformancePoints); + + await Database.DbContext.Entry(userStats).ReloadAsync(); + + var (expectedWeightedPerformancePoints, expectedWeightedAccuracy) = (PerformanceCalculator.CalculateUserWeightedPerformance([persistedScore]), PerformanceCalculator.CalculateUserWeightedAccuracy([persistedScore])); + + Assert.Equal(expectedWeightedPerformancePoints, userStats.PerformancePoints); + Assert.Equal(expectedWeightedAccuracy, userStats.Accuracy); + } + + [Fact] + public async Task TestRecalculationReconcilesBestStatusWhenScoreBecomesHigher() + { + // Arrange + var user = await CreateTestUser(); + + var (beatmapSet, beatmap) = await _mocker.Beatmap.MockRandomBeatmapWithSet(); + beatmapSet.IgnoreBeatmapRanking(); + + var existingBest = await CreatePersistedScore(user, beatmap, 1000, SubmissionStatus.Best, "S", 500); + var submittedScore = await CreatePersistedScore(user, beatmap, 800, SubmissionStatus.Submitted, "A", 400); + + const int expectedNewPerformancePoints = 300; + + App.MockHttpClient?.MockPerformanceCalculation(performancePoints: expectedNewPerformancePoints); + + var handler = new ScoreRecalculationHandler(Database, + CreatePipeline(), + Scope.ServiceProvider.GetRequiredService(), + Scope.ServiceProvider.GetRequiredService()); + + // Act + var result = await handler.ExecuteAsync(new ScoreProcessingTask + { + TaskType = ScoreTaskType.Recalculation, + ScoreId = submittedScore.Id + }, + CancellationToken.None); + + // Assert + Assert.True(result.IsSuccess); + + var persistedExistingBest = await Database.Scores.GetScore(existingBest.Id, new QueryOptions(true), false); + Assert.NotNull(persistedExistingBest); + Assert.Equal(SubmissionStatus.Best, persistedExistingBest.SubmissionStatus); + + var persistedSubmitted = await Database.Scores.GetScore(submittedScore.Id, new QueryOptions(true), false); + Assert.NotNull(persistedSubmitted); + Assert.Equal(SubmissionStatus.Submitted, persistedSubmitted.SubmissionStatus); + Assert.Equal(expectedNewPerformancePoints, persistedSubmitted.PerformancePoints); + } + + [Fact] + public async Task TestRecalculationWithMissingBeatmapReturnsBeatmapNotFoundError() + { + // Arrange + var user = await CreateTestUser(); + var score = _mocker.Score.GetBestScoreableRandomScore(); + score.EnrichWithUserData(user); + + score.BeatmapHash = "missing-beatmap-hash"; + + score = await CreateTestScore(score); + + var handler = new ScoreRecalculationHandler(Database, + CreatePipeline(), + Scope.ServiceProvider.GetRequiredService(), + Scope.ServiceProvider.GetRequiredService()); + + // Act + var result = await handler.ExecuteAsync(new ScoreProcessingTask + { + TaskType = ScoreTaskType.Recalculation, + ScoreId = score.Id + }, + CancellationToken.None); + + // Assert + Assert.True(result.IsFailure); + Assert.Equal(ScoreProcessingErrorCode.BeatmapNotFound, result.Error.Code); + } + + private ScoreCommitPipeline CreatePipeline() + { + return new ScoreCommitPipeline(Database, + [ + new LeaderboardProcessor(Database), + new UserGradesScoreProcessor(Database), + new UserStatsScoreProcessor(Database, Scope.ServiceProvider.GetRequiredService()) + ]); + } + + private async Task CreatePersistedScore( + User user, + Beatmap beatmap, + long totalScore, + SubmissionStatus submissionStatus, + string grade, + int maxCombo) + { + var score = _mocker.Score.GetBestScoreableRandomScore(); + score.EnrichWithUserData(user); + score.Mods = Mods.None; + score.TotalScore = totalScore; + score.Grade = grade; + score.MaxCombo = maxCombo; + score.EnrichWithBeatmapData(beatmap); + score.SubmissionStatus = submissionStatus; + score.LocalProperties = score.LocalProperties.FromScore(score); + + return await CreateTestScore(score); + } +} \ No newline at end of file diff --git a/Sunrise.Processing.Tests/Scores/Jobs/ScoreRestorationProcessingJobTests.cs b/Sunrise.Processing.Tests/Scores/Jobs/ScoreRestorationProcessingJobTests.cs new file mode 100644 index 00000000..9e9ab3de --- /dev/null +++ b/Sunrise.Processing.Tests/Scores/Jobs/ScoreRestorationProcessingJobTests.cs @@ -0,0 +1,262 @@ +using Microsoft.Extensions.DependencyInjection; +using Sunrise.Processing.Scores.Handlers; +using Sunrise.Processing.Scores.Pipeline; +using Sunrise.Processing.Scores.Processors; +using Sunrise.Shared.Database.Models; +using Sunrise.Shared.Database.Models.Scores; +using Sunrise.Shared.Database.Models.Users; +using Sunrise.Shared.Database.Objects; +using Sunrise.Shared.Enums.Scores; +using Sunrise.Shared.Extensions; +using Sunrise.Shared.Objects.Serializable; +using Sunrise.Shared.Services; +using Sunrise.Shared.Utils.Calculators; +using Sunrise.Tests.Abstracts; +using Sunrise.Tests.Extensions; +using Sunrise.Tests.Services.Mock; +using Xunit; +using Mods = osu.Shared.Mods; +using SubmissionStatus = Sunrise.Shared.Enums.Scores.SubmissionStatus; + +namespace Sunrise.Processing.Tests.Scores.Jobs; + +[Collection("Integration tests collection")] +public class ScoreRestorationProcessingJobTests(IntegrationDatabaseFixture fixture, bool reuseScopeInContext = true) : DatabaseTest(fixture, reuseScopeInContext) +{ + private readonly MockService _mocker = new(); + + [Fact] + public async Task TestRestorationOfMissingScoreReturnsUnexpectedError() + { + // Arrange + var handler = new ScoreRestorationHandler(Database, CreatePipeline()); + + // Act + var result = await handler.ExecuteAsync(new ScoreProcessingTask + { + TaskType = ScoreTaskType.Restore, + ScoreId = 999_999 + }, + CancellationToken.None); + + // Assert + Assert.True(result.IsFailure); + Assert.Equal(ScoreProcessingErrorCode.Unexpected, result.Error.Code); + } + + [Fact] + public async Task TestRestorationOfNonDeletedScoreReturnsInvalidStateError() + { + // Arrange + var user = await CreateTestUser(); + var beatmapSet = _mocker.Beatmap.GetRandomBeatmapSet(); + beatmapSet.IgnoreBeatmapRanking(); + var beatmap = beatmapSet.Beatmaps!.First(); + + var score = await CreatePersistedScore(user, beatmap, 1000, SubmissionStatus.Best, "S", 500); + + var handler = new ScoreRestorationHandler(Database, CreatePipeline()); + + // Act + var result = await handler.ExecuteAsync(new ScoreProcessingTask + { + TaskType = ScoreTaskType.Restore, + ScoreId = score.Id + }, + CancellationToken.None); + + // Assert + Assert.True(result.IsFailure); + Assert.Equal(ScoreProcessingErrorCode.InvalidScoreState, result.Error.Code); + } + + [Fact] + public async Task TestRestorationOfDeletedScoreWithNoPeersSetsBestStatus() + { + // Arrange + var user = await CreateTestUser(); + var (beatmapSet, beatmap) = await _mocker.Beatmap.MockRandomBeatmapWithSet(); + beatmapSet.IgnoreBeatmapRanking(); + + var score = await CreatePersistedScore(user, beatmap, 1000, SubmissionStatus.Deleted, "S", 500); + + var handler = new ScoreRestorationHandler(Database, CreatePipeline()); + + // Act + var result = await handler.ExecuteAsync(new ScoreProcessingTask + { + TaskType = ScoreTaskType.Restore, + ScoreId = score.Id + }, + CancellationToken.None); + + // Assert + Assert.True(result.IsSuccess); + + var persistedScore = await Database.Scores.GetScore(score.Id, filterValidScores: false); + Assert.NotNull(persistedScore); + Assert.Equal(SubmissionStatus.Best, persistedScore.SubmissionStatus); + } + + [Fact] + public async Task TestRestorationOfBetterDeletedScoreDemotesSameGamemodePeerAndUpdatesGameStatsAndGrades() + { + // Arrange + var user = await CreateTestUser(); + + var (beatmapSet, beatmap) = await _mocker.Beatmap.MockRandomBeatmapWithSet(); + beatmapSet.IgnoreBeatmapRanking(); + + var peerReplacement = await CreatePersistedScore(user, beatmap, 900, SubmissionStatus.Best, "S", 450); + + var userStats = await Database.Users.Stats.GetUserStats(user.Id, peerReplacement.GameMode); + Assert.NotNull(userStats); + + var userGrades = await Database.Users.Grades.GetUserGrades(user.Id, peerReplacement.GameMode); + Assert.NotNull(userGrades); + + userStats.UpdateWithDbScore(peerReplacement); + userGrades.UpdateWithDbScore(peerReplacement); + + var score = await CreatePersistedScore(user, beatmap, 1000, SubmissionStatus.Deleted, "A", 500); + + userStats.UpdateWithDbScore(score); + userGrades.UpdateWithDbScore(score); + + await Database.Users.Stats.UpdateUserStats(userStats, user); + await Database.Users.Grades.UpdateUserGrades(userGrades); + + var handler = new ScoreRestorationHandler(Database, CreatePipeline()); + + // Act + var result = await handler.ExecuteAsync(new ScoreProcessingTask + { + TaskType = ScoreTaskType.Restore, + ScoreId = score.Id + }, + CancellationToken.None); + + // Assert + Assert.True(result.IsSuccess); + + var persistedScore = await Database.Scores.GetScore(score.Id, filterValidScores: false); + Assert.NotNull(persistedScore); + Assert.Equal(SubmissionStatus.Best, persistedScore.SubmissionStatus); + + var persistedPeerReplacement = await Database.Scores.GetScore(peerReplacement.Id, filterValidScores: false); + Assert.NotNull(persistedPeerReplacement); + Assert.Equal(SubmissionStatus.Submitted, persistedPeerReplacement.SubmissionStatus); + + await Database.DbContext.Entry(userStats).ReloadAsync(); + await Database.DbContext.Entry(userGrades).ReloadAsync(); + + Assert.Equal(1, userGrades.GetGradeCount(score.Grade)); + Assert.Equal(0, userGrades.GetGradeCount(peerReplacement.Grade)); + + var (expectedWeightedPerformancePoints, expectedWeightedAccuracy) = (PerformanceCalculator.CalculateUserWeightedPerformance([score]), PerformanceCalculator.CalculateUserWeightedAccuracy([score])); + + Assert.Equal(score.TotalScore + peerReplacement.TotalScore, userStats.TotalScore); + Assert.Equal(score.MaxCombo, userStats.MaxCombo); + Assert.Equal(score.TotalScore, userStats.RankedScore); + Assert.Equal(expectedWeightedPerformancePoints, userStats.PerformancePoints, 6); + Assert.Equal(expectedWeightedAccuracy, userStats.Accuracy, 6); + } + + [Fact] + public async Task TestRestorationOfHigherScoreDemotesExistingBest() + { + // Arrange + var user = await CreateTestUser(); + var (beatmapSet, beatmap) = await _mocker.Beatmap.MockRandomBeatmapWithSet(); + beatmapSet.IgnoreBeatmapRanking(); + + var existingWithLowerScoreBest = await CreatePersistedScore(user, beatmap, 500, SubmissionStatus.Best, "A", 300); + var deletedWithHigherScoreScore = await CreatePersistedScore(user, beatmap, 1000, SubmissionStatus.Deleted, "S", 500); + + var handler = new ScoreRestorationHandler(Database, CreatePipeline()); + + // Act + var result = await handler.ExecuteAsync(new ScoreProcessingTask + { + TaskType = ScoreTaskType.Restore, + ScoreId = deletedWithHigherScoreScore.Id + }, + CancellationToken.None); + + // Assert + Assert.True(result.IsSuccess); + + var persistedDeletedWithHigherScoreScore = await Database.Scores.GetScore(deletedWithHigherScoreScore.Id, filterValidScores: false); + Assert.NotNull(persistedDeletedWithHigherScoreScore); + Assert.Equal(SubmissionStatus.Best, persistedDeletedWithHigherScoreScore.SubmissionStatus); + + var persistedExistingWithLowerScoreBest = await Database.Scores.GetScore(existingWithLowerScoreBest.Id, filterValidScores: false); + Assert.NotNull(persistedExistingWithLowerScoreBest); + Assert.Equal(SubmissionStatus.Submitted, persistedExistingWithLowerScoreBest.SubmissionStatus); + } + + [Fact] + public async Task TestRestorationOfLowerScoreKeepsExistingBest() + { + // Arrange + var user = await CreateTestUser(); + var beatmapSet = _mocker.Beatmap.GetRandomBeatmapSet(); + beatmapSet.IgnoreBeatmapRanking(); + var beatmap = beatmapSet.Beatmaps!.First(); + + var existingWithHigherScoreBest = await CreatePersistedScore(user, beatmap, 1000, SubmissionStatus.Best, "S", 500); + var deletedScoreWithLowerScore = await CreatePersistedScore(user, beatmap, 500, SubmissionStatus.Deleted, "A", 300); + + var handler = new ScoreRestorationHandler(Database, CreatePipeline()); + + // Act + var result = await handler.ExecuteAsync(new ScoreProcessingTask + { + TaskType = ScoreTaskType.Restore, + ScoreId = deletedScoreWithLowerScore.Id + }, + CancellationToken.None); + + // Assert + Assert.True(result.IsSuccess); + + var persistedDeletedWithLowerScore = await Database.Scores.GetScore(deletedScoreWithLowerScore.Id, new QueryOptions(true), false); + Assert.NotNull(persistedDeletedWithLowerScore); + Assert.Equal(SubmissionStatus.Submitted, persistedDeletedWithLowerScore.SubmissionStatus); + + var persistedExistingWithHigherScoreBest = await Database.Scores.GetScore(existingWithHigherScoreBest.Id, new QueryOptions(true), false); + Assert.NotNull(persistedExistingWithHigherScoreBest); + Assert.Equal(SubmissionStatus.Best, persistedExistingWithHigherScoreBest.SubmissionStatus); + } + + private ScoreCommitPipeline CreatePipeline() + { + return new ScoreCommitPipeline(Database, + [ + new LeaderboardProcessor(Database), + new UserGradesScoreProcessor(Database), + new UserStatsScoreProcessor(Database, Scope.ServiceProvider.GetRequiredService()) + ]); + } + + private async Task CreatePersistedScore( + User user, + Beatmap beatmap, + long totalScore, + SubmissionStatus submissionStatus, + string grade, + int maxCombo) + { + var score = _mocker.Score.GetBestScoreableRandomScore(); + score.EnrichWithUserData(user); + score.Mods = Mods.None; + score.TotalScore = totalScore; + score.Grade = grade; + score.MaxCombo = maxCombo; + score.EnrichWithBeatmapData(beatmap); + score.SubmissionStatus = submissionStatus; + score.LocalProperties = score.LocalProperties.FromScore(score); + + return await CreateTestScore(score); + } +} \ No newline at end of file diff --git a/Sunrise.Processing.Tests/Scores/Jobs/ScoreSubmissionProcessingJobTests.cs b/Sunrise.Processing.Tests/Scores/Jobs/ScoreSubmissionProcessingJobTests.cs new file mode 100644 index 00000000..e3f650c7 --- /dev/null +++ b/Sunrise.Processing.Tests/Scores/Jobs/ScoreSubmissionProcessingJobTests.cs @@ -0,0 +1,390 @@ +using Microsoft.Extensions.DependencyInjection; +using osu.Shared; +using Sunrise.Processing.Scores.Handlers; +using Sunrise.Shared.Database.Models.Scores; +using Sunrise.Shared.Enums.Scores; +using Sunrise.Shared.Enums.Users; +using Sunrise.Shared.Extensions; +using Sunrise.Shared.Extensions.Beatmaps; +using Sunrise.Tests.Abstracts; +using Sunrise.Tests.Extensions; +using Sunrise.Tests.Services.Mock; +using Sunrise.Tests.Utils.Processing; +using Xunit; +using SubmissionStatus = Sunrise.Shared.Enums.Scores.SubmissionStatus; + +namespace Sunrise.Processing.Tests.Scores.Jobs; + +[Collection("Integration tests collection")] +public class ScoreSubmissionProcessingJobTests(IntegrationDatabaseFixture fixture, bool reuseScopeInContext = true) : DatabaseTest(fixture, reuseScopeInContext) +{ + private readonly MockService _mocker = new(); + + [Fact] + public async Task TestSubmissionOfNewBestScorePersistsWithBestStatus() + { + // Arrange + var (session, user) = await CreateTestSession(); + var (replay, beatmapId) = GetValidTestReplay(); + var beatmapSet = _mocker.Beatmap.GetRandomBeatmapSet(); + beatmapSet.IgnoreBeatmapRanking(); + var score = replay.GetScore(); + score.BeatmapId = beatmapId; + score.EnrichWithSessionData(session); + beatmapSet.Beatmaps!.First().EnrichWithScoreData(score); + await _mocker.Beatmap.MockBeatmapSet(beatmapSet); + + var replayFileId = await CreateReplayFileId(user.Id); + var queueEntry = ScoreSubmissionRequestTestDataFactory.CreateQueueEntry(score, user.Username, replayFileId: replayFileId); + await Database.ScoreSubmissionRequests.AddQueueEntry(queueEntry); + + App.MockHttpClient?.MockPerformanceCalculation(performancePoints: 300); + + var handler = Scope.ServiceProvider.GetRequiredService(); + + // Act + var result = await handler.ExecuteAsync(new ScoreProcessingTask + { + TaskType = ScoreTaskType.Submission, + ScoreSubmissionRequestId = queueEntry.Id + }, + CancellationToken.None); + + // Assert + Assert.True(result.IsSuccess); + + var persistedScore = await Database.Scores.GetScore(score.ScoreHash); + Assert.NotNull(persistedScore); + Assert.Equal(user.Id, persistedScore.UserId); + Assert.Equal(SubmissionStatus.Best, persistedScore.SubmissionStatus); + Assert.Equal(300, persistedScore.PerformancePoints); + } + + [Fact] + public async Task TestSubmissionOfLowerScorePersistsWithSubmittedStatus() + { + // Arrange + var (session, user) = await CreateTestSession(); + var (replay, beatmapId) = GetValidTestReplay(); + var beatmapSet = _mocker.Beatmap.GetRandomBeatmapSet(); + beatmapSet.IgnoreBeatmapRanking(); + var beatmap = beatmapSet.Beatmaps!.First(); + var score = replay.GetScore(); + score.BeatmapId = beatmapId; + score.Mods = Mods.None; + score.EnrichWithSessionData(session); + score.EnrichWithBeatmapData(beatmap); + beatmap.EnrichWithScoreData(score); + await _mocker.Beatmap.MockBeatmapSet(beatmapSet); + + + var replayFileId = await CreateReplayFileId(user.Id); + var queueEntry = ScoreSubmissionRequestTestDataFactory.CreateQueueEntry(score, user.Username, replayFileId: replayFileId); + await Database.ScoreSubmissionRequests.AddQueueEntry(queueEntry); + + var existingBest = _mocker.Score.GetBestScoreableRandomScore(); + existingBest.EnrichWithUserData(user); + existingBest.Mods = score.Mods; + existingBest.EnrichWithBeatmapData(beatmap); + + existingBest.TotalScore = score.TotalScore + 1000; + existingBest.SubmissionStatus = SubmissionStatus.Best; + + existingBest.LocalProperties = existingBest.LocalProperties.FromScore(existingBest); + await CreateTestScore(existingBest); + + var handler = Scope.ServiceProvider.GetRequiredService(); + + App.MockHttpClient?.MockPerformanceCalculation(performancePoints: 100); + + // Act + var result = await handler.ExecuteAsync(new ScoreProcessingTask + { + TaskType = ScoreTaskType.Submission, + ScoreSubmissionRequestId = queueEntry.Id + }, + CancellationToken.None); + + // Assert + Assert.True(result.IsSuccess); + + var persistedScore = await Database.Scores.GetScore(score.ScoreHash); + Assert.NotNull(persistedScore); + Assert.Equal(SubmissionStatus.Submitted, persistedScore.SubmissionStatus); + } + + [Fact] + public async Task TestSubmissionOfHigherScoreDemotesExistingBest() + { + // Arrange + var (session, user) = await CreateTestSession(); + var (replay, beatmapId) = GetValidTestReplay(); + var beatmapSet = _mocker.Beatmap.GetRandomBeatmapSet(); + beatmapSet.IgnoreBeatmapRanking(); + var beatmap = beatmapSet.Beatmaps!.First(); + var score = replay.GetScore(); + score.BeatmapId = beatmapId; + score.Mods = Mods.None; + score.EnrichWithSessionData(session); + + + score.TotalScore = 1000; + + beatmap.EnrichWithScoreData(score); + score.EnrichWithBeatmapData(beatmap); + await _mocker.Beatmap.MockBeatmapSet(beatmapSet); + + var replayFileId = await CreateReplayFileId(user.Id); + var queueEntry = ScoreSubmissionRequestTestDataFactory.CreateQueueEntry(score, user.Username, replayFileId: replayFileId); + await Database.ScoreSubmissionRequests.AddQueueEntry(queueEntry); + + var existingBest = _mocker.Score.GetBestScoreableRandomScore(); + existingBest.EnrichWithUserData(user); + existingBest.TotalScore = score.TotalScore - 100; + existingBest.Mods = score.Mods; + existingBest.EnrichWithBeatmapData(beatmap); + existingBest.SubmissionStatus = SubmissionStatus.Best; + existingBest.LocalProperties = existingBest.LocalProperties.FromScore(existingBest); + existingBest = await CreateTestScore(existingBest); + + var handler = Scope.ServiceProvider.GetRequiredService(); + + App.MockHttpClient?.MockPerformanceCalculation(performancePoints: 500); + + // Act + var result = await handler.ExecuteAsync(new ScoreProcessingTask + { + TaskType = ScoreTaskType.Submission, + ScoreSubmissionRequestId = queueEntry.Id + }, + CancellationToken.None); + + // Assert + Assert.True(result.IsSuccess); + + var persistedNewScore = await Database.Scores.GetScore(score.ScoreHash); + var persistedOldBest = await Database.Scores.GetScore(existingBest.Id, filterValidScores: false); + Assert.NotNull(persistedNewScore); + Assert.NotNull(persistedOldBest); + Assert.Equal(SubmissionStatus.Best, persistedNewScore.SubmissionStatus); + Assert.Equal(SubmissionStatus.Submitted, persistedOldBest.SubmissionStatus); + } + + [Fact] + public async Task TestSubmissionOfHigherScoreDoesntDemotesExistingDifferentGamemodeBest() + { + // Arrange + var (session, user) = await CreateTestSession(); + var (replay, beatmapId) = GetValidTestReplay(); + var beatmapSet = _mocker.Beatmap.GetRandomBeatmapSet(); + beatmapSet.IgnoreBeatmapRanking(); + var beatmap = beatmapSet.Beatmaps!.First(); + var score = replay.GetScore(); + score.BeatmapId = beatmapId; + score.EnrichWithSessionData(session); + + score.TotalScore = 999_999_999; + + score.GameMode = score.GameMode.EnrichWithMods(score.Mods); + beatmap.EnrichWithScoreData(score); + await _mocker.Beatmap.MockBeatmapSet(beatmapSet); + + + var replayFileId = await CreateReplayFileId(user.Id); + var queueEntry = ScoreSubmissionRequestTestDataFactory.CreateQueueEntry(score, user.Username, replayFileId: replayFileId); + await Database.ScoreSubmissionRequests.AddQueueEntry(queueEntry); + + // Create an existing best score with lower total score, but different gamemode. + var existingBest = _mocker.Score.GetBestScoreableRandomScore(); + existingBest.EnrichWithUserData(user); + existingBest.TotalScore = 100; + existingBest.Mods = Mods.Relax; + existingBest.EnrichWithBeatmapData(beatmap); + existingBest.SubmissionStatus = SubmissionStatus.Best; + existingBest.GameMode = existingBest.GameMode.EnrichWithMods(existingBest.Mods); + existingBest.LocalProperties = existingBest.LocalProperties.FromScore(existingBest); + existingBest = await CreateTestScore(existingBest); + + var handler = Scope.ServiceProvider.GetRequiredService(); + + App.MockHttpClient?.MockPerformanceCalculation(performancePoints: 500); + + // Act + var result = await handler.ExecuteAsync(new ScoreProcessingTask + { + TaskType = ScoreTaskType.Submission, + ScoreSubmissionRequestId = queueEntry.Id + }, + CancellationToken.None); + + // Assert + Assert.True(result.IsSuccess); + + var persistedNewScore = await Database.Scores.GetScore(score.ScoreHash); + var persistedOldBest = await Database.Scores.GetScore(existingBest.Id, filterValidScores: false); + Assert.NotNull(persistedNewScore); + Assert.NotNull(persistedOldBest); + + // Both should have their own best status + Assert.Equal(SubmissionStatus.Best, persistedNewScore.SubmissionStatus); + Assert.Equal(SubmissionStatus.Best, persistedOldBest.SubmissionStatus); + } + + [Fact] + public async Task TestSubmissionWithMissingBeatmapReturnsBeatmapNotFoundError() + { + // Arrange + var (session, user) = await CreateTestSession(); + var score = _mocker.Score.GetBestScoreableRandomScore(); + score.EnrichWithUserData(user); + score.BeatmapHash = "nonexistent-hash"; + + var replayFileId = await CreateReplayFileId(user.Id); + var queueEntry = ScoreSubmissionRequestTestDataFactory.CreateQueueEntry(score, user.Username, replayFileId: replayFileId); + await Database.ScoreSubmissionRequests.AddQueueEntry(queueEntry); + + var handler = Scope.ServiceProvider.GetRequiredService(); + + // Act + var result = await handler.ExecuteAsync(new ScoreProcessingTask + { + TaskType = ScoreTaskType.Submission, + ScoreSubmissionRequestId = queueEntry.Id + }, + CancellationToken.None); + + // Assert + Assert.True(result.IsFailure); + Assert.Equal(ScoreProcessingErrorCode.BeatmapNotFound, result.Error.Code); + } + + [Fact] + public async Task TestSubmissionOfDuplicateScoreHashReturnsDuplicateError() + { + // Arrange + var (session, user) = await CreateTestSession(); + var (replay, beatmapId) = GetValidTestReplay(); + var beatmapSet = _mocker.Beatmap.GetRandomBeatmapSet(); + beatmapSet.IgnoreBeatmapRanking(); + var score = replay.GetScore(); + score.BeatmapId = beatmapId; + score.EnrichWithSessionData(session); + beatmapSet.Beatmaps!.First().EnrichWithScoreData(score); + await _mocker.Beatmap.MockBeatmapSet(beatmapSet); + + var replayFileId = await CreateReplayFileId(user.Id); + var queueEntry = ScoreSubmissionRequestTestDataFactory.CreateQueueEntry(score, user.Username, replayFileId: replayFileId); + await Database.ScoreSubmissionRequests.AddQueueEntry(queueEntry); + + var handler = Scope.ServiceProvider.GetRequiredService(); + + App.MockHttpClient?.MockPerformanceCalculation(performancePoints: 200); + + var firstResult = await handler.ExecuteAsync(new ScoreProcessingTask + { + TaskType = ScoreTaskType.Submission, + ScoreSubmissionRequestId = queueEntry.Id + }, + CancellationToken.None); + Assert.True(firstResult.IsSuccess); + + // Act + var duplicateResult = await handler.ExecuteAsync(new ScoreProcessingTask + { + TaskType = ScoreTaskType.Submission, + ScoreSubmissionRequestId = queueEntry.Id + }, + CancellationToken.None); + + // Assert + Assert.True(duplicateResult.IsFailure); + Assert.Equal(ScoreProcessingErrorCode.DuplicateScore, duplicateResult.Error.Code); + } + + [Fact] + public async Task TestSubmissionOfFailedScorePersistsWithFailedStatus() + { + // Arrange + var (session, user) = await CreateTestSession(); + var (replay, beatmapId) = GetValidTestReplay(); + var score = replay.GetScore(); + score.BeatmapId = beatmapId; + score.EnrichWithSessionData(session); + score.IsPassed = false; + score.Grade = "F"; + score.Mods = Mods.None; + score.SubmissionStatus = SubmissionStatus.Failed; + score.CountMiss = Math.Max(score.CountMiss, 1); + score.LocalProperties = score.LocalProperties.FromScore(score); + + var beatmapSet = _mocker.Beatmap.GetRandomBeatmapSet(); + beatmapSet.IgnoreBeatmapRanking(); + var beatmap = beatmapSet.Beatmaps!.First(); + beatmap.EnrichWithScoreData(score); + score.EnrichWithBeatmapData(beatmap); + await _mocker.Beatmap.MockBeatmapSet(beatmapSet); + + var queueEntry = ScoreSubmissionRequestTestDataFactory.CreateQueueEntry(score, user.Username, replayFileId: null); + await Database.ScoreSubmissionRequests.AddQueueEntry(queueEntry); + + var handler = Scope.ServiceProvider.GetRequiredService(); + + App.MockHttpClient?.MockPerformanceCalculation(performancePoints: 0); + + // Act + var result = await handler.ExecuteAsync(new ScoreProcessingTask + { + TaskType = ScoreTaskType.Submission, + ScoreSubmissionRequestId = queueEntry.Id + }, + CancellationToken.None); + + // Assert + Assert.True(result.IsSuccess); + + var persistedScore = await Database.Scores.GetScore(score.ScoreHash); + Assert.NotNull(persistedScore); + Assert.False(persistedScore.IsPassed); + Assert.Equal(SubmissionStatus.Failed, persistedScore.SubmissionStatus); + } + + [Fact] + public async Task TestSubmissionWithInvalidChecksumsRestrictsUserAndReturnsInvalidChecksums() + { + // Arrange + var (session, user) = await CreateTestSession(); + var (replay, beatmapId) = GetValidTestReplay(); + var score = replay.GetScore(); + score.BeatmapId = beatmapId; + score.EnrichWithSessionData(session); + + var beatmapSet = _mocker.Beatmap.GetRandomBeatmapSet(); + beatmapSet.IgnoreBeatmapRanking(); + var beatmap = beatmapSet.Beatmaps!.First(); + beatmap.EnrichWithScoreData(score); + await _mocker.Beatmap.MockBeatmapSet(beatmapSet); + + var replayFileId = await CreateReplayFileId(user.Id); + var queueEntry = ScoreSubmissionRequestTestDataFactory.CreateQueueEntry(score, user.Username, replayFileId: replayFileId); + queueEntry.UserHash = "other-user-hash"; + await Database.ScoreSubmissionRequests.AddQueueEntry(queueEntry); + + var handler = Scope.ServiceProvider.GetRequiredService(); + + // Act + var result = await handler.ExecuteAsync(new ScoreProcessingTask + { + TaskType = ScoreTaskType.Submission, + ScoreSubmissionRequestId = queueEntry.Id + }, + CancellationToken.None); + + // Assert + Assert.True(result.IsFailure); + Assert.Equal(ScoreProcessingErrorCode.InvalidChecksums, result.Error.Code); + + var refreshedUser = await Database.Users.GetUser(user.Id); + Assert.NotNull(refreshedUser); + Assert.Equal(UserAccountStatus.Restricted, refreshedUser.AccountStatus); + } +} \ No newline at end of file diff --git a/Sunrise.Processing.Tests/Scores/Pipeline/ScoreCommitPipelineTests.cs b/Sunrise.Processing.Tests/Scores/Pipeline/ScoreCommitPipelineTests.cs new file mode 100644 index 00000000..a592276f --- /dev/null +++ b/Sunrise.Processing.Tests/Scores/Pipeline/ScoreCommitPipelineTests.cs @@ -0,0 +1,850 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using osu.Shared; +using Sunrise.Processing.Scores.Pipeline; +using Sunrise.Processing.Scores.Processors; +using Sunrise.Shared.Database; +using Sunrise.Shared.Database.Models; +using Sunrise.Shared.Database.Models.Scores; +using Sunrise.Shared.Database.Models.Users; +using Sunrise.Shared.Enums.Beatmaps; +using Sunrise.Shared.Enums.Scores; +using Sunrise.Shared.Enums.Users; +using Sunrise.Shared.Extensions; +using Sunrise.Shared.Extensions.Beatmaps; +using Sunrise.Shared.Objects.Serializable; +using Sunrise.Shared.Services; +using Sunrise.Shared.Utils.Calculators; +using Sunrise.Tests.Abstracts; +using Sunrise.Tests.Extensions; +using Sunrise.Tests.Services.Mock; +using Xunit; +using SubmissionStatus = Sunrise.Shared.Enums.Scores.SubmissionStatus; +using GameMode = Sunrise.Shared.Enums.Beatmaps.GameMode; + +namespace Sunrise.Processing.Tests.Scores.Pipeline; + +[Collection("Integration tests collection")] +public class ScoreCommitPipelineTests(IntegrationDatabaseFixture fixture) : DatabaseTest(fixture) +{ + private readonly MockService _mocker = new(); + + [Fact] + public async Task TestCommitSubmissionCapturesOriginalStateEnrichesBeatmapStatusAndPersistsMutations() + { + // Arrange + using var pipelineScope = App.Server.Services.CreateScope(); + var pipeline = CreatePipeline(pipelineScope.ServiceProvider); + var user = await CreateTestUser(); + var beatmapSet = _mocker.Beatmap.GetRandomBeatmapSet(); + beatmapSet.IgnoreBeatmapRanking(); + var beatmap = beatmapSet.Beatmaps!.First(); + + var score = _mocker.Score.GetBestScoreableRandomScore(); + EnrichScore(score, user, beatmap); + score.Grade = "A"; + score.SubmissionStatus = SubmissionStatus.Submitted; + score.IsScoreable = false; + score.BeatmapStatus = BeatmapStatus.Pending; + score.LocalProperties = score.LocalProperties.FromScore(score); + + var (userStats, userGrades) = await LoadUserState(user, score.GameMode); + var context = new ScoreCommitContext(ScoreTaskType.Submission, score, user, userStats, userGrades, beatmap); + var (expectedWeightedPerformancePoints, expectedWeightedAccuracy) = (PerformanceCalculator.CalculateUserWeightedPerformance([score]), PerformanceCalculator.CalculateUserWeightedAccuracy([score])); + + // Act + var result = await pipeline.Commit(context, null, CancellationToken.None); + + // Assert + Assert.True(result.IsSuccess); + Assert.False(context.OriginalState.IsScoreable); + Assert.False(context.OriginalState.IsRanked); + Assert.Equal(SubmissionStatus.Submitted, context.OriginalState.SubmissionStatus); + + var persistedScore = await Database.Scores.GetScore(score.ScoreHash); + Assert.NotNull(persistedScore); + Assert.Equal(BeatmapStatus.Ranked, persistedScore.BeatmapStatus); + Assert.True(persistedScore.IsScoreable); + Assert.Equal(SubmissionStatus.Best, persistedScore.SubmissionStatus); + + var persistedUserStats = await Database.Users.Stats.GetUserStats(user.Id, score.GameMode); + var persistedUserGrades = await Database.Users.Grades.GetUserGrades(user.Id, score.GameMode); + Assert.NotNull(persistedUserStats); + Assert.NotNull(persistedUserGrades); + Assert.Equal(score.TotalScore, persistedUserStats.RankedScore); + Assert.Equal(score.MaxCombo, persistedUserStats.MaxCombo); + Assert.Equal(expectedWeightedPerformancePoints, persistedUserStats.PerformancePoints, 6); + Assert.Equal(expectedWeightedAccuracy, persistedUserStats.Accuracy, 6); + Assert.Equal(1, persistedUserGrades.CountA); + } + + [Fact] + public async Task TestCommitDeletionPromotesReplacementAndPersistsGrades() + { + // Arrange + using var pipelineScope = App.Server.Services.CreateScope(); + var pipeline = CreatePipeline(pipelineScope.ServiceProvider, false); + var user = await CreateTestUser(); + var beatmapSet = _mocker.Beatmap.GetRandomBeatmapSet(); + beatmapSet.IgnoreBeatmapRanking(); + var beatmap = beatmapSet.Beatmaps!.First(); + + var replacement = await CreatePersistedScore(user, beatmap, 900, SubmissionStatus.Submitted, "S", 450); + var score = await CreatePersistedScore(user, beatmap, 1000, SubmissionStatus.Best, "A", 500); + var (userStats, userGrades) = await LoadUserState(user, score.GameMode); + + userGrades.CountA = 1; + + var context = new ScoreCommitContext(ScoreTaskType.Delete, score, user, userStats, userGrades); + + // Act + var result = await pipeline.Commit(context, null, CancellationToken.None); + + // Assert + Assert.True(result.IsSuccess); + + var persistedScore = await Database.Scores.GetScore(score.Id, filterValidScores: false); + var persistedReplacement = await Database.Scores.GetScore(replacement.Id, filterValidScores: false); + var persistedUserGrades = await Database.Users.Grades.GetUserGrades(user.Id, score.GameMode); + + Assert.NotNull(persistedScore); + Assert.NotNull(persistedReplacement); + Assert.NotNull(persistedUserGrades); + Assert.Equal(SubmissionStatus.Deleted, persistedScore.SubmissionStatus); + Assert.Equal(SubmissionStatus.Best, persistedReplacement.SubmissionStatus); + Assert.Equal(0, persistedUserGrades.CountA); + Assert.Equal(1, persistedUserGrades.CountS); + } + + [Fact] + public async Task TestCommitRestorationRestoresBestScoreAndSwapsGradeCounts() + { + // Arrange + using var pipelineScope = App.Server.Services.CreateScope(); + var pipeline = CreatePipeline(pipelineScope.ServiceProvider, false); + var user = await CreateTestUser(); + var beatmapSet = _mocker.Beatmap.GetRandomBeatmapSet(); + beatmapSet.IgnoreBeatmapRanking(); + var beatmap = beatmapSet.Beatmaps!.First(); + + var previousBest = await CreatePersistedScore(user, beatmap, 900, SubmissionStatus.Best, "S", 450); + var score = await CreatePersistedScore(user, beatmap, 1000, SubmissionStatus.Deleted, "A", 500); + var (userStats, userGrades) = await LoadUserState(user, score.GameMode); + + userGrades.CountS = 1; + + var context = new ScoreCommitContext(ScoreTaskType.Restore, score, user, userStats, userGrades); + + // Act + var result = await pipeline.Commit(context, null, CancellationToken.None); + + // Assert + Assert.True(result.IsSuccess); + + var persistedScore = await Database.Scores.GetScore(score.Id, filterValidScores: false); + var persistedPreviousBest = await Database.Scores.GetScore(previousBest.Id, filterValidScores: false); + var persistedUserGrades = await Database.Users.Grades.GetUserGrades(user.Id, score.GameMode); + + Assert.NotNull(persistedScore); + Assert.NotNull(persistedPreviousBest); + Assert.NotNull(persistedUserGrades); + Assert.Equal(SubmissionStatus.Best, persistedScore.SubmissionStatus); + Assert.Equal(SubmissionStatus.Submitted, persistedPreviousBest.SubmissionStatus); + Assert.Equal(0, persistedUserGrades.CountS); + Assert.Equal(1, persistedUserGrades.CountA); + } + + [Fact] + public async Task TestCommitWithLostClaimLeaseRollsBackMutations() + { + // Arrange + using var pipelineScope = App.Server.Services.CreateScope(); + var pipeline = CreatePipeline(pipelineScope.ServiceProvider); + var user = await CreateTestUser(); + var beatmapSet = _mocker.Beatmap.GetRandomBeatmapSet(); + beatmapSet.IgnoreBeatmapRanking(); + var beatmap = beatmapSet.Beatmaps!.First(); + + var score = _mocker.Score.GetBestScoreableRandomScore(); + EnrichScore(score, user, beatmap); + score.LocalProperties = score.LocalProperties.FromScore(score); + + var (userStats, userGrades) = await LoadUserState(user, score.GameMode); + var payload = await CreatePayload(user.Id); + var persistedTask = await CreateTask(ScoreTaskType.Submission, scoreSubmissionRequestId: payload.Id, claimToken: "expected-token", leaseExpiresAt: DateTime.UtcNow.AddMinutes(1)); + var mismatchedTask = new ScoreProcessingTask + { + Id = persistedTask.Id, + TaskType = persistedTask.TaskType, + ClaimToken = "wrong-token" + }; + + var context = new ScoreCommitContext(ScoreTaskType.Submission, score, user, userStats, userGrades, beatmap); + + // Act + var result = await pipeline.Commit(context, mismatchedTask, CancellationToken.None); + + // Assert + Assert.True(result.IsFailure); + Assert.Contains("claim lost", result.Error, StringComparison.OrdinalIgnoreCase); + + var persistedScore = await Database.Scores.GetScore(score.ScoreHash); + var persistedUserStats = await Database.Users.Stats.GetUserStats(user.Id, score.GameMode); + var persistedUserGrades = await Database.Users.Grades.GetUserGrades(user.Id, score.GameMode); + var refreshedTask = await Database.DbContext.ScoreProcessingTasks.AsNoTracking().FirstAsync(x => x.Id == persistedTask.Id); + + Assert.Null(persistedScore); + Assert.NotNull(persistedUserStats); + Assert.NotNull(persistedUserGrades); + Assert.Equal(0, persistedUserStats.TotalScore); + Assert.Equal(0, persistedUserStats.RankedScore); + Assert.Equal(0, persistedUserGrades.CountA); + Assert.Equal("expected-token", refreshedTask.ClaimToken); + } + + [Fact] + public async Task TestCommitDeletionUpdatesUserStatsAndRank() + { + // Arrange + using var pipelineScope = App.Server.Services.CreateScope(); + var pipeline = CreatePipeline(pipelineScope.ServiceProvider); + var calculator = pipelineScope.ServiceProvider.GetRequiredService(); + var user = await CreateTestUser(); + var beatmapSet = _mocker.Beatmap.GetRandomBeatmapSet(); + beatmapSet.IgnoreBeatmapRanking(); + var beatmap = beatmapSet.Beatmaps!.First(); + + // Two best scores: deleting the higher one should reduce ranked score + var lowerScore = await CreatePersistedScore(user, beatmap, 800, SubmissionStatus.Submitted, "B", 300); + var score = await CreatePersistedScore(user, beatmap, 1200, SubmissionStatus.Best, "A", 500); + var (userStats, userGrades) = await LoadUserState(user, score.GameMode); + + // Seed user stats as if the score was already counted + userStats.TotalScore = score.TotalScore + lowerScore.TotalScore; + userStats.RankedScore = score.TotalScore; + userStats.MaxCombo = score.MaxCombo; + userStats.PlayCount = 2; + userStats.PlayTime = score.TimeElapsed + lowerScore.TimeElapsed; + userStats.TotalHits = score.Count300 + score.Count100 + score.Count50 + lowerScore.Count300 + lowerScore.Count100 + lowerScore.Count50; + var seededWeighted = await calculator.CalculateUserWeightedStats(user, score.GameMode); + userStats.PerformancePoints = seededWeighted.PerformancePoints; + userStats.Accuracy = seededWeighted.Accuracy; + userGrades.CountA = 1; + + var rankedScoreBefore = userStats.RankedScore; + var playCountBefore = userStats.PlayCount; + + var context = new ScoreCommitContext(ScoreTaskType.Delete, score, user, userStats, userGrades); + + // Act + var result = await pipeline.Commit(context, null, CancellationToken.None); + + // Assert + Assert.True(result.IsSuccess); + + var persistedUserStats = await Database.Users.Stats.GetUserStats(user.Id, score.GameMode); + var persistedUserGrades = await Database.Users.Grades.GetUserGrades(user.Id, score.GameMode); + Assert.NotNull(persistedUserStats); + Assert.NotNull(persistedUserGrades); + + // After deleting the best score: ranked score should decrease, play count decremented + Assert.True(persistedUserStats.RankedScore < rankedScoreBefore, "RankedScore should decrease after deleting the best score"); + Assert.Equal(playCountBefore - 1, persistedUserStats.PlayCount); + Assert.Equal(0, persistedUserGrades.CountA); + } + + [Fact] + public async Task TestCommitRecalculationUpdatesUserStatsWeightedValues() + { + // Arrange + using var pipelineScope = App.Server.Services.CreateScope(); + var pipeline = CreatePipeline(pipelineScope.ServiceProvider); + var calculator = pipelineScope.ServiceProvider.GetRequiredService(); + var user = await CreateTestUser(); + var beatmapSet = _mocker.Beatmap.GetRandomBeatmapSet(); + beatmapSet.IgnoreBeatmapRanking(); + var beatmap = beatmapSet.Beatmaps!.First(); + + var score = await CreatePersistedScore(user, beatmap, 1000, SubmissionStatus.Best, "A", 400); + var (userStats, userGrades) = await LoadUserState(user, score.GameMode); + + // Seed with old values so we can detect the refresh + userStats.PerformancePoints = 999; + userStats.Accuracy = 50; + + var context = new ScoreCommitContext(ScoreTaskType.Recalculation, score, user, userStats, userGrades); + + // Act + var result = await pipeline.Commit(context, null, CancellationToken.None); + + // Assert + Assert.True(result.IsSuccess); + + var persistedUserStats = await Database.Users.Stats.GetUserStats(user.Id, score.GameMode); + Assert.NotNull(persistedUserStats); + + var expectedWeighted = await calculator.CalculateUserWeightedStats(user, score.GameMode); + Assert.Equal(expectedWeighted.PerformancePoints, persistedUserStats.PerformancePoints, 6); + Assert.Equal(expectedWeighted.Accuracy, persistedUserStats.Accuracy, 6); + } + + [Fact] + public async Task TestCommitRecalculationDemotionUsesPromotedBestForWeightedValues() + { + // Arrange + using var pipelineScope = App.Server.Services.CreateScope(); + var pipeline = CreatePipeline(pipelineScope.ServiceProvider); + var calculator = pipelineScope.ServiceProvider.GetRequiredService(); + + EnvManager.Set("General:UseNewPerformanceCalculationAlgorithm", "true"); // We want target by new PP values + + var user = await CreateTestUser(); + var beatmapSet = _mocker.Beatmap.GetRandomBeatmapSet(); + beatmapSet.IgnoreBeatmapRanking(); + var beatmap = beatmapSet.Beatmaps!.First(); + + var promotedPeer = _mocker.Score.GetBestScoreableRandomScore(); + EnrichScore(promotedPeer, user, beatmap); + promotedPeer.TotalScore = 700; + promotedPeer.PerformancePoints = 150; + promotedPeer.MaxCombo = 300; + promotedPeer.SubmissionStatus = SubmissionStatus.Submitted; + promotedPeer.LocalProperties = promotedPeer.LocalProperties.FromScore(promotedPeer); + promotedPeer = await CreateTestScore(promotedPeer); + + var score = _mocker.Score.GetBestScoreableRandomScore(); + EnrichScore(score, user, beatmap); + score.TotalScore = 900; + score.PerformancePoints = 200; + score.MaxCombo = 350; + score.SubmissionStatus = SubmissionStatus.Best; + score.LocalProperties = score.LocalProperties.FromScore(score); + score = await CreateTestScore(score); + + var (userStats, userGrades) = await LoadUserState(user, score.GameMode); + var weightedBefore = await calculator.CalculateUserWeightedStats(user, score.GameMode); + userStats.PerformancePoints = weightedBefore.PerformancePoints; + userStats.Accuracy = weightedBefore.Accuracy; + + score.PerformancePoints = 100; + + var context = new ScoreCommitContext(ScoreTaskType.Recalculation, score, user, userStats, userGrades, beatmap); + + // Act + var result = await pipeline.Commit(context, null, CancellationToken.None); + + // Assert + Assert.True(result.IsSuccess); + + var persistedScore = await Database.Scores.GetScore(score.Id, filterValidScores: false); + var persistedPromotedPeer = await Database.Scores.GetScore(promotedPeer.Id, filterValidScores: false); + var persistedUserStats = await Database.Users.Stats.GetUserStats(user.Id, score.GameMode); + + Assert.NotNull(persistedScore); + Assert.NotNull(persistedPromotedPeer); + Assert.NotNull(persistedUserStats); + Assert.Equal(SubmissionStatus.Best, persistedScore.SubmissionStatus); + Assert.Equal(SubmissionStatus.Submitted, persistedPromotedPeer.SubmissionStatus); + + var expectedWeighted = await calculator.CalculateUserWeightedStats(user, score.GameMode); + Assert.Equal(expectedWeighted.PerformancePoints, persistedUserStats.PerformancePoints, 6); + Assert.Equal(expectedWeighted.Accuracy, persistedUserStats.Accuracy, 6); + } + + [Fact] + public async Task TestCommitRecalculationDemotionUsesPromotedBestForWeightedValuesIfUpdateSubmissionStatus() + { + // Arrange + using var pipelineScope = App.Server.Services.CreateScope(); + var pipeline = CreatePipeline(pipelineScope.ServiceProvider); + var calculator = pipelineScope.ServiceProvider.GetRequiredService(); + + var user = await CreateTestUser(); + var beatmapSet = _mocker.Beatmap.GetRandomBeatmapSet(); + beatmapSet.IgnoreBeatmapRanking(); + var beatmap = beatmapSet.Beatmaps!.First(); + + var promotedPeer = _mocker.Score.GetBestScoreableRandomScore(); + EnrichScore(promotedPeer, user, beatmap, Mods.Relax); + promotedPeer.TotalScore = 700; + promotedPeer.PerformancePoints = 150; + promotedPeer.MaxCombo = 300; + promotedPeer.SubmissionStatus = SubmissionStatus.Submitted; + promotedPeer.LocalProperties = promotedPeer.LocalProperties.FromScore(promotedPeer); + promotedPeer = await CreateTestScore(promotedPeer); + + var score = _mocker.Score.GetBestScoreableRandomScore(); + EnrichScore(score, user, beatmap, Mods.Relax); + score.TotalScore = 900; + score.PerformancePoints = 200; + score.MaxCombo = 350; + score.SubmissionStatus = SubmissionStatus.Best; + score.LocalProperties = score.LocalProperties.FromScore(score); + score = await CreateTestScore(score); + + var (userStats, userGrades) = await LoadUserState(user, score.GameMode); + var weightedBefore = await calculator.CalculateUserWeightedStats(user, score.GameMode); + userStats.PerformancePoints = weightedBefore.PerformancePoints; + userStats.Accuracy = weightedBefore.Accuracy; + + var context = new ScoreCommitContext(ScoreTaskType.Recalculation, score, user, userStats, userGrades, beatmap); + + // Act + var result = await pipeline.Commit(context, null, CancellationToken.None); + + // Assert + Assert.True(result.IsSuccess); + + var persistedScore = await Database.Scores.GetScore(score.Id, filterValidScores: false); + var persistedPromotedPeer = await Database.Scores.GetScore(promotedPeer.Id, filterValidScores: false); + var persistedUserStats = await Database.Users.Stats.GetUserStats(user.Id, score.GameMode); + + Assert.NotNull(persistedScore); + Assert.NotNull(persistedPromotedPeer); + Assert.NotNull(persistedUserStats); + Assert.Equal(SubmissionStatus.Best, persistedScore.SubmissionStatus); + Assert.Equal(SubmissionStatus.Submitted, persistedPromotedPeer.SubmissionStatus); + + var expectedWeighted = await calculator.CalculateUserWeightedStats(user, score.GameMode); + Assert.Equal(expectedWeighted.PerformancePoints, persistedUserStats.PerformancePoints, 6); + Assert.Equal(expectedWeighted.Accuracy, persistedUserStats.Accuracy, 6); + } + + + [Fact] + public async Task TestCommitSubmissionUpdatesUserRankInLeaderboard() + { + // Arrange + using var pipelineScope = App.Server.Services.CreateScope(); + var pipeline = CreatePipeline(pipelineScope.ServiceProvider); + var user = await CreateTestUser(); + var beatmapSet = _mocker.Beatmap.GetRandomBeatmapSet(); + beatmapSet.IgnoreBeatmapRanking(); + var beatmap = beatmapSet.Beatmaps!.First(); + + var score = _mocker.Score.GetBestScoreableRandomScore(); + EnrichScore(score, user, beatmap); + score.Grade = "S"; + score.PerformancePoints = 500; + score.SubmissionStatus = SubmissionStatus.Submitted; + score.IsScoreable = false; + score.BeatmapStatus = BeatmapStatus.Pending; + score.LocalProperties = score.LocalProperties.FromScore(score); + + var (userStats, userGrades) = await LoadUserState(user, score.GameMode); + var rankBefore = userStats.LocalProperties.Rank; + + var context = new ScoreCommitContext(ScoreTaskType.Submission, score, user, userStats, userGrades, beatmap); + + // Act + var result = await pipeline.Commit(context, null, CancellationToken.None); + + // Assert + Assert.True(result.IsSuccess); + } + + [Fact] + public async Task TestCommitSubmissionUpdatesGlobalAndCountryRank() + { + // Arrange + var userA = _mocker.User.GetRandomUser(_mocker.User.GetRandomUsername()); + userA.Country = CountryCode.US; + userA = await CreateTestUser(userA); + + var userB = _mocker.User.GetRandomUser(_mocker.User.GetRandomUsername()); + userB.Country = CountryCode.US; + userB = await CreateTestUser(userB); + + var beatmapSet = _mocker.Beatmap.GetRandomBeatmapSet(); + beatmapSet.IgnoreBeatmapRanking(); + var beatmap = beatmapSet.Beatmaps!.First(); + + // Give User A a persisted score with 100pp + var scoreA = _mocker.Score.GetBestScoreableRandomScore(); + EnrichScore(scoreA, userA, beatmap); + scoreA.PerformancePoints = 100; + scoreA.LocalProperties = scoreA.LocalProperties.FromScore(scoreA); + await Database.Scores.AddScore(scoreA); + + var userStatsA = await Database.Users.Stats.GetUserStats(userA.Id, GameMode.Standard); + Assert.NotNull(userStatsA); + userStatsA.UpdateWithDbScore(scoreA); + userStatsA.PerformancePoints = 100; + await Database.Users.Stats.UpdateUserStats(userStatsA, userA); + + // User A should be rank 1 + var (globalRankA, countryRankA) = await Database.Users.Stats.Ranks.GetUserRanks(userA, GameMode.Standard); + Assert.Equal(1, globalRankA); + Assert.Equal(1, countryRankA); + + // Create pipeline scope AFTER seeding data + using var pipelineScope = App.Server.Services.CreateScope(); + var pipeline = CreatePipeline(pipelineScope.ServiceProvider); + + // User B submits a score with higher PP (200) via pipeline + var scoreB = _mocker.Score.GetBestScoreableRandomScore(); + EnrichScore(scoreB, userB, beatmap); + scoreB.PerformancePoints = 200; + scoreB.SubmissionStatus = SubmissionStatus.Submitted; + scoreB.IsScoreable = false; + scoreB.BeatmapStatus = BeatmapStatus.Pending; + scoreB.LocalProperties = scoreB.LocalProperties.FromScore(scoreB); + + var (userStatsB, userGradesB) = await LoadUserState(userB, GameMode.Standard); + var context = new ScoreCommitContext(ScoreTaskType.Submission, scoreB, userB, userStatsB, userGradesB, beatmap); + + // Act + var result = await pipeline.Commit(context, null, CancellationToken.None); + + // Assert + Assert.True(result.IsSuccess); + + var (globalRankAAfter, countryRankAAfter) = await Database.Users.Stats.Ranks.GetUserRanks(userA, GameMode.Standard); + var (globalRankBAfter, countryRankBAfter) = await Database.Users.Stats.Ranks.GetUserRanks(userB, GameMode.Standard); + + // User B (200pp) should now be rank 1, User A (100pp) should be rank 2 + Assert.Equal(1, globalRankBAfter); + Assert.Equal(2, globalRankAAfter); + Assert.Equal(1, countryRankBAfter); + Assert.Equal(2, countryRankAAfter); + } + + [Fact] + public async Task TestCommitDeletionUpdatesGlobalAndCountryRank() + { + // Arrange + var userA = _mocker.User.GetRandomUser(_mocker.User.GetRandomUsername()); + userA.Country = CountryCode.US; + userA = await CreateTestUser(userA); + + var userB = _mocker.User.GetRandomUser(_mocker.User.GetRandomUsername()); + userB.Country = CountryCode.US; + userB = await CreateTestUser(userB); + + var beatmapSet = _mocker.Beatmap.GetRandomBeatmapSet(); + beatmapSet.IgnoreBeatmapRanking(); + var beatmap = beatmapSet.Beatmaps!.First(); + + var beatmapSet2 = _mocker.Beatmap.GetRandomBeatmapSet(); + beatmapSet2.IgnoreBeatmapRanking(); + var beatmap2 = beatmapSet2.Beatmaps!.First(); + + // User A: 100pp best score + var scoreA = _mocker.Score.GetBestScoreableRandomScore(); + EnrichScore(scoreA, userA, beatmap); + scoreA.PerformancePoints = 100; + scoreA.LocalProperties = scoreA.LocalProperties.FromScore(scoreA); + await Database.Scores.AddScore(scoreA); + + // User B: two scores - 200pp best and 50pp fallback on different beatmaps + var scoreBLow = _mocker.Score.GetBestScoreableRandomScore(); + EnrichScore(scoreBLow, userB, beatmap2); + scoreBLow.PerformancePoints = 50; + scoreBLow.LocalProperties = scoreBLow.LocalProperties.FromScore(scoreBLow); + await Database.Scores.AddScore(scoreBLow); + + var scoreBHigh = _mocker.Score.GetBestScoreableRandomScore(); + EnrichScore(scoreBHigh, userB, beatmap); + scoreBHigh.PerformancePoints = 200; + scoreBHigh.LocalProperties = scoreBHigh.LocalProperties.FromScore(scoreBHigh); + await Database.Scores.AddScore(scoreBHigh); + + // Seed user stats with explicit PP values and update ranks + var userStatsA = await Database.Users.Stats.GetUserStats(userA.Id, GameMode.Standard); + Assert.NotNull(userStatsA); + userStatsA.UpdateWithDbScore(scoreA); + userStatsA.PerformancePoints = 100; + await Database.Users.Stats.UpdateUserStats(userStatsA, userA); + + var userStatsB = await Database.Users.Stats.GetUserStats(userB.Id, GameMode.Standard); + Assert.NotNull(userStatsB); + userStatsB.UpdateWithDbScore(scoreBLow); + userStatsB.UpdateWithDbScore(scoreBHigh); + userStatsB.PerformancePoints = 250; + await Database.Users.Stats.UpdateUserStats(userStatsB, userB); + + // Create pipeline scope AFTER all data is persisted + using var pipelineScope = App.Server.Services.CreateScope(); + var pipeline = CreatePipeline(pipelineScope.ServiceProvider); + + // Verify initial: B=1, A=2 + var (globalRankBBefore, countryRankBBefore) = await Database.Users.Stats.Ranks.GetUserRanks(userB, GameMode.Standard); + var (globalRankABefore, countryRankABefore) = await Database.Users.Stats.Ranks.GetUserRanks(userA, GameMode.Standard); + Assert.Equal(1, globalRankBBefore); + Assert.Equal(2, globalRankABefore); + Assert.Equal(1, countryRankBBefore); + Assert.Equal(2, countryRankABefore); + + // Delete User B's high score via pipeline + var userGradesB = await Database.Users.Grades.GetUserGrades(userB.Id, GameMode.Standard); + Assert.NotNull(userGradesB); + var context = new ScoreCommitContext(ScoreTaskType.Delete, scoreBHigh, userB, userStatsB, userGradesB); + + // Act + var result = await pipeline.Commit(context, null, CancellationToken.None); + + // Assert + Assert.True(result.IsSuccess); + + var persistedStatsB = await Database.Users.Stats.GetUserStats(userB.Id, GameMode.Standard); + Assert.NotNull(persistedStatsB); + Assert.Equal(1, persistedStatsB.PlayCount); + + // After deleting B's 200pp score, B should drop below A (only 50pp left) + var (globalRankAAfter, countryRankAAfter) = await Database.Users.Stats.Ranks.GetUserRanks(userA, GameMode.Standard); + var (globalRankBAfter, countryRankBAfter) = await Database.Users.Stats.Ranks.GetUserRanks(userB, GameMode.Standard); + Assert.Equal(1, globalRankAAfter); + Assert.Equal(2, globalRankBAfter); + Assert.Equal(1, countryRankAAfter); + Assert.Equal(2, countryRankBAfter); + } + + [Fact] + public async Task TestCommitRestorationUpdatesGlobalAndCountryRank() + { + // Arrange + var userA = _mocker.User.GetRandomUser(_mocker.User.GetRandomUsername()); + userA.Country = CountryCode.US; + userA = await CreateTestUser(userA); + + var userB = _mocker.User.GetRandomUser(_mocker.User.GetRandomUsername()); + userB.Country = CountryCode.US; + userB = await CreateTestUser(userB); + + var beatmapSet = _mocker.Beatmap.GetRandomBeatmapSet(); + beatmapSet.IgnoreBeatmapRanking(); + var beatmap = beatmapSet.Beatmaps!.First(); + + // User A: 100pp best score (currently rank 1) + var scoreA = _mocker.Score.GetBestScoreableRandomScore(); + EnrichScore(scoreA, userA, beatmap); + scoreA.PerformancePoints = 100; + scoreA.LocalProperties = scoreA.LocalProperties.FromScore(scoreA); + await Database.Scores.AddScore(scoreA); + + var userStatsA = await Database.Users.Stats.GetUserStats(userA.Id, GameMode.Standard); + Assert.NotNull(userStatsA); + userStatsA.UpdateWithDbScore(scoreA); + userStatsA.PerformancePoints = 100; + await Database.Users.Stats.UpdateUserStats(userStatsA, userA); + + // User B: has a deleted 200pp score (rank should be worse than A currently) + var scoreB = _mocker.Score.GetBestScoreableRandomScore(); + EnrichScore(scoreB, userB, beatmap); + scoreB.PerformancePoints = 200; + scoreB.SubmissionStatus = SubmissionStatus.Deleted; + scoreB.LocalProperties = scoreB.LocalProperties.FromScore(scoreB); + await Database.Scores.AddScore(scoreB); + + var userStatsB = await Database.Users.Stats.GetUserStats(userB.Id, GameMode.Standard); + Assert.NotNull(userStatsB); + // User B has 0 PP (deleted score doesn't count) + await Database.Users.Stats.UpdateUserStats(userStatsB, userB); + + // Verify: A=1, B=2 + var (globalRankABefore, countryRankABefore) = await Database.Users.Stats.Ranks.GetUserRanks(userA, GameMode.Standard); + var (globalRankBBefore, countryRankBBefore) = await Database.Users.Stats.Ranks.GetUserRanks(userB, GameMode.Standard); + Assert.Equal(1, globalRankABefore); + Assert.Equal(1, countryRankABefore); + Assert.True(globalRankBBefore > globalRankABefore); + + // Create pipeline scope AFTER all data is persisted + using var pipelineScope = App.Server.Services.CreateScope(); + var pipeline = CreatePipeline(pipelineScope.ServiceProvider); + + // Restore User B's score via pipeline + var userGradesB = await Database.Users.Grades.GetUserGrades(userB.Id, GameMode.Standard); + Assert.NotNull(userGradesB); + var context = new ScoreCommitContext(ScoreTaskType.Restore, scoreB, userB, userStatsB, userGradesB); + + // Act + var result = await pipeline.Commit(context, null, CancellationToken.None); + + // Assert + Assert.True(result.IsSuccess); + + // After restoring B's 200pp score, B should overtake A + var (globalRankAAfter, countryRankAAfter) = await Database.Users.Stats.Ranks.GetUserRanks(userA, GameMode.Standard); + var (globalRankBAfter, countryRankBAfter) = await Database.Users.Stats.Ranks.GetUserRanks(userB, GameMode.Standard); + Assert.Equal(1, globalRankBAfter); + Assert.Equal(2, globalRankAAfter); + Assert.Equal(1, countryRankBAfter); + Assert.Equal(2, countryRankAAfter); + } + + [Fact] + public async Task TestCommitRecalculationUpdatesGlobalAndCountryRank() + { + // Arrange + var userA = _mocker.User.GetRandomUser(_mocker.User.GetRandomUsername()); + userA.Country = CountryCode.US; + userA = await CreateTestUser(userA); + + var userB = _mocker.User.GetRandomUser(_mocker.User.GetRandomUsername()); + userB.Country = CountryCode.US; + userB = await CreateTestUser(userB); + + var beatmapSet = _mocker.Beatmap.GetRandomBeatmapSet(); + beatmapSet.IgnoreBeatmapRanking(); + var beatmap = beatmapSet.Beatmaps!.First(); + + // User A: 100pp score + var scoreA = _mocker.Score.GetBestScoreableRandomScore(); + EnrichScore(scoreA, userA, beatmap); + scoreA.PerformancePoints = 100; + scoreA.LocalProperties = scoreA.LocalProperties.FromScore(scoreA); + await Database.Scores.AddScore(scoreA); + + var userStatsA = await Database.Users.Stats.GetUserStats(userA.Id, GameMode.Standard); + Assert.NotNull(userStatsA); + userStatsA.UpdateWithDbScore(scoreA); + userStatsA.PerformancePoints = 100; + await Database.Users.Stats.UpdateUserStats(userStatsA, userA); + + // User B: score persisted with 0pp (simulates pre-recalculation state) + var scoreB = _mocker.Score.GetBestScoreableRandomScore(); + EnrichScore(scoreB, userB, beatmap); + scoreB.PerformancePoints = 0; + scoreB.LocalProperties = scoreB.LocalProperties.FromScore(scoreB); + await Database.Scores.AddScore(scoreB); + + var userStatsB = await Database.Users.Stats.GetUserStats(userB.Id, GameMode.Standard); + Assert.NotNull(userStatsB); + userStatsB.UpdateWithDbScore(scoreB); + await Database.Users.Stats.UpdateUserStats(userStatsB, userB); + + // Verify: A=1 (has PP), B=2 (0 PP) + var (globalRankABefore, _) = await Database.Users.Stats.Ranks.GetUserRanks(userA, GameMode.Standard); + var (globalRankBBefore, _) = await Database.Users.Stats.Ranks.GetUserRanks(userB, GameMode.Standard); + Assert.Equal(1, globalRankABefore); + Assert.True(globalRankBBefore > globalRankABefore); + + // Create pipeline scope AFTER all data is persisted + using var pipelineScope = App.Server.Services.CreateScope(); + var pipeline = CreatePipeline(pipelineScope.ServiceProvider); + + // Recalculate User B's score with 200pp (simulates pp recalculation) + scoreB.PerformancePoints = 200; + var userGradesB = await Database.Users.Grades.GetUserGrades(userB.Id, GameMode.Standard); + Assert.NotNull(userGradesB); + var context = new ScoreCommitContext(ScoreTaskType.Recalculation, scoreB, userB, userStatsB, userGradesB); + + // Act + var result = await pipeline.Commit(context, null, CancellationToken.None); + + // Assert + Assert.True(result.IsSuccess); + + // After recalculation, B (200pp) should overtake A (100pp) + var (globalRankAAfter, countryRankAAfter) = await Database.Users.Stats.Ranks.GetUserRanks(userA, GameMode.Standard); + var (globalRankBAfter, countryRankBAfter) = await Database.Users.Stats.Ranks.GetUserRanks(userB, GameMode.Standard); + Assert.Equal(1, globalRankBAfter); + Assert.Equal(2, globalRankAAfter); + Assert.Equal(1, countryRankBAfter); + Assert.Equal(2, countryRankAAfter); + } + + private static ScoreCommitPipeline CreatePipeline(IServiceProvider services, bool includeUserStatsProcessor = true) + { + var database = services.GetRequiredService(); + var processors = new List + { + new LeaderboardProcessor(database), + new UserGradesScoreProcessor(database) + }; + + if (includeUserStatsProcessor) + processors.Add(new UserStatsScoreProcessor(database, services.GetRequiredService())); + + return new ScoreCommitPipeline(database, processors); + } + + private async Task<(UserStats UserStats, UserGrades UserGrades)> LoadUserState(User user, GameMode mode) + { + var userStats = await Database.Users.Stats.GetUserStats(user.Id, mode); + var userGrades = await Database.Users.Grades.GetUserGrades(user.Id, mode); + + Assert.NotNull(userStats); + Assert.NotNull(userGrades); + + return (userStats, userGrades); + } + + private async Task CreateTask( + ScoreTaskType taskType, + int? scoreId = null, + int? scoreSubmissionRequestId = null, + string? claimToken = null, + DateTime? leaseExpiresAt = null) + { + var task = new ScoreProcessingTask + { + TaskType = taskType, + ScoreId = scoreId, + ScoreSubmissionRequestId = scoreSubmissionRequestId, + Status = ScoreProcessingStatus.Failed, + ClaimToken = claimToken, + LeaseExpiresAt = leaseExpiresAt, + CreatedAt = DateTime.UtcNow + }; + + await Database.ScoreProcessingTasks.AddQueueEntry(task); + return task; + } + + private async Task CreatePayload(int userId) + { + var payload = new ScoreSubmissionRequest + { + UserId = userId, + ScoreHash = $"{Guid.NewGuid():N}", + ScoreSerialized = "payload", + BeatmapHash = "pipeline-beatmap-hash", + TimeElapsed = 120, + OsuVersion = "b20260101.1", + ClientHash = "client-hash", + StoryboardHash = null, + UserHash = "user-hash", + WhenPlayed = DateTime.UtcNow + }; + + await Database.ScoreSubmissionRequests.AddQueueEntry(payload); + return payload; + } + + private async Task CreatePersistedScore( + User user, + Beatmap beatmap, + long totalScore, + SubmissionStatus submissionStatus, + string grade, + int maxCombo, + bool isPassed = true) + { + var score = _mocker.Score.GetBestScoreableRandomScore(); + EnrichScore(score, user, beatmap); + score.TotalScore = totalScore; + score.Grade = grade; + score.MaxCombo = maxCombo; + score.SubmissionStatus = submissionStatus; + + if (!isPassed) + { + score.IsPassed = false; + score.CountMiss = 1; + } + + score.LocalProperties = score.LocalProperties.FromScore(score); + + return await CreateTestScore(score); + } + + private static void EnrichScore(Score score, User user, Beatmap beatmap, Mods mods = Mods.None) + { + beatmap.ModeInt = (int)GameMode.Standard.ToVanillaGameMode(); + score.Mods = mods; + score.EnrichWithUserData(user); + score.EnrichWithBeatmapData(beatmap); + score.GameMode = score.GameMode.EnrichWithMods(score.Mods); + } +} \ No newline at end of file diff --git a/Sunrise.Processing.Tests/Scores/Pipeline/ScoreStateSnapshotTests.cs b/Sunrise.Processing.Tests/Scores/Pipeline/ScoreStateSnapshotTests.cs new file mode 100644 index 00000000..b808ec0a --- /dev/null +++ b/Sunrise.Processing.Tests/Scores/Pipeline/ScoreStateSnapshotTests.cs @@ -0,0 +1,27 @@ +using Sunrise.Processing.Scores.Pipeline; +using Sunrise.Tests.Services.Mock; +using Xunit; + +namespace Sunrise.Processing.Tests.Scores.Pipeline; + +public class ScoreStateSnapshotTests +{ + private readonly MockService _mocker = new(); + + [Fact] + public void TestCaptureWithRankedPassedScoreStoresCurrentState() + { + // Arrange + var score = _mocker.Score.GetRandomScore(); + score.LocalProperties = score.LocalProperties.FromScore(score); + + // Act + var snapshot = ScoreStateSnapshot.Capture(score); + + // Assert + Assert.Equal(score.SubmissionStatus, snapshot.SubmissionStatus); + Assert.Equal(score.IsScoreable, snapshot.IsScoreable); + Assert.Equal(score.IsPassed, snapshot.IsPassed); + Assert.Equal(score.LocalProperties.IsRanked, snapshot.IsRanked); + } +} \ No newline at end of file diff --git a/Sunrise.Processing.Tests/Scores/Processors/LeaderboardProcessorTests.cs b/Sunrise.Processing.Tests/Scores/Processors/LeaderboardProcessorTests.cs new file mode 100644 index 00000000..feda2db4 --- /dev/null +++ b/Sunrise.Processing.Tests/Scores/Processors/LeaderboardProcessorTests.cs @@ -0,0 +1,258 @@ +using Sunrise.Processing.Scores.Pipeline; +using Sunrise.Processing.Scores.Processors; +using Sunrise.Shared.Database.Models; +using Sunrise.Shared.Database.Models.Users; +using Sunrise.Shared.Enums.Scores; +using Sunrise.Shared.Objects; +using Sunrise.Tests.Abstracts; +using Sunrise.Tests.Extensions; +using Sunrise.Tests.Services.Mock; +using Sunrise.Tests.Utils.Processing; +using Xunit; +using Mods = osu.Shared.Mods; +using SubmissionStatus = Sunrise.Shared.Enums.Scores.SubmissionStatus; +using GameMode = Sunrise.Shared.Enums.Beatmaps.GameMode; + +namespace Sunrise.Processing.Tests.Scores.Processors; + +[Collection("Integration tests collection")] +public class LeaderboardProcessorTests(IntegrationDatabaseFixture fixture) : DatabaseTest(fixture) +{ + private readonly MockService _mocker = new(); + + [Fact] + public async Task TestOnNewSubmissionWithBetterScoreReturnsBestAndDemotesPreviousBest() + { + // Arrange + var processor = new LeaderboardProcessor(Database); + var user = await CreateTestUser(); + var previousBest = await CreatePersistedScore(user, 1000, SubmissionStatus.Best); + var score = CreateScore(user, 1200, SubmissionStatus.Submitted); + var context = await CreateContext(ScoreTaskType.Submission, score, user, previousBest, ScoreStateSnapshot.Capture(score)); + + // Act + await processor.OnNewSubmission(context); + + // Assert + Assert.Equal(SubmissionStatus.Best, score.SubmissionStatus); + + var persistedPreviousBest = await Database.Scores.GetScore(previousBest.Id, filterValidScores: false); + Assert.NotNull(persistedPreviousBest); + Assert.Equal(SubmissionStatus.Submitted, persistedPreviousBest.SubmissionStatus); + } + + [Fact] + public async Task TestOnNewSubmissionWithWorseScoreReturnsSubmittedAndKeepsPreviousBest() + { + // Arrange + var processor = new LeaderboardProcessor(Database); + var user = await CreateTestUser(); + var previousBest = await CreatePersistedScore(user, 1000, SubmissionStatus.Best); + var score = CreateScore(user, 900, SubmissionStatus.Submitted); + var context = await CreateContext(ScoreTaskType.Submission, score, user, previousBest, ScoreStateSnapshot.Capture(score)); + + // Act + await processor.OnNewSubmission(context); + + // Assert + Assert.Equal(SubmissionStatus.Submitted, score.SubmissionStatus); + + var persistedPreviousBest = await Database.Scores.GetScore(previousBest.Id, filterValidScores: false); + Assert.NotNull(persistedPreviousBest); + Assert.Equal(SubmissionStatus.Best, persistedPreviousBest.SubmissionStatus); + } + + [Fact] + public async Task TestOnRecalculationWithBetterScoreReturnsBestAndDemotesPreviousBest() + { + // Arrange + var processor = new LeaderboardProcessor(Database); + var user = await CreateTestUser(); + var previousBest = await CreatePersistedScore(user, 1000, SubmissionStatus.Best); + var score = CreateScore(user, 1200, SubmissionStatus.Submitted); + var context = await CreateContext(ScoreTaskType.Recalculation, score, user, previousBest, ScoreStateSnapshot.Capture(score)); + + // Act + await processor.OnRecalculation(context); + + // Assert + Assert.Equal(SubmissionStatus.Best, score.SubmissionStatus); + + var persistedPreviousBest = await Database.Scores.GetScore(previousBest.Id, filterValidScores: false); + Assert.NotNull(persistedPreviousBest); + Assert.Equal(SubmissionStatus.Submitted, persistedPreviousBest.SubmissionStatus); + } + + [Fact] + public async Task TestOnRecalculationWithWorseScoreReturnsSubmittedAndKeepsPreviousBest() + { + // Arrange + var processor = new LeaderboardProcessor(Database); + var user = await CreateTestUser(); + var previousBest = await CreatePersistedScore(user, 1000, SubmissionStatus.Best); + var score = CreateScore(user, 900, SubmissionStatus.Submitted); + var context = await CreateContext(ScoreTaskType.Recalculation, score, user, previousBest, ScoreStateSnapshot.Capture(score)); + + // Act + await processor.OnRecalculation(context); + + // Assert + Assert.Equal(SubmissionStatus.Submitted, score.SubmissionStatus); + + var persistedPreviousBest = await Database.Scores.GetScore(previousBest.Id, filterValidScores: false); + Assert.NotNull(persistedPreviousBest); + Assert.Equal(SubmissionStatus.Best, persistedPreviousBest.SubmissionStatus); + } + + [Fact] + public async Task TestOnDeletionWithBestOriginalStatePromotesNextBestPeer() + { + // Arrange + var processor = new LeaderboardProcessor(Database); + var user = await CreateTestUser(); + var nextBest = await CreatePersistedScore(user, 1000, SubmissionStatus.Submitted); + var score = CreateScore(user, 1200, SubmissionStatus.Best); + var originalState = ScoreStateSnapshot.Capture(score); + var context = await CreateContext(ScoreTaskType.Delete, score, user, nextBest, originalState); + + // Act + await processor.OnDeletion(context); + + // Assert + Assert.Equal(SubmissionStatus.Deleted, score.SubmissionStatus); + + var persistedNextBest = await Database.Scores.GetScore(nextBest.Id, filterValidScores: false); + Assert.NotNull(persistedNextBest); + Assert.Equal(SubmissionStatus.Best, persistedNextBest.SubmissionStatus); + } + + [Fact] + public async Task TestOnDeletionWithSubmittedOriginalStateKeepsPeerUnchanged() + { + // Arrange + var processor = new LeaderboardProcessor(Database); + var user = await CreateTestUser(); + var nextBest = await CreatePersistedScore(user, 1000, SubmissionStatus.Submitted); + var score = CreateScore(user, 900, SubmissionStatus.Submitted); + var originalState = ScoreStateSnapshot.Capture(score); + var context = await CreateContext(ScoreTaskType.Delete, score, user, nextBest, originalState); + + // Act + await processor.OnDeletion(context); + + // Assert + Assert.Equal(SubmissionStatus.Deleted, score.SubmissionStatus); + + var persistedNextBest = await Database.Scores.GetScore(nextBest.Id, filterValidScores: false); + Assert.NotNull(persistedNextBest); + Assert.Equal(SubmissionStatus.Submitted, persistedNextBest.SubmissionStatus); + } + + [Fact] + public async Task TestOnRestorationWithPassedBetterScoreReturnsBestAndDemotesPreviousBest() + { + // Arrange + var processor = new LeaderboardProcessor(Database); + var user = await CreateTestUser(); + var previousBest = await CreatePersistedScore(user, 1000, SubmissionStatus.Best); + var score = CreateScore(user, 1200, SubmissionStatus.Deleted); + var originalState = ScoreStateSnapshot.Capture(score); + var context = await CreateContext(ScoreTaskType.Restore, score, user, previousBest, originalState); + + // Act + await processor.OnRestoration(context); + + // Assert + Assert.Equal(SubmissionStatus.Best, score.SubmissionStatus); + + var persistedPreviousBest = await Database.Scores.GetScore(previousBest.Id, filterValidScores: false); + Assert.NotNull(persistedPreviousBest); + Assert.Equal(SubmissionStatus.Submitted, persistedPreviousBest.SubmissionStatus); + } + + [Fact] + public async Task TestOnRestorationWithFailedScoreReturnsFailedAndKeepsPreviousBest() + { + // Arrange + var processor = new LeaderboardProcessor(Database); + var user = await CreateTestUser(); + var previousBest = await CreatePersistedScore(user, 1000, SubmissionStatus.Best); + var score = CreateScore(user, 1200, SubmissionStatus.Deleted, false); + var originalState = ScoreStateSnapshot.Capture(score); + var context = await CreateContext(ScoreTaskType.Restore, score, user, previousBest, originalState); + + // Act + await processor.OnRestoration(context); + + // Assert + Assert.Equal(SubmissionStatus.Failed, score.SubmissionStatus); + + var persistedPreviousBest = await Database.Scores.GetScore(previousBest.Id, filterValidScores: false); + Assert.NotNull(persistedPreviousBest); + Assert.Equal(SubmissionStatus.Best, persistedPreviousBest.SubmissionStatus); + } + + private async Task CreateContext( + ScoreTaskType taskType, + Score score, + User user, + Score? sameModsPeer, + ScoreStateSnapshot originalState) + { + var userStats = await Database.Users.Stats.GetUserStats(user.Id, score.GameMode); + var userGrades = await Database.Users.Grades.GetUserGrades(user.Id, score.GameMode); + + Assert.NotNull(userStats); + Assert.NotNull(userGrades); + + var peers = sameModsPeer == null + ? null + : new UserBeatmapPeers(new UserPersonalBestScores(sameModsPeer), new UserPersonalBestScores(sameModsPeer)); + + return ScoreCommitContextFactory.Create(taskType, score, user, userStats, userGrades, userPersonalBestScores: peers, originalState: originalState); + } + + private async Task CreatePersistedScore(User user, long totalScore, SubmissionStatus submissionStatus, bool isPassed = true) + { + var score = CreateScore(user, totalScore, submissionStatus, isPassed); + return await CreateTestScore(score); + } + + // TODO: Refactor this to proper fixture + private Score CreateScore(User user, long totalScore, SubmissionStatus submissionStatus, bool isPassed = true) + { + var beatmap = _mocker.Beatmap.GetRandomBeatmap(); + beatmap.StatusString = "ranked"; + beatmap.ModeInt = (int)GameMode.Standard; + + var score = new Score + { + ScoreHash = $"{Guid.NewGuid():N}", + TotalScore = totalScore, + MaxCombo = 100, + Count300 = 100, + Count100 = 10, + Count50 = 0, + CountMiss = isPassed ? 0 : 1, + CountKatu = 0, + CountGeki = 0, + Perfect = false, + Mods = Mods.None, + Grade = isPassed ? "A" : "F", + IsPassed = isPassed, + IsScoreable = true, + SubmissionStatus = submissionStatus, + WhenPlayed = new DateTime(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc), + OsuVersion = "b20260101.1", + ClientTime = new DateTime(2026, 1, 2, 3, 4, 5), + Accuracy = isPassed ? 98 : 50, + PerformancePoints = totalScore, + TimeElapsed = 120 + }; + + score.EnrichWithUserData(user); + score.EnrichWithBeatmapData(beatmap); + score.LocalProperties = score.LocalProperties.FromScore(score); + return score; + } +} \ No newline at end of file diff --git a/Sunrise.Processing.Tests/Scores/Processors/MedalScoreProcessorTests.cs b/Sunrise.Processing.Tests/Scores/Processors/MedalScoreProcessorTests.cs new file mode 100644 index 00000000..3254abae --- /dev/null +++ b/Sunrise.Processing.Tests/Scores/Processors/MedalScoreProcessorTests.cs @@ -0,0 +1,233 @@ +using Sunrise.Processing.Scores.Pipeline; +using Sunrise.Processing.Scores.Processors; +using Sunrise.Shared.Database.Models; +using Sunrise.Shared.Database.Models.Users; +using Sunrise.Shared.Enums.Scores; +using Sunrise.Shared.Objects.Serializable; +using Sunrise.Tests.Abstracts; +using Sunrise.Tests.Extensions; +using Sunrise.Tests.Services.Mock; +using Sunrise.Tests.Utils.Processing; +using Xunit; +using GameMode = Sunrise.Shared.Enums.Beatmaps.GameMode; +using Mods = osu.Shared.Mods; + +namespace Sunrise.Processing.Tests.Scores.Processors; + +[Collection("Integration tests collection")] +public class MedalScoreProcessorTests(IntegrationDatabaseFixture fixture) : DatabaseTest(fixture) +{ + private readonly MockService _mocker = new(); + + [Fact] + public async Task TestOnNewSubmissionWithRankedPassedScoreUnlocksSeededSkillMedal() + { + // Arrange + var processor = new MedalScoreProcessor(Database); + var user = await CreateTestUser(); + var userStats = await Database.Users.Stats.GetUserStats(user.Id, GameMode.Standard); + Assert.NotNull(userStats); + + var score = CreateScore(user); + var (beatmapSet, beatmap) = await _mocker.Beatmap.MockRankedBeatmapWithSetForScore(score); + beatmap.DifficultyRating = 1; + var context = await CreateContext(ScoreTaskType.Submission, score, user, userStats, beatmap, beatmapSet); + + // Act + await processor.OnNewSubmission(context); + + // Assert + Assert.NotNull(context.UnlockedMedals); + Assert.Contains(context.UnlockedMedals, m => m.Id == 1); + + var userMedals = await Database.Users.Medals.GetUserMedals(user.Id); + Assert.Contains(userMedals, m => m.MedalId == 1); + } + + [Fact] + public async Task TestOnNewSubmissionWithRankedPassedNoFailScoreUnlocksNoFailModIntroductionMedal() + { + // Arrange + var processor = new MedalScoreProcessor(Database); + var user = await CreateTestUser(); + var userStats = await Database.Users.Stats.GetUserStats(user.Id, GameMode.Standard); + Assert.NotNull(userStats); + + var score = CreateScore(user); + score.Mods = Mods.NoFail; + var (beatmapSet, beatmap) = await _mocker.Beatmap.MockRankedBeatmapWithSetForScore(score); + var context = await CreateContext(ScoreTaskType.Submission, score, user, userStats, beatmap, beatmapSet); + + // Act + await processor.OnNewSubmission(context); + + // Assert + Assert.NotNull(context.UnlockedMedals); + Assert.Contains(context.UnlockedMedals, m => m.Id == 97); + + var userMedals = await Database.Users.Medals.GetUserMedals(user.Id); + Assert.Contains(userMedals, m => m.MedalId == 97); + } + + [Fact] + public async Task TestOnNewSubmissionWithRankedPassedScoreUnlocksMultipleMedals() + { + // Arrange + var processor = new MedalScoreProcessor(Database); + var user = await CreateTestUser(); + var userStats = await Database.Users.Stats.GetUserStats(user.Id, GameMode.Standard); + Assert.NotNull(userStats); + + var score = CreateScore(user); + score.Mods = Mods.DoubleTime; + score.MaxCombo = 500; + + var (beatmapSet, beatmap) = await _mocker.Beatmap.MockRankedBeatmapWithSetForScore(score); + var context = await CreateContext(ScoreTaskType.Submission, score, user, userStats, beatmap, beatmapSet); + + // Act + await processor.OnNewSubmission(context); + + // Assert + Assert.NotNull(context.UnlockedMedals); + Assert.Contains(context.UnlockedMedals, m => m.Id == 92); + Assert.Contains(context.UnlockedMedals, m => m.Id == 21); + + var userMedals = await Database.Users.Medals.GetUserMedals(user.Id); + Assert.Contains(userMedals, m => m.MedalId == 92); + Assert.Contains(userMedals, m => m.MedalId == 21); + } + + [Fact] + public async Task TestOnNewSubmissionWithFailedScoreDoesNotUnlockAnyMedals() + { + // Arrange + var processor = new MedalScoreProcessor(Database); + var user = await CreateTestUser(); + var userStats = await Database.Users.Stats.GetUserStats(user.Id, GameMode.Standard); + Assert.NotNull(userStats); + + var score = CreateScore(user); + score.IsPassed = false; + + var (beatmapSet, beatmap) = await _mocker.Beatmap.MockRankedBeatmapWithSetForScore(score); + var context = await CreateContext(ScoreTaskType.Submission, score, user, userStats, beatmap, beatmapSet); + + // Act + await processor.OnNewSubmission(context); + + // Assert + Assert.NotNull(context.UnlockedMedals); + Assert.Empty(context.UnlockedMedals); + + var userMedals = await Database.Users.Medals.GetUserMedals(user.Id); + Assert.Empty(userMedals); + } + + [Fact] + public async Task TestOnNewSubmissionWithUnscoreableBeatmapDoesNotUnlockAnyMedals() + { + // Arrange + var processor = new MedalScoreProcessor(Database); + var user = await CreateTestUser(); + var userStats = await Database.Users.Stats.GetUserStats(user.Id, GameMode.Standard); + Assert.NotNull(userStats); + + var score = CreateScore(user); + score.Mods = Mods.NoFail; + + var (beatmapSet, beatmap) = await _mocker.Beatmap.MockGraveyardBeatmapWithSetForScore(score); + var context = await CreateContext(ScoreTaskType.Submission, score, user, userStats, beatmap, beatmapSet); + + // Act + await processor.OnNewSubmission(context); + + // Assert + Assert.NotNull(context.UnlockedMedals); + Assert.Empty(context.UnlockedMedals); + + var userMedals = await Database.Users.Medals.GetUserMedals(user.Id); + Assert.Empty(userMedals); + } + + [Fact] + public async Task TestOnNewSubmissionWithPreviouslyUnlockedMedalDoesNotDuplicateIt() + { + // Arrange + var processor = new MedalScoreProcessor(Database); + var user = await CreateTestUser(); + var userStats = await Database.Users.Stats.GetUserStats(user.Id, GameMode.Standard); + Assert.NotNull(userStats); + + var score = CreateScore(user); + score.Mods = Mods.NoFail; + + var (beatmapSet, beatmap) = await _mocker.Beatmap.MockRankedBeatmapWithSetForScore(score); + + await Database.Users.Medals.UnlockMedals(user.Id, [97]); + var context = await CreateContext(ScoreTaskType.Submission, score, user, userStats, beatmap, beatmapSet); + + // Act + await processor.OnNewSubmission(context); + + // Assert + Assert.NotNull(context.UnlockedMedals); + Assert.DoesNotContain(context.UnlockedMedals, m => m.Id == 97); + + var userMedals = await Database.Users.Medals.GetUserMedals(user.Id); + Assert.Contains(userMedals, m => m.MedalId == 97); + } + + [Fact] + public async Task TestOnRecalculationWithRankedPassedScoreUnlocksMedals() + { + // Arrange + var processor = new MedalScoreProcessor(Database); + var user = await CreateTestUser(); + var userStats = await Database.Users.Stats.GetUserStats(user.Id, GameMode.Standard); + Assert.NotNull(userStats); + + var score = CreateScore(user); + score.Mods = Mods.NoFail; + var (beatmapSet, beatmap) = await _mocker.Beatmap.MockRankedBeatmapWithSetForScore(score); + var context = await CreateContext(ScoreTaskType.Recalculation, score, user, userStats, beatmap, beatmapSet); + + // Act + await processor.OnRecalculation(context); + + // Assert + Assert.NotNull(context.UnlockedMedals); + Assert.Contains(context.UnlockedMedals, m => m.Id == 97); + } + + private Score CreateScore(User user) + { + var score = _mocker.Score.GetBestScoreableRandomScore(); + score.GameMode = GameMode.Standard; + score.EnrichWithUserData(user); + + return score; + } + + private async Task CreateContext( + ScoreTaskType taskType, + Score score, + User user, + UserStats userStats, + Beatmap beatmap, + BeatmapSet beatmapSet) + { + var userGrades = await Database.Users.Grades.GetUserGrades(user.Id, score.GameMode); + Assert.NotNull(userGrades); + + return ScoreCommitContextFactory.Create( + taskType, + score, + user, + userStats, + userGrades, + beatmap, + beatmapSet, + originalState: ScoreStateSnapshot.Capture(score)); + } +} \ No newline at end of file diff --git a/Sunrise.Processing.Tests/Scores/Processors/UserGradesScoreProcessorTests.cs b/Sunrise.Processing.Tests/Scores/Processors/UserGradesScoreProcessorTests.cs new file mode 100644 index 00000000..a97cc7f0 --- /dev/null +++ b/Sunrise.Processing.Tests/Scores/Processors/UserGradesScoreProcessorTests.cs @@ -0,0 +1,342 @@ +using Sunrise.Processing.Scores.Pipeline; +using Sunrise.Processing.Scores.Processors; +using Sunrise.Shared.Database.Models; +using Sunrise.Shared.Database.Models.Users; +using Sunrise.Shared.Enums.Scores; +using Sunrise.Shared.Objects; +using Sunrise.Tests.Abstracts; +using Sunrise.Tests.Extensions; +using Sunrise.Tests.Services.Mock; +using Sunrise.Tests.Utils.Processing; +using Xunit; +using Mods = osu.Shared.Mods; +using SubmissionStatus = Sunrise.Shared.Enums.Scores.SubmissionStatus; +using GameMode = Sunrise.Shared.Enums.Beatmaps.GameMode; + +namespace Sunrise.Processing.Tests.Scores.Processors; + +[Collection("Integration tests collection")] +public class UserGradesScoreProcessorTests(IntegrationDatabaseFixture fixture) : DatabaseTest(fixture) +{ + private readonly MockService _mocker = new(); + + [Fact] + public async Task TestOnNewSubmissionWithBestScoreIncrementsMatchingGradeCount() + { + // Arrange + var processor = new UserGradesScoreProcessor(Database); + var user = await CreateTestUser(); + var userStats = await Database.Users.Stats.GetUserStats(user.Id, GameMode.Standard); + Assert.NotNull(userStats); + + var userGrades = new UserGrades + { + UserId = user.Id, + GameMode = GameMode.Standard + }; + var score = CreateScore(user); + var context = ScoreCommitContextFactory.Create(ScoreTaskType.Submission, score, user, userStats, userGrades, originalState: ScoreStateSnapshot.Capture(score)); + + // Act + await processor.OnNewSubmission(context); + + // Assert + Assert.Equal(1, userGrades.CountA); + } + + [Fact] + public async Task TestOnNewSubmissionWithPreviousBestReplacesGradeCounts() + { + // Arrange + var processor = new UserGradesScoreProcessor(Database); + var user = await CreateTestUser(); + var userStats = await Database.Users.Stats.GetUserStats(user.Id, GameMode.Standard); + Assert.NotNull(userStats); + var userGrades = new UserGrades + { + UserId = user.Id, + GameMode = GameMode.Standard, + CountS = 1 + }; + + var previousBest = CreateScore(user, "S", submissionStatus: SubmissionStatus.Best); + var score = CreateScore(user); + + var context = ScoreCommitContextFactory.Create( + ScoreTaskType.Submission, + score, + user, + userStats, + userGrades, + userPersonalBestScores: new UserBeatmapPeers(null, new UserPersonalBestScores(previousBest)), + originalState: ScoreStateSnapshot.Capture(score)); + + // Act + await processor.OnNewSubmission(context); + + // Assert + Assert.Equal(0, userGrades.CountS); + Assert.Equal(1, userGrades.CountA); + } + + [Fact] + public async Task TestOnNewSubmissionWithModSpecificBestButWorseOverallKeepsGradesUnchanged() + { + // Arrange + var processor = new UserGradesScoreProcessor(Database); + var user = await CreateTestUser(); + var userStats = await Database.Users.Stats.GetUserStats(user.Id, GameMode.Standard); + Assert.NotNull(userStats); + var userGrades = new UserGrades + { + UserId = user.Id, + GameMode = GameMode.Standard, + CountS = 1 + }; + + var existingOverallBest = CreateScore(user, "S", submissionStatus: SubmissionStatus.Best); + existingOverallBest.TotalScore = 1200; + + var score = CreateScore(user); + score.TotalScore = 1100; + + var context = ScoreCommitContextFactory.Create( + ScoreTaskType.Submission, + score, + user, + userStats, + userGrades, + userPersonalBestScores: new UserBeatmapPeers(null, new UserPersonalBestScores(existingOverallBest)), + originalState: ScoreStateSnapshot.Capture(score)); + + // Act + await processor.OnNewSubmission(context); + + // Assert + Assert.Equal(1, userGrades.CountS); + Assert.Equal(0, userGrades.CountA); + } + + [Theory] + [InlineData(false, true, SubmissionStatus.Best)] + [InlineData(true, false, SubmissionStatus.Best)] + [InlineData(true, true, SubmissionStatus.Submitted)] + public async Task TestOnNewSubmissionWithInvalidScoreStateKeepsGradesUnchanged(bool isScoreable, bool isPassed, SubmissionStatus submissionStatus) + { + // Arrange + var processor = new UserGradesScoreProcessor(Database); + var user = await CreateTestUser(); + var userStats = await Database.Users.Stats.GetUserStats(user.Id, GameMode.Standard); + Assert.NotNull(userStats); + var userGrades = new UserGrades + { + UserId = user.Id, + GameMode = GameMode.Standard, + CountA = 2 + }; + var score = CreateScore(user, isScoreable: isScoreable, isPassed: isPassed, submissionStatus: submissionStatus); + var context = ScoreCommitContextFactory.Create(ScoreTaskType.Submission, score, user, userStats, userGrades, originalState: ScoreStateSnapshot.Capture(score)); + + // Act + await processor.OnNewSubmission(context); + + // Assert + Assert.Equal(2, userGrades.CountA); + } + + [Fact] + public async Task TestOnRecalculationReturnsWithoutChangingGrades() + { + // Arrange + var processor = new UserGradesScoreProcessor(Database); + var user = await CreateTestUser(); + var userStats = await Database.Users.Stats.GetUserStats(user.Id, GameMode.Standard); + Assert.NotNull(userStats); + var userGrades = new UserGrades + { + UserId = user.Id, + GameMode = GameMode.Standard, + CountA = 2 + }; + var score = CreateScore(user); + var context = ScoreCommitContextFactory.Create(ScoreTaskType.Recalculation, score, user, userStats, userGrades, originalState: ScoreStateSnapshot.Capture(score)); + + // Act + await processor.OnRecalculation(context); + + // Assert + Assert.Equal(2, userGrades.CountA); + } + + [Fact] + public async Task TestOnDeletionWithBestOriginalStateDecrementsMatchingGradeCount() + { + // Arrange + var processor = new UserGradesScoreProcessor(Database); + var user = await CreateTestUser(); + var userStats = await Database.Users.Stats.GetUserStats(user.Id, GameMode.Standard); + Assert.NotNull(userStats); + var userGrades = new UserGrades + { + UserId = user.Id, + GameMode = GameMode.Standard, + CountA = 1 + }; + var score = CreateScore(user); + var originalState = ScoreStateSnapshot.Capture(score); + var context = ScoreCommitContextFactory.Create(ScoreTaskType.Delete, score, user, userStats, userGrades, originalState: originalState); + + // Act + await processor.OnDeletion(context); + + // Assert + Assert.Equal(0, userGrades.CountA); + } + + [Fact] + public async Task TestOnDeletionWithPromotedReplacementReplacesGradeCounts() + { + // Arrange + var processor = new UserGradesScoreProcessor(Database); + var user = await CreateTestUser(); + var userStats = await Database.Users.Stats.GetUserStats(user.Id, GameMode.Standard); + Assert.NotNull(userStats); + var userGrades = new UserGrades + { + UserId = user.Id, + GameMode = GameMode.Standard, + CountA = 1 + }; + + var promotedReplacement = CreateScore(user, "S", submissionStatus: SubmissionStatus.Best); + var score = CreateScore(user); + var originalState = ScoreStateSnapshot.Capture(score); + + var context = ScoreCommitContextFactory.Create( + ScoreTaskType.Delete, + score, + user, + userStats, + userGrades, + userPersonalBestScores: new UserBeatmapPeers(new UserPersonalBestScores(promotedReplacement), new UserPersonalBestScores(promotedReplacement)), + originalState: originalState); + + // Act + await processor.OnDeletion(context); + + // Assert + Assert.Equal(0, userGrades.CountA); + Assert.Equal(1, userGrades.CountS); + } + + [Fact] + public async Task TestOnDeletionWithNonBestOriginalStateKeepsGradesUnchanged() + { + // Arrange + var processor = new UserGradesScoreProcessor(Database); + var user = await CreateTestUser(); + var userStats = await Database.Users.Stats.GetUserStats(user.Id, GameMode.Standard); + Assert.NotNull(userStats); + var userGrades = new UserGrades + { + UserId = user.Id, + GameMode = GameMode.Standard, + CountA = 1 + }; + var score = CreateScore(user, submissionStatus: SubmissionStatus.Submitted); + var originalState = ScoreStateSnapshot.Capture(score); + var context = ScoreCommitContextFactory.Create(ScoreTaskType.Delete, score, user, userStats, userGrades, originalState: originalState); + + // Act + await processor.OnDeletion(context); + + // Assert + Assert.Equal(1, userGrades.CountA); + } + + [Fact] + public async Task TestOnRestorationWithBestScoreIncrementsMatchingGradeCount() + { + // Arrange + var processor = new UserGradesScoreProcessor(Database); + var user = await CreateTestUser(); + var userStats = await Database.Users.Stats.GetUserStats(user.Id, GameMode.Standard); + Assert.NotNull(userStats); + var userGrades = new UserGrades + { + UserId = user.Id, + GameMode = GameMode.Standard + }; + var score = CreateScore(user); + var context = ScoreCommitContextFactory.Create(ScoreTaskType.Restore, score, user, userStats, userGrades, originalState: ScoreStateSnapshot.Capture(score)); + + // Act + await processor.OnRestoration(context); + + // Assert + Assert.Equal(1, userGrades.CountA); + } + + [Fact] + public async Task TestOnNewSubmissionWithUnknownGradeThrowsArgumentOutOfRangeException() + { + // Arrange + var processor = new UserGradesScoreProcessor(Database); + var user = await CreateTestUser(); + var userStats = await Database.Users.Stats.GetUserStats(user.Id, GameMode.Standard); + Assert.NotNull(userStats); + var userGrades = new UserGrades + { + UserId = user.Id, + GameMode = GameMode.Standard + }; + var score = CreateScore(user, "Z", submissionStatus: SubmissionStatus.Best); + var context = ScoreCommitContextFactory.Create(ScoreTaskType.Submission, score, user, userStats, userGrades, originalState: ScoreStateSnapshot.Capture(score)); + + // Act & Assert + await Assert.ThrowsAsync(() => processor.OnNewSubmission(context)); + } + + // TODO: Refactor this to proper fixture + private Score CreateScore( + User user, + string grade = "A", + bool isScoreable = true, + bool isPassed = true, + SubmissionStatus submissionStatus = SubmissionStatus.Best) + { + var beatmap = _mocker.Beatmap.GetRandomBeatmap(); + beatmap.StatusString = isScoreable ? "ranked" : "pending"; + beatmap.ModeInt = (int)GameMode.Standard; + + var score = new Score + { + ScoreHash = $"{Guid.NewGuid():N}", + TotalScore = 1000, + MaxCombo = 100, + Count300 = 100, + Count100 = 10, + Count50 = 0, + CountMiss = isPassed ? 0 : 1, + CountKatu = 0, + CountGeki = 0, + Perfect = false, + Mods = Mods.None, + Grade = grade, + IsPassed = isPassed, + IsScoreable = isScoreable, + SubmissionStatus = submissionStatus, + WhenPlayed = new DateTime(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc), + OsuVersion = "b20260101.1", + ClientTime = new DateTime(2026, 1, 2, 3, 4, 5), + Accuracy = isPassed ? 98 : 50, + PerformancePoints = 100, + TimeElapsed = 120 + }; + + score.EnrichWithUserData(user); + score.EnrichWithBeatmapData(beatmap); + score.LocalProperties = score.LocalProperties.FromScore(score); + return score; + } +} \ No newline at end of file diff --git a/Sunrise.Processing.Tests/Scores/Processors/UserStatsScoreProcessorTests.cs b/Sunrise.Processing.Tests/Scores/Processors/UserStatsScoreProcessorTests.cs new file mode 100644 index 00000000..fc61d174 --- /dev/null +++ b/Sunrise.Processing.Tests/Scores/Processors/UserStatsScoreProcessorTests.cs @@ -0,0 +1,875 @@ +using Microsoft.Extensions.DependencyInjection; +using Sunrise.Processing.Scores.Pipeline; +using Sunrise.Processing.Scores.Processors; +using Sunrise.Shared.Database.Models; +using Sunrise.Shared.Database.Models.Users; +using Sunrise.Shared.Enums.Beatmaps; +using Sunrise.Shared.Enums.Scores; +using Sunrise.Shared.Extensions.Beatmaps; +using Sunrise.Shared.Objects; +using Sunrise.Shared.Services; +using Sunrise.Shared.Utils.Calculators; +using Sunrise.Tests.Abstracts; +using Sunrise.Tests.Extensions; +using Sunrise.Tests.Services.Mock; +using Sunrise.Tests.Utils.Processing; +using Xunit; +using Mods = osu.Shared.Mods; +using SubmissionStatus = Sunrise.Shared.Enums.Scores.SubmissionStatus; +using GameMode = Sunrise.Shared.Enums.Beatmaps.GameMode; + +namespace Sunrise.Processing.Tests.Scores.Processors; + +[Collection("Integration tests collection")] +public class UserStatsScoreProcessorTests(IntegrationDatabaseFixture fixture) : DatabaseTest(fixture) +{ + private readonly MockService _mocker = new(); + + [Fact] + public async Task TestOnNewSubmissionWithFirstRankedScoreUpdatesStatsAndWeightedValues() + { + // Arrange + var user = await CreateTestUser(); + + var calculator = Scope.ServiceProvider.GetRequiredService(); + var processor = new UserStatsScoreProcessor(Database, calculator); + + var score = await CreateTestScore(user); + var (userStats, userGrades) = await LoadUserState(user, score.GameMode); + var previousStats = userStats.Clone(); + var expectedWeighted = PerformanceCalculator.CalculateUserWeightedStats([score]); + + var context = ScoreCommitContextFactory.Create(ScoreTaskType.Submission, score, user, userStats, userGrades, originalState: ScoreStateSnapshot.Capture(score)); + + // Act + await processor.OnNewSubmission(context); + + // Assert + AssertIncrementedCoreStats(previousStats, userStats, score); + Assert.Equal(previousStats.RankedScore + score.TotalScore, userStats.RankedScore); + Assert.Equal(score.MaxCombo, userStats.MaxCombo); + Assert.Equal(expectedWeighted.PerformancePoints, userStats.PerformancePoints, 6); + Assert.Equal(expectedWeighted.Accuracy, userStats.Accuracy, 6); + } + + [Fact] + public async Task TestOnNewSubmissionWithBetterRankedScoreUpdatesRankedScoreAndWeightedValues() + { + // Arrange + var user = await CreateTestUser(); + + var calculator = Scope.ServiceProvider.GetRequiredService(); + var processor = new UserStatsScoreProcessor(Database, calculator); + + var oldScore = await CreatePersistedScore(user, 1000, 90, 300); + var score = CreateScore(user, totalScore: 1200, performancePoints: 100, maxCombo: 400); + var (userStats, userGrades) = await LoadUserState(user, score.GameMode); + + userStats.UpdateWithDbScore(oldScore); + + var previousStats = userStats.Clone(); + var expectedWeighted = PerformanceCalculator.CalculateUserWeightedStats([score]); + + var context = ScoreCommitContextFactory.Create( + ScoreTaskType.Submission, + score, + user, + userStats, + userGrades, + userPersonalBestScores: new UserBeatmapPeers(null, new UserPersonalBestScores(oldScore)), + originalState: ScoreStateSnapshot.Capture(score)); + + // Act + await processor.OnNewSubmission(context); + + // Assert + Assert.Equal(previousStats.TotalScore + score.TotalScore, userStats.TotalScore); + Assert.Equal(previousStats.TotalHits + GetTotalHitsDelta(score), userStats.TotalHits); + Assert.Equal(previousStats.PlayTime + score.TimeElapsed, userStats.PlayTime); + Assert.Equal(previousStats.PlayCount + 1, userStats.PlayCount); + Assert.Equal(previousStats.RankedScore + (score.TotalScore - oldScore.TotalScore), userStats.RankedScore); + + Assert.Equal(score.MaxCombo, userStats.MaxCombo); + Assert.Equal(expectedWeighted.PerformancePoints, userStats.PerformancePoints, 6); + Assert.Equal(expectedWeighted.Accuracy, userStats.Accuracy, 6); + } + + [Fact] + public async Task TestOnNewSubmissionWithWorseRankedScoreKeepsRankedAndWeightedValues() + { + // Arrange + var user = await CreateTestUser(); + + var calculator = Scope.ServiceProvider.GetRequiredService(); + var processor = new UserStatsScoreProcessor(Database, calculator); + + var oldScore = await CreatePersistedScore(user, 1000, 100, 350); + var score = CreateScore(user, totalScore: 900, performancePoints: 90, maxCombo: 340); + var (userStats, userGrades) = await LoadUserState(user, score.GameMode); + userStats.UpdateWithDbScore(oldScore); + + var previousStats = userStats.Clone(); + + var context = ScoreCommitContextFactory.Create( + ScoreTaskType.Submission, + score, + user, + userStats, + userGrades, + userPersonalBestScores: new UserBeatmapPeers(null, new UserPersonalBestScores(oldScore)), + originalState: ScoreStateSnapshot.Capture(score)); + + // Act + await processor.OnNewSubmission(context); + + + // Assert + AssertIncrementedCoreStats(previousStats, userStats, score); + Assert.Equal(previousStats.RankedScore, userStats.RankedScore); + Assert.Equal(previousStats.MaxCombo, userStats.MaxCombo); + Assert.Equal(previousStats.PerformancePoints, userStats.PerformancePoints, 6); + Assert.Equal(previousStats.Accuracy, userStats.Accuracy, 6); + } + + [Fact] + public async Task TestOnNewSubmissionWithNewAlgorithmBetterTotalOnlyUpdatesRankedScoreOnly() + { + // Arrange + EnvManager.Set("General:UseNewPerformanceCalculationAlgorithm", "true"); + + var user = await CreateTestUser(); + + var calculator = Scope.ServiceProvider.GetRequiredService(); + var processor = new UserStatsScoreProcessor(Database, calculator); + + var oldScore = await CreatePersistedScore(user, 1100, 120, 300); + var score = CreateScore(user, totalScore: 1200, performancePoints: 100, maxCombo: 400); + var (userStats, userGrades) = await LoadUserState(user, score.GameMode); + userStats.UpdateWithDbScore(oldScore); + + var previousStats = userStats.Clone(); + + var context = ScoreCommitContextFactory.Create( + ScoreTaskType.Submission, + score, + user, + userStats, + userGrades, + userPersonalBestScores: new UserBeatmapPeers(null, new UserPersonalBestScores(oldScore)), + originalState: ScoreStateSnapshot.Capture(score)); + + // Act + await processor.OnNewSubmission(context); + + // Assert + AssertIncrementedCoreStats(previousStats, userStats, score); + Assert.Equal(previousStats.RankedScore + (score.TotalScore - oldScore.TotalScore), userStats.RankedScore); + Assert.Equal(score.MaxCombo, userStats.MaxCombo); + Assert.Equal(previousStats.PerformancePoints, userStats.PerformancePoints, 6); + Assert.Equal(previousStats.Accuracy, userStats.Accuracy, 6); + } + + [Fact] + public async Task TestOnNewSubmissionWithNewAlgorithmBetterPerformanceOnlyUpdatesWeightedValues() + { + // Arrange + EnvManager.Set("General:UseNewPerformanceCalculationAlgorithm", "true"); + + var user = await CreateTestUser(); + + var calculator = Scope.ServiceProvider.GetRequiredService(); + var processor = new UserStatsScoreProcessor(Database, calculator); + + var oldScore = await CreatePersistedScore(user, 1200, 100, 300); + var score = CreateScore(user, totalScore: 1100, performancePoints: 120, maxCombo: 400); + var (userStats, userGrades) = await LoadUserState(user, score.GameMode); + userStats.UpdateWithDbScore(oldScore); + + var previousStats = userStats.Clone(); + var expectedWeighted = PerformanceCalculator.CalculateUserWeightedStats([score]); + + var context = ScoreCommitContextFactory.Create( + ScoreTaskType.Submission, + score, + user, + userStats, + userGrades, + userPersonalBestScores: new UserBeatmapPeers(null, new UserPersonalBestScores(oldScore)), + originalState: ScoreStateSnapshot.Capture(score)); + + // Act + await processor.OnNewSubmission(context); + + // Assert + AssertIncrementedCoreStats(previousStats, userStats, score); + Assert.Equal(previousStats.RankedScore, userStats.RankedScore); + Assert.Equal(score.MaxCombo, userStats.MaxCombo); + Assert.Equal(expectedWeighted.PerformancePoints, userStats.PerformancePoints, 6); + Assert.Equal(expectedWeighted.Accuracy, userStats.Accuracy, 6); + } + + /// + /// Happens if we submitted score on a loved beatmap. It is not ranked, but it is scoreable. + /// + [Fact] + public async Task TestOnNewSubmissionWithUnrankedScoreableBeatmapUpdatesMaxComboOnly() + { + // Arrange + var user = await CreateTestUser(); + + var calculator = Scope.ServiceProvider.GetRequiredService(); + var processor = new UserStatsScoreProcessor(Database, calculator); + + var score = CreateScore(user, totalScore: 1000, performancePoints: 100, maxCombo: 450, beatmapStatus: BeatmapStatus.Loved, isScoreable: true); + var (userStats, userGrades) = await LoadUserState(user, score.GameMode); + userStats.MaxCombo = 100; + userStats.PerformancePoints = 50; + userStats.Accuracy = 90; + var previousStats = userStats.Clone(); + + var context = ScoreCommitContextFactory.Create(ScoreTaskType.Submission, score, user, userStats, userGrades, originalState: ScoreStateSnapshot.Capture(score)); + + // Act + await processor.OnNewSubmission(context); + + // Assert + AssertIncrementedCoreStats(previousStats, userStats, score); + Assert.Equal(previousStats.RankedScore, userStats.RankedScore); + Assert.Equal(score.MaxCombo, userStats.MaxCombo); + Assert.Equal(previousStats.PerformancePoints, userStats.PerformancePoints, 6); + Assert.Equal(previousStats.Accuracy, userStats.Accuracy, 6); + } + + [Theory] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(false, false)] + public async Task TestOnNewSubmissionWithFailedOrUnscoreableScoreKeepsRankedAndWeightedValues(bool isScoreable, bool isPassed) + { + // Arrange + var user = await CreateTestUser(); + + var calculator = Scope.ServiceProvider.GetRequiredService(); + var processor = new UserStatsScoreProcessor(Database, calculator); + + var score = CreateScore(user, totalScore: 1000, performancePoints: 100, maxCombo: 450, isScoreable: isScoreable, isPassed: isPassed, beatmapStatus: isScoreable ? BeatmapStatus.Ranked : BeatmapStatus.Pending); + var (userStats, userGrades) = await LoadUserState(user, score.GameMode); + userStats.MaxCombo = 100; + userStats.RankedScore = 500; + userStats.PerformancePoints = 50; + userStats.Accuracy = 90; + var previousStats = userStats.Clone(); + + var context = ScoreCommitContextFactory.Create(ScoreTaskType.Submission, score, user, userStats, userGrades, originalState: ScoreStateSnapshot.Capture(score)); + + // Act + await processor.OnNewSubmission(context); + + // Assert + AssertIncrementedCoreStats(previousStats, userStats, score); + Assert.Equal(previousStats.RankedScore, userStats.RankedScore); + Assert.Equal(previousStats.MaxCombo, userStats.MaxCombo); + Assert.Equal(previousStats.PerformancePoints, userStats.PerformancePoints, 6); + Assert.Equal(previousStats.Accuracy, userStats.Accuracy, 6); + } + + [Theory] + [InlineData(GameMode.Standard, 3)] + [InlineData(GameMode.Taiko, 5)] + [InlineData(GameMode.CatchTheBeat, 3)] + [InlineData(GameMode.Mania, 5)] + public async Task TestOnNewSubmissionWithDifferentGameModesUpdatesExpectedTotalHits(GameMode mode, int expectedDelta) + { + // Arrange + var user = await CreateTestUser(); + + var calculator = Scope.ServiceProvider.GetRequiredService(); + var processor = new UserStatsScoreProcessor(Database, calculator); + + var score = CreateScore(user, gameMode: mode, isScoreable: false, beatmapStatus: BeatmapStatus.Pending, count300: 1, count100: 1, count50: 1, countGeki: 1, countKatu: 1); + var (userStats, userGrades) = await LoadUserState(user, mode); + var previousStats = userStats.Clone(); + + var context = ScoreCommitContextFactory.Create(ScoreTaskType.Submission, score, user, userStats, userGrades, originalState: ScoreStateSnapshot.Capture(score)); + + // Act + await processor.OnNewSubmission(context); + + // Assert + Assert.Equal(previousStats.TotalHits + expectedDelta, userStats.TotalHits); + } + + [Fact] + public async Task TestOnDeletionWithBestRankedScoreUpdatesFallbackMaxComboRankedScoreAndWeightedValues() + { + // Arrange + var user = await CreateTestUser(); + + var calculator = Scope.ServiceProvider.GetRequiredService(); + var processor = new UserStatsScoreProcessor(Database, calculator); + + var promotedPeer = await CreatePersistedScore(user, 900, 90, 450); + var score = CreateScore(user, 1234, 1000, 100, 500, submissionStatus: SubmissionStatus.Best); + var (userStats, userGrades) = await LoadUserState(user, score.GameMode); + + userStats.TotalScore = score.TotalScore + promotedPeer.TotalScore; + userStats.TotalHits = GetTotalHitsDelta(score) + GetTotalHitsDelta(promotedPeer); + userStats.PlayTime = score.TimeElapsed + promotedPeer.TimeElapsed; + userStats.PlayCount = 2; + userStats.RankedScore = score.TotalScore; + userStats.MaxCombo = score.MaxCombo; + userStats.PerformancePoints = 999; + userStats.Accuracy = 88; + var expectedWeighted = await calculator.CalculateUserWeightedStats(user, score.GameMode); + var previousStats = userStats.Clone(); + + var context = ScoreCommitContextFactory.Create( + ScoreTaskType.Delete, + score, + user, + userStats, + userGrades, + userPersonalBestScores: new UserBeatmapPeers(new UserPersonalBestScores(promotedPeer), new UserPersonalBestScores(promotedPeer)), + originalState: ScoreStateSnapshot.Capture(score)); + + // Act + await processor.OnDeletion(context); + + // Assert + Assert.Equal(Math.Max(0, previousStats.TotalScore - score.TotalScore), userStats.TotalScore); + Assert.Equal(Math.Max(0, previousStats.TotalHits - GetTotalHitsDelta(score)), userStats.TotalHits); + Assert.Equal(Math.Max(0, previousStats.PlayTime - score.TimeElapsed), userStats.PlayTime); + Assert.Equal(Math.Max(0, previousStats.PlayCount - 1), userStats.PlayCount); + Assert.Equal(previousStats.RankedScore - (score.TotalScore - promotedPeer.TotalScore), userStats.RankedScore); + Assert.Equal(promotedPeer.MaxCombo, userStats.MaxCombo); + Assert.Equal(expectedWeighted.PerformancePoints, userStats.PerformancePoints, 6); + Assert.Equal(expectedWeighted.Accuracy, userStats.Accuracy, 6); + } + + [Fact] + public async Task TestOnDeletionWithOnlyExistingScoreForGamemodeResetMaxCombo() + { + // Arrange + var user = await CreateTestUser(); + + var calculator = Scope.ServiceProvider.GetRequiredService(); + var processor = new UserStatsScoreProcessor(Database, calculator); + + await CreatePersistedScore(user, 900, 90, 450, gameMode: GameMode.Mania); // We should ignore score in different game mode + + var score = CreateScore(user, 1234, 1000, 100, 500, submissionStatus: SubmissionStatus.Best, gameMode: GameMode.Standard); + var (userStats, userGrades) = await LoadUserState(user, score.GameMode); + + userStats.UpdateWithDbScore(score); + var previousStats = userStats.Clone(); + + var context = ScoreCommitContextFactory.Create( + ScoreTaskType.Delete, + score, + user, + userStats, + userGrades, + userPersonalBestScores: null, + originalState: ScoreStateSnapshot.Capture(score)); + + // Act + await processor.OnDeletion(context); + + // Assert + Assert.Equal(score.MaxCombo, previousStats.MaxCombo); + Assert.Equal(0, userStats.MaxCombo); + } + + [Fact] + public async Task TestOnDeletionFallbacksToSubmittedScoresEvenIfTheyAreNotTheBest() + { + // Arrange + var user = await CreateTestUser(); + + var calculator = Scope.ServiceProvider.GetRequiredService(); + var processor = new UserStatsScoreProcessor(Database, calculator); + + var peerSubmittedScore = await CreatePersistedScore(user, 900, 90, 450, SubmissionStatus.Submitted, gameMode: GameMode.Standard); + + var score = CreateScore(user, 1234, 1000, 100, 500, submissionStatus: SubmissionStatus.Best, gameMode: GameMode.Standard); + var (userStats, userGrades) = await LoadUserState(user, score.GameMode); + + userStats.UpdateWithDbScore(score); + var previousStats = userStats.Clone(); + + var context = ScoreCommitContextFactory.Create( + ScoreTaskType.Delete, + score, + user, + userStats, + userGrades, + userPersonalBestScores: null, + originalState: ScoreStateSnapshot.Capture(score)); + + // Act + await processor.OnDeletion(context); + + // Assert + Assert.Equal(score.MaxCombo, previousStats.MaxCombo); + Assert.Equal(peerSubmittedScore.MaxCombo, userStats.MaxCombo); + } + + [Fact] + public async Task TestOnDeletionWithFailedOriginalKeepsRankedAndWeightedValues() + { + // Arrange + var user = await CreateTestUser(); + + var calculator = Scope.ServiceProvider.GetRequiredService(); + var processor = new UserStatsScoreProcessor(Database, calculator); + + var score = CreateScore(user, 1234, 1000, 100, 500, submissionStatus: SubmissionStatus.Failed, isPassed: false); + var (userStats, userGrades) = await LoadUserState(user, score.GameMode); + + userStats.TotalScore = score.TotalScore; + userStats.TotalHits = GetTotalHitsDelta(score); + userStats.PlayTime = score.TimeElapsed; + userStats.PlayCount = 1; + userStats.RankedScore = 500; + userStats.MaxCombo = 200; + userStats.PerformancePoints = 75; + userStats.Accuracy = 91; + var previousStats = userStats.Clone(); + + var context = ScoreCommitContextFactory.Create(ScoreTaskType.Delete, score, user, userStats, userGrades, originalState: ScoreStateSnapshot.Capture(score)); + + // Act + await processor.OnDeletion(context); + + // Assert + Assert.Equal(0, userStats.TotalScore); + Assert.Equal(0, userStats.TotalHits); + Assert.Equal(0, userStats.PlayTime); + Assert.Equal(0, userStats.PlayCount); + Assert.Equal(previousStats.RankedScore, userStats.RankedScore); + Assert.Equal(previousStats.MaxCombo, userStats.MaxCombo); + Assert.Equal(previousStats.PerformancePoints, userStats.PerformancePoints, 6); + Assert.Equal(previousStats.Accuracy, userStats.Accuracy, 6); + } + + [Fact] + public async Task TestOnDeletionWithBestButNotGloballyBestRankedScoreKeepsRankedScoreUnchanged() + { + // Arrange + var user = await CreateTestUser(); + + var calculator = Scope.ServiceProvider.GetRequiredService(); + var processor = new UserStatsScoreProcessor(Database, calculator); + + var globalBestScore = await CreatePersistedScore(user, 2000, 120, 600, mods: Mods.Hidden); + var score = CreateScore(user, totalScore: 1500, performancePoints: 100, maxCombo: 500, submissionStatus: SubmissionStatus.Best); + var (userStats, userGrades) = await LoadUserState(user, score.GameMode); + + userStats.TotalScore = globalBestScore.TotalScore + score.TotalScore; + userStats.TotalHits = GetTotalHitsDelta(globalBestScore) + GetTotalHitsDelta(score); + userStats.PlayTime = globalBestScore.TimeElapsed + score.TimeElapsed; + userStats.PlayCount = 2; + userStats.RankedScore = globalBestScore.TotalScore; + userStats.MaxCombo = globalBestScore.MaxCombo; + var previousStats = userStats.Clone(); + + var context = ScoreCommitContextFactory.Create( + ScoreTaskType.Delete, + score, + user, + userStats, + userGrades, + userPersonalBestScores: new UserBeatmapPeers(null, new UserPersonalBestScores(globalBestScore)), + originalState: ScoreStateSnapshot.Capture(score)); + + // Act + await processor.OnDeletion(context); + + // Assert + Assert.Equal(previousStats.RankedScore, userStats.RankedScore); + } + + [Fact] + public async Task TestOnDeletionWithGloballyBestButUnrankedScoreKeepsRankedScoreUnchanged() + { + // Arrange + var user = await CreateTestUser(); + + var calculator = Scope.ServiceProvider.GetRequiredService(); + var processor = new UserStatsScoreProcessor(Database, calculator); + + var previousBest = await CreatePersistedScore(user, 1200, 90, 450); + var score = CreateScore( + user, + totalScore: 1800, + performancePoints: 110, + maxCombo: 500, + submissionStatus: SubmissionStatus.Best, + beatmapStatus: BeatmapStatus.Loved, + isScoreable: true); + var (userStats, userGrades) = await LoadUserState(user, score.GameMode); + + userStats.TotalScore = previousBest.TotalScore + score.TotalScore; + userStats.TotalHits = GetTotalHitsDelta(previousBest) + GetTotalHitsDelta(score); + userStats.PlayTime = previousBest.TimeElapsed + score.TimeElapsed; + userStats.PlayCount = 2; + userStats.RankedScore = previousBest.TotalScore; + userStats.MaxCombo = previousBest.MaxCombo; + var previousStats = userStats.Clone(); + + var context = ScoreCommitContextFactory.Create( + ScoreTaskType.Delete, + score, + user, + userStats, + userGrades, + userPersonalBestScores: new UserBeatmapPeers(null, new UserPersonalBestScores(previousBest)), + originalState: ScoreStateSnapshot.Capture(score)); + + // Act + await processor.OnDeletion(context); + + // Assert + Assert.Equal(previousStats.RankedScore, userStats.RankedScore); + } + + [Fact] + public async Task TestOnDeletionWithRelaxStandardBestScoreUpdatesRankedScoreAndRefreshesWeightedValues() + { + // Arrange + var user = await CreateTestUser(); + + var calculator = Scope.ServiceProvider.GetRequiredService(); + var processor = new UserStatsScoreProcessor(Database, calculator); + + var promotedPeer = await CreatePersistedScore(user, 1200, 90, 450, gameMode: GameMode.Standard, mods: Mods.Relax); + var score = CreateScore(user, totalScore: 1500, performancePoints: 100, maxCombo: 500, gameMode: GameMode.Standard, mods: Mods.Relax, submissionStatus: SubmissionStatus.Best); + var (userStats, userGrades) = await LoadUserState(user, score.GameMode); + + Assert.Equal(GameMode.RelaxStandard, score.GameMode); + Assert.True(score.GameMode.IsGameModeWithoutScoreMultiplier()); + + userStats.TotalScore = score.TotalScore + promotedPeer.TotalScore; + userStats.TotalHits = GetTotalHitsDelta(score) + GetTotalHitsDelta(promotedPeer); + userStats.PlayTime = score.TimeElapsed + promotedPeer.TimeElapsed; + userStats.PlayCount = 2; + userStats.RankedScore = score.TotalScore; + userStats.PerformancePoints = 999; + userStats.Accuracy = 88; + + var expectedWeighted = await calculator.CalculateUserWeightedStats(user, score.GameMode); + var previousStats = userStats.Clone(); + + var context = ScoreCommitContextFactory.Create( + ScoreTaskType.Delete, + score, + user, + userStats, + userGrades, + userPersonalBestScores: new UserBeatmapPeers(new UserPersonalBestScores(promotedPeer), new UserPersonalBestScores(promotedPeer)), + originalState: ScoreStateSnapshot.Capture(score)); + + // Act + await processor.OnDeletion(context); + + // Assert + Assert.Equal(previousStats.RankedScore - (score.TotalScore - promotedPeer.TotalScore), userStats.RankedScore); + Assert.Equal(expectedWeighted.PerformancePoints, userStats.PerformancePoints, 6); + Assert.Equal(expectedWeighted.Accuracy, userStats.Accuracy, 6); + } + + [Fact] + public async Task TestOnRecalculationWithRankedPassedScoreRefreshesWeightedValues() + { + // Arrange + var user = await CreateTestUser(); + + var calculator = Scope.ServiceProvider.GetRequiredService(); + var processor = new UserStatsScoreProcessor(Database, calculator); + + var score = await CreatePersistedScore(user, 1000, 100, 400); + var (userStats, userGrades) = await LoadUserState(user, score.GameMode); + + score.PerformancePoints = 140; + score.Accuracy = 98; + await Database.Scores.UpdateScore(score); + + var (expectedWeightedPerformancePoints, expectedWeightedAccuracy) = (PerformanceCalculator.CalculateUserWeightedPerformance([score]), PerformanceCalculator.CalculateUserWeightedAccuracy([score])); + + var context = ScoreCommitContextFactory.Create(ScoreTaskType.Recalculation, score, user, userStats, userGrades, originalState: ScoreStateSnapshot.Capture(score)); + + // Act + await processor.OnRecalculation(context); + + // Assert + var (updatedUserStats, _) = await LoadUserState(user, score.GameMode); + + Assert.Equal(expectedWeightedPerformancePoints, updatedUserStats.PerformancePoints, 6); + Assert.Equal(expectedWeightedAccuracy, updatedUserStats.Accuracy, 6); + } + + [Theory] + [InlineData(false, true, BeatmapStatus.Ranked)] + [InlineData(true, false, BeatmapStatus.Ranked)] + [InlineData(true, true, BeatmapStatus.Loved)] + public async Task TestOnRecalculationWithNonRankedOrFailedScoreKeepsWeightedValues(bool isScoreable, bool isPassed, BeatmapStatus beatmapStatus) + { + // Arrange + var user = await CreateTestUser(); + + var calculator = Scope.ServiceProvider.GetRequiredService(); + var processor = new UserStatsScoreProcessor(Database, calculator); + + var score = CreateScore(user, isScoreable: isScoreable, isPassed: isPassed, beatmapStatus: beatmapStatus); + var (userStats, userGrades) = await LoadUserState(user, score.GameMode); + userStats.PerformancePoints = 50; + userStats.Accuracy = 90; + + var context = ScoreCommitContextFactory.Create(ScoreTaskType.Recalculation, score, user, userStats, userGrades, originalState: ScoreStateSnapshot.Capture(score)); + + // Act + await processor.OnRecalculation(context); + + // Assert + Assert.Equal(50, userStats.PerformancePoints, 6); + Assert.Equal(90, userStats.Accuracy, 6); + } + + [Fact] + public async Task TestOnRestorationWithRankedScoreUpdatesStatsAndWeightedValues() + { + // Arrange + var user = await CreateTestUser(); + + var calculator = Scope.ServiceProvider.GetRequiredService(); + var processor = new UserStatsScoreProcessor(Database, calculator); + + var score = CreateScore(user, totalScore: 1000, performancePoints: 100, maxCombo: 400); + var (userStats, userGrades) = await LoadUserState(user, score.GameMode); + var previousStats = userStats.Clone(); + var expectedWeighted = PerformanceCalculator.CalculateUserWeightedStats([score]); + + var context = ScoreCommitContextFactory.Create(ScoreTaskType.Restore, score, user, userStats, userGrades, originalState: ScoreStateSnapshot.Capture(score)); + + // Act + await processor.OnRestoration(context); + + // Assert + AssertIncrementedCoreStats(previousStats, userStats, score); + Assert.Equal(previousStats.RankedScore + score.TotalScore, userStats.RankedScore); + Assert.Equal(score.MaxCombo, userStats.MaxCombo); + Assert.Equal(expectedWeighted.PerformancePoints, userStats.PerformancePoints, 6); + Assert.Equal(expectedWeighted.Accuracy, userStats.Accuracy, 6); + } + + [Fact] + public async Task TestOnRestorationWithBestButNotGloballyBestRankedScoreKeepsRankedScoreUnchanged() + { + // Arrange + var user = await CreateTestUser(); + + var calculator = Scope.ServiceProvider.GetRequiredService(); + var processor = new UserStatsScoreProcessor(Database, calculator); + + var globalBestScore = await CreatePersistedScore(user, 2000, 120, 600, mods: Mods.Hidden); + var score = CreateScore(user, totalScore: 1500, performancePoints: 100, maxCombo: 500, submissionStatus: SubmissionStatus.Best); + var (userStats, userGrades) = await LoadUserState(user, score.GameMode); + + userStats.RankedScore = globalBestScore.TotalScore; + var previousStats = userStats.Clone(); + + var context = ScoreCommitContextFactory.Create( + ScoreTaskType.Restore, + score, + user, + userStats, + userGrades, + userPersonalBestScores: new UserBeatmapPeers(null, new UserPersonalBestScores(globalBestScore)), + originalState: ScoreStateSnapshot.Capture(score)); + + // Act + await processor.OnRestoration(context); + + // Assert + Assert.Equal(previousStats.RankedScore, userStats.RankedScore); + } + + [Fact] + public async Task TestOnRestorationWithGloballyBestButUnrankedScoreKeepsRankedScoreUnchanged() + { + // Arrange + var user = await CreateTestUser(); + + var calculator = Scope.ServiceProvider.GetRequiredService(); + var processor = new UserStatsScoreProcessor(Database, calculator); + + var previousBest = await CreatePersistedScore(user, 1200, 90, 450); + var score = CreateScore( + user, + totalScore: 1800, + performancePoints: 110, + maxCombo: 500, + submissionStatus: SubmissionStatus.Best, + beatmapStatus: BeatmapStatus.Loved, + isScoreable: true); + var (userStats, userGrades) = await LoadUserState(user, score.GameMode); + + userStats.RankedScore = previousBest.TotalScore; + var previousStats = userStats.Clone(); + + var context = ScoreCommitContextFactory.Create( + ScoreTaskType.Restore, + score, + user, + userStats, + userGrades, + userPersonalBestScores: new UserBeatmapPeers(null, new UserPersonalBestScores(previousBest)), + originalState: ScoreStateSnapshot.Capture(score)); + + // Act + await processor.OnRestoration(context); + + // Assert + Assert.Equal(previousStats.RankedScore, userStats.RankedScore); + } + + [Fact] + public async Task TestOnRestorationWithRelaxStandardBestScoreUpdatesRankedScoreAndRefreshesWeightedValues() + { + // Arrange + var user = await CreateTestUser(); + + var calculator = Scope.ServiceProvider.GetRequiredService(); + var processor = new UserStatsScoreProcessor(Database, calculator); + + var existingBest = await CreatePersistedScore(user, 1200, 90, 450, gameMode: GameMode.Standard, mods: Mods.Relax); + var score = CreateScore(user, totalScore: 1500, performancePoints: 100, maxCombo: 500, gameMode: GameMode.Standard, mods: Mods.Relax, submissionStatus: SubmissionStatus.Best); + var (userStats, userGrades) = await LoadUserState(user, score.GameMode); + + Assert.Equal(GameMode.RelaxStandard, score.GameMode); + Assert.True(score.GameMode.IsGameModeWithoutScoreMultiplier()); + + userStats.RankedScore = existingBest.TotalScore; + userStats.PerformancePoints = 60; + userStats.Accuracy = 80; + var expectedWeighted = PerformanceCalculator.CalculateUserWeightedStats([score]); + var previousStats = userStats.Clone(); + + var context = ScoreCommitContextFactory.Create( + ScoreTaskType.Restore, + score, + user, + userStats, + userGrades, + userPersonalBestScores: new UserBeatmapPeers(null, new UserPersonalBestScores(existingBest)), + originalState: ScoreStateSnapshot.Capture(score)); + + // Act + await processor.OnRestoration(context); + + // Assert + Assert.Equal(previousStats.RankedScore + (score.TotalScore - existingBest.TotalScore), userStats.RankedScore); + Assert.Equal(expectedWeighted.PerformancePoints, userStats.PerformancePoints, 6); + Assert.Equal(expectedWeighted.Accuracy, userStats.Accuracy, 6); + } + + private async Task<(UserStats UserStats, UserGrades UserGrades)> LoadUserState(User user, GameMode mode) + { + var userStats = await Database.Users.Stats.GetUserStats(user.Id, mode); + var userGrades = await Database.Users.Grades.GetUserGrades(user.Id, mode); + + Assert.NotNull(userStats); + Assert.NotNull(userGrades); + + return (userStats, userGrades); + } + + private async Task CreatePersistedScore( + User user, + long totalScore, + double performancePoints, + int maxCombo, + SubmissionStatus submissionStatus = SubmissionStatus.Best, + bool isPassed = true, + GameMode gameMode = GameMode.Standard, + Mods mods = Mods.None) + { + var score = CreateScore(user, totalScore: totalScore, performancePoints: performancePoints, maxCombo: maxCombo, submissionStatus: submissionStatus, isPassed: isPassed, gameMode: gameMode, mods: mods); + return await CreateTestScore(score); + } + + private static void AssertIncrementedCoreStats(UserStats previousStats, UserStats currentStats, Score score) + { + Assert.Equal(previousStats.TotalScore + score.TotalScore, currentStats.TotalScore); + Assert.Equal(previousStats.TotalHits + GetTotalHitsDelta(score), currentStats.TotalHits); + Assert.Equal(previousStats.PlayTime + score.TimeElapsed, currentStats.PlayTime); + Assert.Equal(previousStats.PlayCount + 1, currentStats.PlayCount); + } + + private static int GetTotalHitsDelta(Score score) + { + var delta = score.Count300 + score.Count100 + score.Count50; + + if ((GameMode)score.GameMode.ToVanillaGameMode() is GameMode.Taiko or GameMode.Mania) + delta += score.CountGeki + score.CountKatu; + + return delta; + } + + // TODO: Refactor this to proper fixture + private Score CreateScore( + User user, + int id = 0, + long totalScore = 1000, + double performancePoints = 100, + int maxCombo = 400, + bool isPassed = true, + bool isScoreable = true, + GameMode gameMode = GameMode.Standard, + Mods mods = Mods.None, + SubmissionStatus submissionStatus = SubmissionStatus.Best, + BeatmapStatus beatmapStatus = BeatmapStatus.Ranked, + int count300 = 100, + int count100 = 10, + int count50 = 0, + int countGeki = 0, + int countKatu = 0) + { + var beatmap = _mocker.Beatmap.GetRandomBeatmap(); + beatmap.StatusString = beatmapStatus.BeatmapStatusToString(); + beatmap.ModeInt = (int)gameMode.ToVanillaGameMode(); + + var score = new Score + { + Id = id, + ScoreHash = $"{Guid.NewGuid():N}", + TotalScore = totalScore, + MaxCombo = maxCombo, + Count300 = count300, + Count100 = count100, + Count50 = count50, + CountMiss = isPassed ? 0 : 1, + CountKatu = countKatu, + CountGeki = countGeki, + Perfect = false, + Mods = mods, + Grade = isPassed ? "A" : "F", + IsPassed = isPassed, + IsScoreable = isScoreable, + SubmissionStatus = submissionStatus, + GameMode = gameMode, + WhenPlayed = new DateTime(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc), + OsuVersion = "b20260101.1", + ClientTime = new DateTime(2026, 1, 2, 3, 4, 5), + Accuracy = isPassed ? 98 : 50, + PerformancePoints = performancePoints, + TimeElapsed = 120 + }; + + score.EnrichWithUserData(user); + score.EnrichWithBeatmapData(beatmap); + score.GameMode = score.GameMode.EnrichWithMods(score.Mods); + score.LocalProperties = score.LocalProperties.FromScore(score); + return score; + } +} \ No newline at end of file diff --git a/Sunrise.Processing.Tests/Services/ScoreSideEffectsPublisherServiceTests.cs b/Sunrise.Processing.Tests/Services/ScoreSideEffectsPublisherServiceTests.cs new file mode 100644 index 00000000..ddb3f632 --- /dev/null +++ b/Sunrise.Processing.Tests/Services/ScoreSideEffectsPublisherServiceTests.cs @@ -0,0 +1,258 @@ +using HOPEless.Bancho; +using HOPEless.Bancho.Objects; +using Microsoft.Extensions.DependencyInjection; +using Sunrise.Processing.Services; +using Sunrise.Processing.Utils; +using Sunrise.Shared.Application; +using Sunrise.Shared.Database.Models; +using Sunrise.Shared.Database.Models.Users; +using Sunrise.Shared.Enums.Beatmaps; +using Sunrise.Shared.Enums.Scores; +using Sunrise.Shared.Extensions; +using Sunrise.Shared.Objects.Sessions; +using Sunrise.Shared.Repositories; +using Sunrise.Tests.Abstracts; +using Sunrise.Tests.Extensions; +using Sunrise.Tests.Services.Mock; +using Sunrise.Tests.Utils.Processing; +using Xunit; +using Mods = osu.Shared.Mods; + +namespace Sunrise.Processing.Tests.Services; + +[Collection("Integration tests collection")] +public class ScoreSideEffectsPublisherServiceTests(IntegrationDatabaseFixture fixture, bool reuseScopeInContext = true) : DatabaseTest(fixture, reuseScopeInContext) +{ + private readonly MockService _mocker = new(); + + [Fact] + public async Task TestPublishScoreSubmissionSideEffectsWithoutBeatmapReturnsError() + { + // Arrange + using var scope = Scope; + var service = scope.ServiceProvider.GetRequiredService(); + var user = await CreateTestUser(); + var score = _mocker.Score.GetBestScoreableRandomScore(); + score.EnrichWithUserData(user); + var (userStats, userGrades) = await LoadUserState(user, score.GameMode); + var ctx = ScoreCommitContextFactory.Create(ScoreTaskType.Submission, score, user, userStats, userGrades); + + // Act + var result = await service.PublishScoreSubmissionSideEffects( + BaseSession.GenerateServerSession(), + ctx, + CancellationToken.None); + + // Assert + Assert.NotEmpty(result.Error); + + Assert.Equal("Beatmap and beatmap set must be present in context to publish score side effects.", result.Error); + } + + [Fact] + public async Task TestPublishScoreSubmissionSideEffectsWithNewFirstPlaceSendsAnnouncement() + { + // Arrange + using var scope = Scope; + var service = scope.ServiceProvider.GetRequiredService(); + var channels = scope.ServiceProvider.GetRequiredService(); + + var user = await CreateTestUser(); + var session = CreateTestSession(user); + channels.JoinChannel("#announce", session); + session.GetContent(); + + var otherUser = await CreateTestUser(); + var beatmapSet = _mocker.Beatmap.GetRandomBeatmapSet(); + beatmapSet.IgnoreBeatmapRanking(); + var beatmap = beatmapSet.Beatmaps!.First(); + + var previousTopScore = _mocker.Score.GetBestScoreableRandomScore(); + previousTopScore.EnrichWithUserData(otherUser); + previousTopScore.Mods = Mods.None; + previousTopScore.TotalScore = 900; + previousTopScore.EnrichWithBeatmapData(beatmap); + previousTopScore.LocalProperties = previousTopScore.LocalProperties.FromScore(previousTopScore); + await CreateTestScore(previousTopScore); + + var score = _mocker.Score.GetBestScoreableRandomScore(); + score.EnrichWithUserData(user); + score.Mods = Mods.None; + score.TotalScore = 1000; + score.EnrichWithBeatmapData(beatmap); + score.LocalProperties = score.LocalProperties.FromScore(score); + score = await CreateTestScore(score); + + var (userStats, userGrades) = await LoadUserState(user, score.GameMode); + ApplyScoreToUserStats(userStats, score); + + var ctx = ScoreCommitContextFactory.Create(ScoreTaskType.Submission, score, user, userStats, userGrades, beatmap, beatmapSet); + + // Act + var result = await service.PublishScoreSubmissionSideEffects( + BaseSession.GenerateServerSession(), + ctx, + CancellationToken.None); + + // Assert + Assert.True(result.IsSuccess); + + var chatPacket = GetSessionPackets(session).FirstOrDefault(packet => packet.Type == PacketType.ServerChatMessage); + Assert.NotNull(chatPacket); + + var chatMessage = new BanchoChatMessage(chatPacket.Data); + Assert.Equal(Configuration.BotUsername, chatMessage.Sender); + Assert.Equal("#announce", chatMessage.Channel); + Assert.Equal(ScoreSubmissionUtil.GetNewFirstPlaceString(score, beatmapSet, beatmap), chatMessage.Message); + } + + [Fact] + public async Task TestPublishScoreSubmissionSideEffectsWithoutLeaderboardTakeoverDoesNotSendAnnouncement() + { + // Arrange + using var scope = Scope; + var service = scope.ServiceProvider.GetRequiredService(); + var channels = scope.ServiceProvider.GetRequiredService(); + + var user = await CreateTestUser(); + var session = CreateTestSession(user); + channels.JoinChannel("#announce", session); + session.GetContent(); + + var beatmapSet = _mocker.Beatmap.GetRandomBeatmapSet(); + beatmapSet.IgnoreBeatmapRanking(); + var beatmap = beatmapSet.Beatmaps!.First(); + + var existingBest = _mocker.Score.GetBestScoreableRandomScore(); + existingBest.EnrichWithUserData(user); + existingBest.Mods = Mods.None; + existingBest.TotalScore = 900; + existingBest.EnrichWithBeatmapData(beatmap); + existingBest.LocalProperties = existingBest.LocalProperties.FromScore(existingBest); + await CreateTestScore(existingBest); + + var score = _mocker.Score.GetBestScoreableRandomScore(); + score.EnrichWithUserData(user); + score.Mods = Mods.None; + score.TotalScore = 1000; + score.EnrichWithBeatmapData(beatmap); + score.LocalProperties = score.LocalProperties.FromScore(score); + score = await CreateTestScore(score); + + var (userStats, userGrades) = await LoadUserState(user, score.GameMode); + ApplyScoreToUserStats(userStats, score); + + var ctx = ScoreCommitContextFactory.Create(ScoreTaskType.Submission, score, user, userStats, userGrades, beatmap, beatmapSet); + + // Act + var result = await service.PublishScoreSubmissionSideEffects( + BaseSession.GenerateServerSession(), + ctx, + CancellationToken.None); + + // Assert + Assert.True(result.IsSuccess); + + Assert.DoesNotContain(GetSessionPackets(session), packet => packet.Type == PacketType.ServerChatMessage); + } + + [Fact] + public async Task TestPublishScoreSubmissionSideEffectsWithRelaxFirstPlaceUsesScoreValueComparison() + { + // Arrange + using var scope = Scope; + var service = scope.ServiceProvider.GetRequiredService(); + var channels = scope.ServiceProvider.GetRequiredService(); + + var user = await CreateTestUser(); + var session = CreateTestSession(user); + channels.JoinChannel("#announce", session); + session.GetContent(); + + var otherUser = await CreateTestUser(); + + var otherBeatmapSet = _mocker.Beatmap.GetRandomBeatmapSet(); + otherBeatmapSet.IgnoreBeatmapRanking(); + var otherBeatmap = otherBeatmapSet.Beatmaps!.First(); + + var overallBest = _mocker.Score.GetBestScoreableRandomScore(); + overallBest.EnrichWithUserData(user); + overallBest.GameMode = GameMode.Standard; + overallBest.Mods = Mods.Relax; + overallBest.TotalScore = 1000; + overallBest.PerformancePoints = 150; + overallBest.EnrichWithBeatmapData(otherBeatmap); + overallBest.LocalProperties = overallBest.LocalProperties.FromScore(overallBest); + await CreateTestScore(overallBest); + + var beatmapSet = _mocker.Beatmap.GetRandomBeatmapSet(); + beatmapSet.IgnoreBeatmapRanking(); + var beatmap = beatmapSet.Beatmaps!.First(); + + var secondPlace = _mocker.Score.GetBestScoreableRandomScore(); + secondPlace.EnrichWithUserData(otherUser); + secondPlace.GameMode = GameMode.Standard; + secondPlace.Mods = Mods.Relax; + secondPlace.TotalScore = 5000; + secondPlace.PerformancePoints = 140; + secondPlace.EnrichWithBeatmapData(beatmap); + secondPlace.LocalProperties = secondPlace.LocalProperties.FromScore(secondPlace); + await CreateTestScore(secondPlace); + + var score = _mocker.Score.GetBestScoreableRandomScore(); + score.EnrichWithUserData(user); + score.GameMode = GameMode.Standard; + score.Mods = Mods.Relax; + score.TotalScore = 1200; + score.PerformancePoints = 160; + score.EnrichWithBeatmapData(beatmap); + score.LocalProperties = score.LocalProperties.FromScore(score); + score = await CreateTestScore(score); + + var (userStats, userGrades) = await LoadUserState(user, score.GameMode); + ApplyScoreToUserStats(userStats, score); + + var ctx = ScoreCommitContextFactory.Create(ScoreTaskType.Submission, score, user, userStats, userGrades, beatmap, beatmapSet); + + // Act + var result = await service.PublishScoreSubmissionSideEffects( + BaseSession.GenerateServerSession(), + ctx, + CancellationToken.None); + + // Assert + Assert.True(result.IsSuccess); + + Assert.DoesNotContain(GetSessionPackets(session), packet => packet.Type == PacketType.ServerChatMessage); + } + + private async Task<(UserStats UserStats, UserGrades UserGrades)> LoadUserState(User user, GameMode mode) + { + var userStats = await Database.Users.Stats.GetUserStats(user.Id, mode); + var userGrades = await Database.Users.Grades.GetUserGrades(user.Id, mode); + + Assert.NotNull(userStats); + Assert.NotNull(userGrades); + + return (userStats, userGrades); + } + + private static void ApplyScoreToUserStats(UserStats userStats, Score score) + { + userStats.PlayCount = 1; + userStats.PlayTime = score.TimeElapsed; + userStats.TotalScore = score.TotalScore; + userStats.RankedScore = score.TotalScore; + userStats.MaxCombo = score.MaxCombo; + userStats.Accuracy = score.Accuracy; + userStats.PerformancePoints = score.PerformancePoints; + userStats.TotalHits = score.Count300 + score.Count100 + score.Count50 + score.CountMiss + score.CountKatu + score.CountGeki; + } + + private static List GetSessionPackets(Session session) + { + var content = session.GetContent(); + using var buffer = new MemoryStream(content); + return BanchoSerializer.DeserializePackets(buffer).ToList(); + } +} \ No newline at end of file diff --git a/Sunrise.Processing.Tests/Sunrise.Processing.Tests.csproj b/Sunrise.Processing.Tests/Sunrise.Processing.Tests.csproj new file mode 100644 index 00000000..11cffdd4 --- /dev/null +++ b/Sunrise.Processing.Tests/Sunrise.Processing.Tests.csproj @@ -0,0 +1,24 @@ + + + + net8.0 + enable + enable + + false + true + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + diff --git a/Sunrise.Processing.Tests/Utils/ScoreCandidateBuilderUtilTests.cs b/Sunrise.Processing.Tests/Utils/ScoreCandidateBuilderUtilTests.cs new file mode 100644 index 00000000..1a1e2715 --- /dev/null +++ b/Sunrise.Processing.Tests/Utils/ScoreCandidateBuilderUtilTests.cs @@ -0,0 +1,227 @@ +using Sunrise.Processing.Utils; +using Sunrise.Shared.Database.Models; +using Sunrise.Shared.Database.Models.Scores; +using Sunrise.Shared.Enums.Scores; +using Sunrise.Shared.Extensions.Beatmaps; +using Sunrise.Shared.Extensions.Scores; +using Sunrise.Shared.Objects.Serializable; +using Sunrise.Tests.Abstracts; +using Sunrise.Tests.Extensions; +using Sunrise.Tests.Services.Mock; +using Xunit; +using Mods = osu.Shared.Mods; + +namespace Sunrise.Processing.Tests.Utils; + +public class ScoreCandidateBuilderUtilTests : BaseTest +{ + private readonly MockService _mocker = new(); + + [Fact] + public void TestBuildWithValidQueueEntryReturnsScoreAndSubmittedScore() + { + // Arrange + var (queueEntry, originalScore, beatmap, username, _) = CreateValidQueueEntry(); + + // Act + var result = ScoreCandidateBuilderUtil.Build(queueEntry, beatmap); + + // Assert + Assert.True(result.IsSuccess); + + Assert.Equal(username, result.Value.submittedScore.PlayerUsername); + Assert.Equal(queueEntry.WhenPlayed, result.Value.submittedScore.WhenPlayed); + Assert.Equal(queueEntry.UserId, result.Value.score.UserId); + Assert.Equal(originalScore.BeatmapHash, result.Value.score.BeatmapHash); + Assert.Equal(originalScore.ScoreHash, result.Value.score.ScoreHash); + Assert.Equal(beatmap.Id, result.Value.score.BeatmapId); + Assert.Equal(queueEntry.ReplayFileId, result.Value.score.ReplayFileId); + } + + [Fact] + public void TestBuildWithInvalidScoreStringReturnsParsedScoreInvalidError() + { + // Arrange + var (queueEntry, _, beatmap, _, _) = CreateValidQueueEntry(); + + queueEntry.ScoreSerialized = "invalid-score-string"; + + // Act + var result = ScoreCandidateBuilderUtil.Build(queueEntry, beatmap); + + // Assert + Assert.True(result.IsFailure); + + Assert.Equal(ScoreProcessingErrorCode.ParsedScoreInvalid, result.Error.Code); + } + + [Fact] + public void TestValidateBuiltScoreWithValidQueueEntryReturnsSuccess() + { + // Arrange + var (queueEntry, _, beatmap, _, _) = CreateValidQueueEntry(); + var buildResult = ScoreCandidateBuilderUtil.Build(queueEntry, beatmap); + + // Act + var result = ScoreCandidateBuilderUtil.ValidateBuiltScore(queueEntry, buildResult.Value.score, buildResult.Value.submittedScore, beatmap.Checksum!); + + // Assert + Assert.True(result.IsSuccess); + } + + [Fact] + public void TestValidateBuiltScoreWithPassedScoreWithoutReplayReturnsReplayMissingError() + { + // Arrange + var (queueEntry, _, beatmap, _, _) = CreateValidQueueEntry(replayFileId: null); + var buildResult = ScoreCandidateBuilderUtil.Build(queueEntry, beatmap); + + // Act + var result = ScoreCandidateBuilderUtil.ValidateBuiltScore(queueEntry, buildResult.Value.score, buildResult.Value.submittedScore, beatmap.Checksum!); + + // Assert + Assert.True(result.IsFailure); + + Assert.Equal(ScoreProcessingErrorCode.ReplayMissing, result.Error.Code); + } + + [Fact] + public void TestValidateBuiltScoreWithFailedScoreWithoutReplayReturnsSuccess() + { + // Arrange + var (queueEntry, _, beatmap, _, _) = CreateValidQueueEntry(Mods.None, false, null); + var buildResult = ScoreCandidateBuilderUtil.Build(queueEntry, beatmap); + + // Act + var result = ScoreCandidateBuilderUtil.ValidateBuiltScore(queueEntry, buildResult.Value.score, buildResult.Value.submittedScore, beatmap.Checksum!); + + // Assert + Assert.True(result.IsSuccess); + } + + [Fact] + public void TestValidateBuiltScoreWithInvalidModsReturnsInvalidModsError() + { + // Arrange + var (queueEntry, _, beatmap, _, _) = CreateValidQueueEntry(Mods.Target); + var buildResult = ScoreCandidateBuilderUtil.Build(queueEntry, beatmap); + + // Act + var result = ScoreCandidateBuilderUtil.ValidateBuiltScore(queueEntry, buildResult.Value.score, buildResult.Value.submittedScore, beatmap.Checksum!); + + // Assert + Assert.True(result.IsFailure); + + Assert.Equal(ScoreProcessingErrorCode.InvalidMods, result.Error.Code); + } + + [Fact] + public void TestValidateBuiltScoreWithMultipleNonStandardModsReturnsInvalidModsError() + { + // Arrange + var (queueEntry, _, beatmap, _, _) = CreateValidQueueEntry(Mods.ScoreV2 | Mods.Relax); + var buildResult = ScoreCandidateBuilderUtil.Build(queueEntry, beatmap); + + // Act + var result = ScoreCandidateBuilderUtil.ValidateBuiltScore(queueEntry, buildResult.Value.score, buildResult.Value.submittedScore, beatmap.Checksum!); + + // Assert + Assert.True(result.IsFailure); + + Assert.Equal(ScoreProcessingErrorCode.InvalidMods, result.Error.Code); + } + + [Fact] + public void TestValidateBuiltScoreWithMismatchedUserHashReturnsInvalidChecksumsError() + { + // Arrange + var (queueEntry, _, beatmap, _, _) = CreateValidQueueEntry(); + var buildResult = ScoreCandidateBuilderUtil.Build(queueEntry, beatmap); + + queueEntry.UserHash = "other-user-hash"; + + // Act + var result = ScoreCandidateBuilderUtil.ValidateBuiltScore(queueEntry, buildResult.Value.score, buildResult.Value.submittedScore, beatmap.Checksum!); + + // Assert + Assert.True(result.IsFailure); + + Assert.Equal(ScoreProcessingErrorCode.InvalidChecksums, result.Error.Code); + Assert.Contains("index: 0", result.Error.Message); + } + + [Fact] + public void TestValidateBuiltScoreWithMismatchedScoreHashReturnsInvalidChecksumsError() + { + // Arrange + var (queueEntry, _, beatmap, _, _) = CreateValidQueueEntry(); + var buildResult = ScoreCandidateBuilderUtil.Build(queueEntry, beatmap); + + buildResult.Value.score.ScoreHash = "different-score-hash"; + + // Act + var result = ScoreCandidateBuilderUtil.ValidateBuiltScore(queueEntry, buildResult.Value.score, buildResult.Value.submittedScore, beatmap.Checksum!); + + // Assert + Assert.True(result.IsFailure); + + Assert.Equal(ScoreProcessingErrorCode.InvalidChecksums, result.Error.Code); + Assert.Contains("index: 1", result.Error.Message); + } + + [Fact] + public void TestValidateBuiltScoreWithMismatchedBeatmapHashReturnsInvalidChecksumsError() + { + // Arrange + var (queueEntry, _, beatmap, _, _) = CreateValidQueueEntry(); + var buildResult = ScoreCandidateBuilderUtil.Build(queueEntry, beatmap); + + // Act + var result = ScoreCandidateBuilderUtil.ValidateBuiltScore(queueEntry, buildResult.Value.score, buildResult.Value.submittedScore, "different-beatmap-hash"); + + // Assert + Assert.True(result.IsFailure); + + Assert.Equal(ScoreProcessingErrorCode.InvalidChecksums, result.Error.Code); + Assert.Contains("index: 2", result.Error.Message); + } + + private (ScoreSubmissionRequest QueueEntry, Score Score, Beatmap Beatmap, string Username, string ClientHash) CreateValidQueueEntry( + Mods mods = Mods.None, + bool isPassed = true, + int? replayFileId = 1, + string? storyboardHash = null) + { + var user = _mocker.User.GetRandomUser(); + var beatmap = _mocker.Beatmap.GetRandomBeatmap(); + var score = _mocker.Score.GetBestScoreableRandomScore(); + + score.EnrichWithUserData(user); + score.EnrichWithBeatmapData(beatmap); + score.IsScoreable = true; + score.IsPassed = isPassed; + score.Mods = mods; + score.GameMode = score.GameMode.EnrichWithMods(score.Mods); + score.LocalProperties = score.LocalProperties.FromScore(score); + + var clientHash = "client-hash"; + score.ScoreHash = score.ComputeOnlineHash(user.Username, clientHash, storyboardHash); + + var queueEntry = new ScoreSubmissionRequest + { + UserId = user.Id, + ScoreHash = score.ScoreHash, + ScoreSerialized = score.ToScoreString(user.Username), + BeatmapHash = beatmap.Checksum!, + TimeElapsed = 123, + OsuVersion = score.OsuVersion, + ClientHash = clientHash, + ReplayFileId = replayFileId, + StoryboardHash = storyboardHash, + UserHash = clientHash, + WhenPlayed = score.WhenPlayed + }; + + return (queueEntry, score, beatmap, user.Username, clientHash); + } +} \ No newline at end of file diff --git a/Sunrise.Processing.Tests/Utils/ScoreSubmissionUtilTests.cs b/Sunrise.Processing.Tests/Utils/ScoreSubmissionUtilTests.cs new file mode 100644 index 00000000..75df76c5 --- /dev/null +++ b/Sunrise.Processing.Tests/Utils/ScoreSubmissionUtilTests.cs @@ -0,0 +1,345 @@ +using Sunrise.Processing.Utils; +using Sunrise.Shared.Application; +using Sunrise.Shared.Enums.Beatmaps; +using Sunrise.Shared.Extensions; +using Sunrise.Shared.Extensions.Beatmaps; +using Sunrise.Shared.Extensions.Scores; +using Sunrise.Shared.Objects; +using Sunrise.Shared.Utils.Converters; +using Sunrise.Tests.Abstracts; +using Sunrise.Tests.Extensions; +using Sunrise.Tests.Services.Mock; +using Xunit; +using SubmissionStatus = Sunrise.Shared.Enums.Scores.SubmissionStatus; +using Mods = osu.Shared.Mods; + +namespace Sunrise.Processing.Tests.Utils; + +public class ScoreSubmissionUtilTests : BaseTest +{ + private readonly MockService _mocker = new(); + + [Fact] + public void TestGetNewFirstPlaceStringWithValidArgsReturnsFormattedMessage() + { + // Arrange + var score = _mocker.Score.GetRandomScore(); + var user = _mocker.User.GetRandomUser(); + score.User = user; + + var beatmapSet = _mocker.Beatmap.GetRandomBeatmapSet(); + var beatmap = beatmapSet.Beatmaps!.First(); + + var expectedMessage = $"[https://{Configuration.Domain}/user/{user.Id} {user.Username}] achieved #1 on {beatmap.GetBeatmapInGameChatString(beatmapSet)} {score.Mods.GetModsString()}| GameMode: {score.GameMode.ToVanillaGameMode()} | Acc: {score.Accuracy:0.00}% | {score.PerformancePoints:0.00}pp | {TimeConverter.SecondsToString(beatmap.TotalLength)} | {beatmap.DifficultyRating:0.00} ★"; + + // Act + var result = ScoreSubmissionUtil.GetNewFirstPlaceString(score, beatmapSet, beatmap); + + // Assert + Assert.Equal(expectedMessage, result); + } + + [Fact] + public void TestGetNewFirstPlaceStringWithoutUserThrowsNullReferenceException() + { + // Arrange + var score = _mocker.Score.GetRandomScore(); + score.User = null; + + var beatmapSet = _mocker.Beatmap.GetRandomBeatmapSet(); + var beatmap = beatmapSet.Beatmaps!.First(); + + // Act & Assert + Assert.Throws(() => ScoreSubmissionUtil.GetNewFirstPlaceString(score, beatmapSet, beatmap)); + } + + [Fact] + public void TestUpdateSubmissionStatusWithFailedScoreReturnsFailedStatus() + { + // Arrange + var score = _mocker.Score.GetBestScoreableRandomScore(); + + score.IsPassed = false; + score.Mods = Mods.None; + + // Act + score.UpdateSubmissionStatus(null); + + // Assert + Assert.Equal(SubmissionStatus.Failed, score.SubmissionStatus); + } + + [Fact] + public void TestUpdateSubmissionStatusWithUnscoreableScoreReturnsSubmittedStatus() + { + // Arrange + var score = _mocker.Score.GetBestScoreableRandomScore(); + + score.IsScoreable = false; + score.BeatmapStatus = BeatmapStatus.Pending; + + // Act + score.UpdateSubmissionStatus(null); + + // Assert + Assert.Equal(SubmissionStatus.Submitted, score.SubmissionStatus); + } + + [Fact] + public void TestUpdateSubmissionStatusWithFirstScoreReturnsBestStatus() + { + // Arrange + var score = _mocker.Score.GetBestScoreableRandomScore(); + + // Act + score.UpdateSubmissionStatus(null); + + // Assert + Assert.Equal(SubmissionStatus.Best, score.SubmissionStatus); + } + + [Fact] + public void TestUpdateSubmissionStatusWithWorseScoreReturnsSubmittedStatus() + { + // Arrange + var score = _mocker.Score.GetBestScoreableRandomScore(); + score.GameMode = GameMode.Standard; + score.Mods = Mods.None; + score.TotalScore = 500; + + var previousBest = _mocker.Score.GetBestScoreableRandomScore(); + previousBest.GameMode = GameMode.Standard; + previousBest.Mods = Mods.None; + previousBest.TotalScore = 1000; + + // Act + score.UpdateSubmissionStatus(previousBest); + + // Assert + Assert.Equal(SubmissionStatus.Submitted, score.SubmissionStatus); + } + + [Fact] + public void TestUpdateSubmissionStatusWithWorsePerformanceForSpecialGameModesReturnsSubmittedStatus() + { + // Arrange + var score = _mocker.Score.GetBestScoreableRandomScore(); + score.GameMode = GameMode.RelaxStandard; + score.Mods = Mods.Relax; + score.PerformancePoints = 500; + score.TotalScore = 1000; + + var previousBest = _mocker.Score.GetBestScoreableRandomScore(); + previousBest.GameMode = GameMode.RelaxStandard; + previousBest.Mods = Mods.Relax; + previousBest.PerformancePoints = 1000; + previousBest.TotalScore = 500; + + // Act + score.UpdateSubmissionStatus(previousBest); + + // Assert + Assert.Equal(SubmissionStatus.Submitted, score.SubmissionStatus); + } + + [Fact] + public void TestGetScoreSubmitResponseWithRankedBeatmapReturnsExpectedResponse() + { + // Arrange + var user = _mocker.User.GetRandomUser(); + user.Id = 1; + + var newScore = _mocker.Score.GetBestScoreableRandomScore(); + newScore.EnrichWithUserData(user); + newScore.PerformancePoints = 200; + newScore.LocalProperties.LeaderboardPosition = 1; + + var beatmapSet = _mocker.Beatmap.GetRandomBeatmapSet(); + beatmapSet.IgnoreBeatmapRanking(); + var beatmap = beatmapSet.Beatmaps!.First(); + beatmap.EnrichWithScoreData(newScore); + + var previousBeatmapBest = _mocker.Score.GetBestScoreableRandomScore(); + previousBeatmapBest.EnrichWithBeatmapData(beatmap); + previousBeatmapBest.EnrichWithUserData(user); + previousBeatmapBest.PerformancePoints = 50; + previousBeatmapBest.LocalProperties.LeaderboardPosition = 5; + + var previousPerformanceBest = _mocker.Score.GetBestScoreableRandomScore(); + previousPerformanceBest.EnrichWithBeatmapData(beatmap); + previousPerformanceBest.EnrichWithUserData(user); + previousPerformanceBest.PerformancePoints = 100; + previousPerformanceBest.LocalProperties.LeaderboardPosition = 6; + + var userStats = _mocker.User.GetRandomUserStats(); + userStats.EnrichWithUserData(user); + + + var prevUserStats = _mocker.User.GetRandomUserStats(); + prevUserStats.EnrichWithUserData(user); + + var previousPersonalBestScores = new UserPersonalBestScores(previousBeatmapBest, previousPerformanceBest); + + var newAchievements = "new-achievements"; + + var expectedResponse = + $"beatmapId:{beatmap.Id}|beatmapSetId:{beatmap.BeatmapsetId}|beatmapPlaycount:{beatmap.Playcount}|beatmapPasscount:{beatmap.Passcount}|approvedDate:{beatmap.LastUpdated:yyyy-MM-dd}\n" + + $"chartId:beatmap|chartUrl:{beatmap.Url}|chartName:Beatmap Ranking|rankBefore:{previousBeatmapBest.LocalProperties.LeaderboardPosition}|rankAfter:{newScore.LocalProperties.LeaderboardPosition}|rankedScoreBefore:{previousBeatmapBest.TotalScore}|rankedScoreAfter:{newScore.TotalScore}|totalScoreBefore:{previousBeatmapBest.TotalScore}|totalScoreAfter:{newScore.TotalScore}|maxComboBefore:{previousBeatmapBest.MaxCombo}|maxComboAfter:{newScore.MaxCombo}|accuracyBefore:{previousBeatmapBest.Accuracy}|accuracyAfter:{newScore.Accuracy}|ppBefore:{previousBeatmapBest.PerformancePoints}|ppAfter:{newScore.PerformancePoints}|onlineScoreId:{newScore.Id}\n" + + $"chartId:overall|chartUrl:https://{Configuration.Domain}/user/{user.Id}|chartName:Overall Ranking|rankBefore:{prevUserStats.LocalProperties.Rank}|rankAfter:{userStats.LocalProperties.Rank}|rankedScoreBefore:{prevUserStats.RankedScore}|rankedScoreAfter:{userStats.RankedScore}|totalScoreBefore:{prevUserStats.TotalScore}|totalScoreAfter:{userStats.TotalScore}|maxComboBefore:{prevUserStats.MaxCombo}|maxComboAfter:{userStats.MaxCombo}|accuracyBefore:{prevUserStats.Accuracy}|accuracyAfter:{userStats.Accuracy}|ppBefore:{prevUserStats.PerformancePoints}|ppAfter:{userStats.PerformancePoints}|achievements-new:{newAchievements}"; + + // Act + var result = ScoreSubmissionUtil.GetScoreSubmitResponse(beatmap, userStats, prevUserStats, newScore, previousPersonalBestScores, newAchievements); + + // Assert + Assert.Equal(expectedResponse, result); + } + + [Fact] + public void TestGetScoreSubmitResponseWithLovedBeatmapHidesBeatmapPpValues() + { + // Arrange + var user = _mocker.User.GetRandomUser(); + user.Id = 1; + + var newScore = _mocker.Score.GetBestScoreableRandomScore(); + newScore.EnrichWithUserData(user); + newScore.PerformancePoints = 200; + newScore.LocalProperties.LeaderboardPosition = 1; + + var beatmapSet = _mocker.Beatmap.GetRandomBeatmapSet(); + + beatmapSet.Ranked = (int)BeatmapStatus.Loved; + beatmapSet.StatusString = "loved"; + + var beatmap = beatmapSet.Beatmaps!.First(); + beatmap.EnrichWithScoreData(newScore); + + beatmap.Ranked = (int)BeatmapStatus.Loved; + beatmap.StatusString = "loved"; + + var previousBeatmapBest = _mocker.Score.GetBestScoreableRandomScore(); + previousBeatmapBest.EnrichWithBeatmapData(beatmap); + previousBeatmapBest.EnrichWithUserData(user); + previousBeatmapBest.PerformancePoints = 50; + previousBeatmapBest.LocalProperties.LeaderboardPosition = 5; + + var previousPerformanceBest = _mocker.Score.GetBestScoreableRandomScore(); + previousPerformanceBest.EnrichWithBeatmapData(beatmap); + previousPerformanceBest.EnrichWithUserData(user); + previousPerformanceBest.PerformancePoints = 100; + previousPerformanceBest.LocalProperties.LeaderboardPosition = 6; + + var userStats = _mocker.User.GetRandomUserStats(); + userStats.EnrichWithUserData(user); + + + var prevUserStats = _mocker.User.GetRandomUserStats(); + prevUserStats.EnrichWithUserData(user); + + var previousPersonalBestScores = new UserPersonalBestScores(previousBeatmapBest, previousPerformanceBest); + + var newAchievements = "new-achievements"; + + var expectedPerformancePoints = ""; + + var expectedResponse = + $"beatmapId:{beatmap.Id}|beatmapSetId:{beatmap.BeatmapsetId}|beatmapPlaycount:{beatmap.Playcount}|beatmapPasscount:{beatmap.Passcount}|approvedDate:{beatmap.LastUpdated:yyyy-MM-dd}\n" + + $"chartId:beatmap|chartUrl:{beatmap.Url}|chartName:Beatmap Ranking|rankBefore:{previousBeatmapBest.LocalProperties.LeaderboardPosition}|rankAfter:{newScore.LocalProperties.LeaderboardPosition}|rankedScoreBefore:{previousBeatmapBest.TotalScore}|rankedScoreAfter:{newScore.TotalScore}|totalScoreBefore:{previousBeatmapBest.TotalScore}|totalScoreAfter:{newScore.TotalScore}|maxComboBefore:{previousBeatmapBest.MaxCombo}|maxComboAfter:{newScore.MaxCombo}|accuracyBefore:{previousBeatmapBest.Accuracy}|accuracyAfter:{newScore.Accuracy}|ppBefore:{expectedPerformancePoints}|ppAfter:{expectedPerformancePoints}|onlineScoreId:{newScore.Id}\n" + + $"chartId:overall|chartUrl:https://{Configuration.Domain}/user/{user.Id}|chartName:Overall Ranking|rankBefore:{prevUserStats.LocalProperties.Rank}|rankAfter:{userStats.LocalProperties.Rank}|rankedScoreBefore:{prevUserStats.RankedScore}|rankedScoreAfter:{userStats.RankedScore}|totalScoreBefore:{prevUserStats.TotalScore}|totalScoreAfter:{userStats.TotalScore}|maxComboBefore:{prevUserStats.MaxCombo}|maxComboAfter:{userStats.MaxCombo}|accuracyBefore:{prevUserStats.Accuracy}|accuracyAfter:{userStats.Accuracy}|ppBefore:{prevUserStats.PerformancePoints}|ppAfter:{userStats.PerformancePoints}|achievements-new:{newAchievements}"; + + // Act + var result = ScoreSubmissionUtil.GetScoreSubmitResponse(beatmap, userStats, prevUserStats, newScore, previousPersonalBestScores, newAchievements); + + // Assert + Assert.Equal(expectedResponse, result); + } + + [Fact] + public void TestGetTimeElapsedWithPassedScoreReturnsScoreTime() + { + // Arrange + var score = _mocker.Score.GetBestScoreableRandomScore(); + score.IsPassed = true; + + var submittedScore = _mocker.Score.GetRandomSubmittedScore(score); + + var scoreTime = _mocker.GetRandomInteger(); + var scoreFailTime = _mocker.GetRandomInteger(); + + // Act + var result = ScoreSubmissionUtil.GetTimeElapsed(submittedScore, scoreTime, scoreFailTime); + + // Assert + Assert.Equal(scoreTime, result); + } + + [Fact] + public void TestGetTimeElapsedWithFailedScoreReturnsFailTime() + { + // Arrange + var score = _mocker.Score.GetBestScoreableRandomScore(); + score.Mods = Mods.None; + score.IsPassed = false; + + var submittedScore = _mocker.Score.GetRandomSubmittedScore(score); + + var scoreTime = _mocker.GetRandomInteger(); + var scoreFailTime = _mocker.GetRandomInteger(); + + // Act + var result = ScoreSubmissionUtil.GetTimeElapsed(submittedScore, scoreTime, scoreFailTime); + + // Assert + Assert.Equal(scoreFailTime, result); + } + + [Fact] + public void TestGetTimeElapsedWithNoFailScoreReturnsScoreTime() + { + // Arrange + var score = _mocker.Score.GetBestScoreableRandomScore(); + score.Mods = Mods.NoFail; + score.IsPassed = false; + + var submittedScore = _mocker.Score.GetRandomSubmittedScore(score); + + var scoreTime = _mocker.GetRandomInteger(); + var scoreFailTime = _mocker.GetRandomInteger(); + + // Act + var result = ScoreSubmissionUtil.GetTimeElapsed(submittedScore, scoreTime, scoreFailTime); + + // Assert + Assert.Equal(scoreTime, result); + } + + [Fact] + public void TestIsScoreFailedWithFailedScoreReturnsTrue() + { + // Arrange + var score = _mocker.Score.GetBestScoreableRandomScore(); + score.Mods = Mods.None; + score.IsPassed = false; + + // Act + var result = ScoreSubmissionUtil.IsScoreFailed(score); + + // Assert + Assert.True(result); + } + + [Fact] + public void TestIsScoreFailedWithNoFailScoreReturnsFalse() + { + // Arrange + var score = _mocker.Score.GetBestScoreableRandomScore(); + score.Mods = Mods.NoFail; + score.IsPassed = false; + + // Act + var result = ScoreSubmissionUtil.IsScoreFailed(score); + + // Assert + Assert.False(result); + } +} \ No newline at end of file diff --git a/Sunrise.Processing/FodyWeavers.xml b/Sunrise.Processing/FodyWeavers.xml new file mode 100644 index 00000000..a6a2edf1 --- /dev/null +++ b/Sunrise.Processing/FodyWeavers.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/Sunrise.Processing/FodyWeavers.xsd b/Sunrise.Processing/FodyWeavers.xsd new file mode 100644 index 00000000..1a5a0c8a --- /dev/null +++ b/Sunrise.Processing/FodyWeavers.xsd @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + The assembly name of the aspect type, which does not contain the '.dll' suffix. + + + + + The aspect type full name. + + + + + An AspectN pattern. Apply the aspect type to methods matched by the pattern. This pattern will override the pointcut settings of the aspect type. + + + + + + + + + + + Set to false to disable Rougamo. The default is true. + + + + + Set to true to use the type and method composite accessibility. The default is false. Etc, an internal type has a public method, public for default(false) and internal for true. + + + + + Set to true to skip saving ref struct parameters and return value into MethodContext. The default is false. + + + + + Set to false to prevent generating the StackTraceHiddenAttribute for the proxy method. The default is true. + + + + + Set to true to save the items that the iterator returns. This will take up additional memory space. The default is false. + + + + + Set to false to make the execution order of the OnSuccess, OnException, and OnExit methods the same as OnEntry. The default is true. + + + + + Regex expressions for the type's full name, separated by ',' or ';'. All types matching any of these regex expressions will be ignored by Rougamo. + + + + + + + + 'true' to run assembly verification (PEVerify) on the target assembly after all weavers have been executed. + + + + + A comma-separated list of error codes that can be safely ignored in assembly verification. + + + + + 'false' to turn off automatic generation of the XML Schema file. + + + + + \ No newline at end of file diff --git a/Sunrise.Processing/ProcessingJobs.cs b/Sunrise.Processing/ProcessingJobs.cs new file mode 100644 index 00000000..cb876acc --- /dev/null +++ b/Sunrise.Processing/ProcessingJobs.cs @@ -0,0 +1,12 @@ +using Hangfire; +using Sunrise.Processing.Scores.Jobs; + +namespace Sunrise.Processing; + +public static class ProcessingJobs +{ + public static void Initialize() + { + RecurringJob.AddOrUpdate("Process score queue", service => service.ProcessQueue(CancellationToken.None), Cron.Minutely); + } +} \ No newline at end of file diff --git a/Sunrise.Processing/Scores/Handlers/IScoreHandler.cs b/Sunrise.Processing/Scores/Handlers/IScoreHandler.cs new file mode 100644 index 00000000..2ecea479 --- /dev/null +++ b/Sunrise.Processing/Scores/Handlers/IScoreHandler.cs @@ -0,0 +1,10 @@ +using CSharpFunctionalExtensions; +using Sunrise.Shared.Database.Models.Scores; +using Sunrise.Shared.Objects; + +namespace Sunrise.Processing.Scores.Handlers; + +public interface IScoreHandler +{ + Task> ExecuteAsync(ScoreProcessingTask task, CancellationToken ct); +} \ No newline at end of file diff --git a/Sunrise.Processing/Scores/Handlers/ScoreDeletionHandler.cs b/Sunrise.Processing/Scores/Handlers/ScoreDeletionHandler.cs new file mode 100644 index 00000000..793ea985 --- /dev/null +++ b/Sunrise.Processing/Scores/Handlers/ScoreDeletionHandler.cs @@ -0,0 +1,40 @@ +using CSharpFunctionalExtensions; +using Sunrise.Processing.Scores.Pipeline; +using Sunrise.Shared.Database; +using Sunrise.Shared.Database.Models.Scores; +using Sunrise.Shared.Enums.Scores; +using Sunrise.Shared.Objects; +using SubmissionStatus = Sunrise.Shared.Enums.Scores.SubmissionStatus; + +namespace Sunrise.Processing.Scores.Handlers; + +public class ScoreDeletionHandler( + DatabaseService database, + ScoreCommitPipeline pipeline) + : ScoreHandlerBase(database, pipeline) +{ + internal override async Task> PrepareAsync(ScoreProcessingTask task, CancellationToken ct) + { + var score = await Database.Scores.GetScore(task.ScoreId!.Value, filterValidScores: false, ct: ct); + if (score == null) + return new ScoreProcessingError( + ScoreProcessingErrorCode.Unexpected, + $"Score {task.ScoreId} not found") + .ToResult(); + + if (score.SubmissionStatus == SubmissionStatus.Deleted) + return new ScoreProcessingError( + ScoreProcessingErrorCode.InvalidScoreState, + $"Score {task.ScoreId} is already deleted" + ).ToResult(); + + var loadUserStateResult = await LoadUserState(score, ct); + if (loadUserStateResult.IsFailure) + return loadUserStateResult.Error.ToResult(); + + var (user, userStats, userGrades) = loadUserStateResult.Value; + var ctx = new ScoreCommitContext(ScoreTaskType.Delete, score, user, userStats, userGrades); + + return ctx; + } +} \ No newline at end of file diff --git a/Sunrise.Processing/Scores/Handlers/ScoreHandlerBase.cs b/Sunrise.Processing/Scores/Handlers/ScoreHandlerBase.cs new file mode 100644 index 00000000..d3942f68 --- /dev/null +++ b/Sunrise.Processing/Scores/Handlers/ScoreHandlerBase.cs @@ -0,0 +1,171 @@ +using System.Net; +using CSharpFunctionalExtensions; +using Microsoft.EntityFrameworkCore; +using Serilog; +using Sunrise.Processing.Scores.Pipeline; +using Sunrise.Shared.Attributes; +using Sunrise.Shared.Database; +using Sunrise.Shared.Database.Models; +using Sunrise.Shared.Database.Models.Scores; +using Sunrise.Shared.Database.Models.Users; +using Sunrise.Shared.Database.Objects; +using Sunrise.Shared.Enums.Scores; +using Sunrise.Shared.Objects; +using Sunrise.Shared.Objects.Serializable; +using Sunrise.Shared.Objects.Sessions; +using Sunrise.Shared.Services; + +namespace Sunrise.Processing.Scores.Handlers; + +public abstract class ScoreHandlerBase( + DatabaseService database, + ScoreCommitPipeline pipeline) : IScoreHandler +{ + + protected DatabaseService Database { get; } = database; + + public async Task> ExecuteAsync(ScoreProcessingTask task, CancellationToken ct) + { + var prepareResult = await PrepareAsync(task, ct); + if (prepareResult.IsFailure) + return UnitResult.Failure(prepareResult.Error); + + + var commitResult = await CommitAsync(prepareResult.Value, task, ct); + if (commitResult.IsFailure) + return commitResult.Error; + + await OnCommitted(commitResult.Value, ct); + return UnitResult.Success(); + } + + internal virtual Task> PrepareAsync( + ScoreProcessingTask task, CancellationToken ct) + { + throw new NotSupportedException($"{GetType().Name} does not implement PrepareAsync."); + } + + protected async Task> CommitAsync( + ScoreCommitContext ctx, + ScoreProcessingTask? task, + CancellationToken ct) + { + var commitResult = await pipeline.Commit(ctx, task, ct); + + if (commitResult.IsFailure) + { + var translated = TryTranslateTransactionFailure(commitResult.Error); + if (translated.IsFailure) + return translated.Error; + + Log.Warning("Failed to commit score state mutation, reason: {Reason}, ScoreId: {ScoreId}", + commitResult.Error, + ctx.Score.Id); + + return new ScoreProcessingError( + ScoreProcessingErrorCode.TransactionFailed, + $"Failed to commit score state mutation: {commitResult.Error}", + ScoreProcessingDisposition.Retryable); + } + + return ctx; + } + + internal virtual Task OnCommitted(ScoreCommitContext ctx, CancellationToken ct) + { + return Task.CompletedTask; + } + + [TraceExecution] + protected async Task> LoadUserState( + Score score, CancellationToken ct) + { + var user = score.User ?? await Database.Users.GetUser( + score.UserId, + options: new QueryOptions + { + QueryModifier = q => q.Cast().Include(u => u.UserStats) + }, + ct: ct); + + if (user == null) + { + Log.Warning("Couldn't find user while processing score {ScoreId}", score.Id); + return new ScoreProcessingError(ScoreProcessingErrorCode.UserNotFound, "User not found") + .ToResult<(User, UserStats, UserGrades)>(); + } + + var userStats = user.UserStats.FirstOrDefault(u => u.GameMode == score.GameMode) + ?? await Database.Users.Stats.GetUserStats(user.Id, score.GameMode, ct); + + if (userStats == null) + { + Log.Warning("User stats not found. ScoreId: {scoreId}", score.Id); + return new ScoreProcessingError(ScoreProcessingErrorCode.UserStatsNotFound, "User stats not found") + .ToResult<(User, UserStats, UserGrades)>(); + } + + var userGrades = await Database.Users.Grades.GetUserGrades(user.Id, userStats.GameMode, ct); + + if (userGrades == null) + { + Log.Warning("Couldn't find user grades while processing score {ScoreId}", score.Id); + return new ScoreProcessingError(ScoreProcessingErrorCode.UserGradesNotFound, "User grades not found") + .ToResult<(User, UserStats, UserGrades)>(); + } + + // TODO: Deprecate in favour of just tracking the get user ranks. + var (currentRank, _) = await Database.Users.Stats.Ranks.GetUserRanks(user, userStats.GameMode, ct: ct); + userStats.LocalProperties.Rank = currentRank; + + return (user, userStats, userGrades); + } + + [TraceExecution] + protected async Task> ResolveBeatmap( + BeatmapService beatmapService, + BaseSession session, + string beatmapHash, + CancellationToken ct) + { + var beatmapSetResult = await beatmapService.GetBeatmapSet(session, beatmapHash: beatmapHash, retryCount: 1, ct: ct); + + if (beatmapSetResult.IsFailure || beatmapSetResult.Value == null) + { + var disposition = beatmapSetResult.Error.Status == HttpStatusCode.NotFound + ? ScoreProcessingDisposition.Permanent + : ScoreProcessingDisposition.Retryable; + + return new ScoreProcessingError(ScoreProcessingErrorCode.BeatmapNotFound, + $"Failed to fetch beatmap set: {beatmapSetResult.Error.Message}", + disposition) + .ToResult<(BeatmapSet, Beatmap)>(); + } + + var beatmapSet = beatmapSetResult.Value; + if (beatmapSet == null) + throw new InvalidOperationException("BeatmapSet returned as success but value was null from GetBeatmapSet."); + + var beatmap = beatmapSet.Beatmaps?.FirstOrDefault(x => x.Checksum == beatmapHash); + + if (beatmap == null) + return new ScoreProcessingError(ScoreProcessingErrorCode.BeatmapNotFound, $"Beatmap with hash {beatmapHash} not found in fetched beatmap set {beatmapSet.Id}") + .ToResult<(BeatmapSet, Beatmap)>(); + + return (beatmapSet, beatmap); + } + + protected static UnitResult TryTranslateTransactionFailure(string errorMessage) + { + if (errorMessage.Contains("IX_score_ScoreHash", StringComparison.OrdinalIgnoreCase) + || errorMessage.Contains("duplicate entry", StringComparison.OrdinalIgnoreCase) + && errorMessage.Contains("ScoreHash", StringComparison.OrdinalIgnoreCase)) + { + return new ScoreProcessingError( + ScoreProcessingErrorCode.DuplicateScore, + "Score with same hash already exists").ToUnit(); + } + + return UnitResult.Success(); + } +} \ No newline at end of file diff --git a/Sunrise.Processing/Scores/Handlers/ScoreRecalculationHandler.cs b/Sunrise.Processing/Scores/Handlers/ScoreRecalculationHandler.cs new file mode 100644 index 00000000..6670cc75 --- /dev/null +++ b/Sunrise.Processing/Scores/Handlers/ScoreRecalculationHandler.cs @@ -0,0 +1,69 @@ +using CSharpFunctionalExtensions; +using Sunrise.Processing.Scores.Pipeline; +using Sunrise.Shared.Database; +using Sunrise.Shared.Database.Models.Scores; +using Sunrise.Shared.Enums.Scores; +using Sunrise.Shared.Objects; +using Sunrise.Shared.Objects.Sessions; +using Sunrise.Shared.Services; +using SubmissionStatus = Sunrise.Shared.Enums.Scores.SubmissionStatus; + +namespace Sunrise.Processing.Scores.Handlers; + +public class ScoreRecalculationHandler( + DatabaseService database, + ScoreCommitPipeline pipeline, + BeatmapService beatmapService, + CalculatorService calculatorService) + : ScoreHandlerBase(database, pipeline) +{ + internal override async Task> PrepareAsync( + ScoreProcessingTask task, CancellationToken ct) + { + var score = await Database.Scores.GetScore(task.ScoreId!.Value, filterValidScores: false, ct: ct); + if (score == null) + return new ScoreProcessingError( + ScoreProcessingErrorCode.Unexpected, + $"Score {task.ScoreId} not found") + .ToResult(); + + if (score.SubmissionStatus == SubmissionStatus.Deleted) + return new ScoreProcessingError( + ScoreProcessingErrorCode.InvalidScoreState, + $"Score {task.ScoreId} is deleted; use RestoreScore to bring it back") + .ToResult(); + + var beatmapRatelimitSession = BaseSession.GenerateServerSession(); + + var loadBeatmapResult = await ResolveBeatmap(beatmapService, beatmapRatelimitSession, score.BeatmapHash, ct); + if (loadBeatmapResult.IsFailure) + return loadBeatmapResult.Error.ToResult(); + + var (_, beatmap) = loadBeatmapResult.Value; + + var scorePerformanceResult = await calculatorService.CalculateScorePerformance(beatmapRatelimitSession, score, ct: ct); + if (scorePerformanceResult.IsFailure) + return new ScoreProcessingError( + ScoreProcessingErrorCode.PpCalculationFailed, + "PP calculation failed: " + scorePerformanceResult.Error.Message, + ScoreProcessingDisposition.Retryable) + .ToResult(); + + if (scorePerformanceResult.Value == null) + return new ScoreProcessingError( + ScoreProcessingErrorCode.PpCalculationFailed, + "Score performance calculation returned null", + ScoreProcessingDisposition.Retryable) + .ToResult(); + + score.PerformancePoints = scorePerformanceResult.Value.PerformancePoints; + + var loadUserStateResult = await LoadUserState(score, ct); + if (loadUserStateResult.IsFailure) + return loadUserStateResult.Error.ToResult(); + + var (user, userStats, userGrades) = loadUserStateResult.Value; + var ctx = new ScoreCommitContext(ScoreTaskType.Recalculation, score, user, userStats, userGrades, beatmap); + return ctx; + } +} \ No newline at end of file diff --git a/Sunrise.Processing/Scores/Handlers/ScoreRestorationHandler.cs b/Sunrise.Processing/Scores/Handlers/ScoreRestorationHandler.cs new file mode 100644 index 00000000..7419677e --- /dev/null +++ b/Sunrise.Processing/Scores/Handlers/ScoreRestorationHandler.cs @@ -0,0 +1,41 @@ +using CSharpFunctionalExtensions; +using Sunrise.Processing.Scores.Pipeline; +using Sunrise.Shared.Database; +using Sunrise.Shared.Database.Models.Scores; +using Sunrise.Shared.Enums.Scores; +using Sunrise.Shared.Objects; +using SubmissionStatus = Sunrise.Shared.Enums.Scores.SubmissionStatus; + +namespace Sunrise.Processing.Scores.Handlers; + +public class ScoreRestorationHandler( + DatabaseService database, + ScoreCommitPipeline pipeline) + : ScoreHandlerBase(database, pipeline) +{ + + internal override async Task> PrepareAsync( + ScoreProcessingTask task, CancellationToken ct) + { + var score = await Database.Scores.GetScore(task.ScoreId!.Value, filterValidScores: false, ct: ct); + if (score == null) + return new ScoreProcessingError( + ScoreProcessingErrorCode.Unexpected, + $"Score {task.ScoreId} not found") + .ToResult(); + + if (score.SubmissionStatus != SubmissionStatus.Deleted) + return new ScoreProcessingError( + ScoreProcessingErrorCode.InvalidScoreState, + $"Score {task.ScoreId} is not deleted") + .ToResult(); + + var loadUserStateResult = await LoadUserState(score, ct); + if (loadUserStateResult.IsFailure) + return loadUserStateResult.Error.ToResult(); + + var (user, userStats, userGrades) = loadUserStateResult.Value; + var ctx = new ScoreCommitContext(ScoreTaskType.Restore, score, user, userStats, userGrades); + return ctx; + } +} \ No newline at end of file diff --git a/Sunrise.Processing/Scores/Handlers/ScoreSubmissionHandler.cs b/Sunrise.Processing/Scores/Handlers/ScoreSubmissionHandler.cs new file mode 100644 index 00000000..8ac2d09a --- /dev/null +++ b/Sunrise.Processing/Scores/Handlers/ScoreSubmissionHandler.cs @@ -0,0 +1,207 @@ +using CSharpFunctionalExtensions; +using osu.Shared; +using Serilog; +using Sunrise.Processing.Scores.Pipeline; +using Sunrise.Processing.Services; +using Sunrise.Processing.Utils; +using Sunrise.Shared.Application; +using Sunrise.Shared.Database; +using Sunrise.Shared.Database.Models; +using Sunrise.Shared.Database.Models.Scores; +using Sunrise.Shared.Database.Models.Users; +using Sunrise.Shared.Enums.Scores; +using Sunrise.Shared.Extensions.Scores; +using Sunrise.Shared.Objects; +using Sunrise.Shared.Objects.Sessions; +using Sunrise.Shared.Services; + +namespace Sunrise.Processing.Scores.Handlers; + +public class ScoreSubmissionHandler( + DatabaseService database, + ScoreCommitPipeline pipeline, + BeatmapService beatmapService, + CalculatorService calculatorService, + OsuVersionService osuVersionService, + ScoreSideEffectsPublisherService scoreSideEffectsPublisherService) + : ScoreHandlerBase(database, pipeline) +{ + private UserStats? _prevUserStatsSnapshot; + + internal override async Task> PrepareAsync( + ScoreProcessingTask task, CancellationToken ct) + { + if (!task.ScoreSubmissionRequestId.HasValue) + return new ScoreProcessingError( + ScoreProcessingErrorCode.Unexpected, + $"Submission task {task.Id} is missing its payload reference") + .ToResult(); + + var payload = await Database.ScoreSubmissionRequests.GetById(task.ScoreSubmissionRequestId.Value, ct); + if (payload == null) + return new ScoreProcessingError( + ScoreProcessingErrorCode.Unexpected, + $"Submission payload {task.ScoreSubmissionRequestId.Value} was not found for task {task.Id}") + .ToResult(); + + var beatmapRatelimitSession = BaseSession.GenerateServerSession(); + + var prepareInlineSubmissionCtxAsync = await PrepareInlineSubmissionAsync(beatmapRatelimitSession, payload, ct); + if (prepareInlineSubmissionCtxAsync.IsFailure) + return prepareInlineSubmissionCtxAsync.Error; + + return prepareInlineSubmissionCtxAsync; + } + + internal async Task> PrepareInlineSubmissionAsync( + BaseSession beatmapRatelimitSession, + ScoreSubmissionRequest queueEntry, CancellationToken ct) + { + var loadBeatmapResult = await ResolveBeatmap(beatmapService, beatmapRatelimitSession, queueEntry.BeatmapHash, ct); + if (loadBeatmapResult.IsFailure) + return loadBeatmapResult.Error; + + var (beatmapSet, beatmap) = loadBeatmapResult.Value; + + var buildScoreCandidateResult = ScoreCandidateBuilderUtil.Build(queueEntry, beatmap); + if (buildScoreCandidateResult.IsFailure) + return buildScoreCandidateResult.Error.ToResult(); + + var (submittedScore, score) = buildScoreCandidateResult.Value; + + if (Configuration.EnforceLatestClientVersion) + await CheckScoreClientVersion(score.OsuVersion, queueEntry.OsuVersion, ct); + + var validateBuiltScoreResult = ScoreCandidateBuilderUtil.ValidateBuiltScore(queueEntry, score, submittedScore, beatmap.Checksum ?? string.Empty); + + if (validateBuiltScoreResult.IsFailure) + { + await RestrictUserIfErrorCodeIsBannable(score.UserId, validateBuiltScoreResult.Error.Code); + return validateBuiltScoreResult.Error.ToResult(); + } + + var scorePerformanceResult = await calculatorService.CalculateScorePerformance(beatmapRatelimitSession, score, ct: ct); + if (scorePerformanceResult.IsFailure) + return new ScoreProcessingError( + ScoreProcessingErrorCode.PpCalculationFailed, + "PP calculation failed: " + scorePerformanceResult.Error.Message, + ScoreProcessingDisposition.Retryable) + .ToResult(); + + if (scorePerformanceResult.Value == null) + return new ScoreProcessingError( + ScoreProcessingErrorCode.PpCalculationFailed, + "Score performance calculation returned null", + ScoreProcessingDisposition.Retryable) + .ToResult(); + + score.PerformancePoints = scorePerformanceResult.Value.PerformancePoints; + + var validateScorePerformanceResult = ValidateScorePerformance(score, ct); + + if (validateScorePerformanceResult.IsFailure) + { + await RestrictUserIfErrorCodeIsBannable(score.UserId, validateScorePerformanceResult.Error.Code); + return validateScorePerformanceResult.Error.ToResult(); + } + + var loadUserStateResult = await LoadUserState(score, ct); + if (loadUserStateResult.IsFailure) + return loadUserStateResult.Error.ToResult(); + + var (user, userStats, userGrades) = loadUserStateResult.Value; + + _prevUserStatsSnapshot = userStats.Clone(); + + var ctx = new ScoreCommitContext(ScoreTaskType.Submission, score, user, userStats, userGrades, beatmap, beatmapSet); + return ctx; + } + + public async Task> ExecuteInlineSubmission( + BaseSession beatmapRatelimitSession, + ScoreSubmissionRequest queueEntry, + CancellationToken ct, + ScoreProcessingTask? task = null) + { + var prepareResult = await PrepareInlineSubmissionAsync(beatmapRatelimitSession, queueEntry, ct); + if (prepareResult.IsFailure) + return prepareResult.Error; + + var ctx = prepareResult.Value; + + var commitResult = await CommitAsync(prepareResult.Value, task, ct); + if (commitResult.IsFailure) + return commitResult.Error; + + await OnCommitted(commitResult.Value, ct); + + var shouldReturnScoreResponseString = ctx.Beatmap?.IsScoreable ?? false; + + if (!shouldReturnScoreResponseString) + return null; + + var responseString = await scoreSideEffectsPublisherService.BuildScoreSubmitResponse(ctx, _prevUserStatsSnapshot!, ct); + + return responseString; + } + + internal override async Task OnCommitted(ScoreCommitContext ctx, CancellationToken ct) + { + var publishSideEffectsResult = await scoreSideEffectsPublisherService.PublishScoreSubmissionSideEffects(BaseSession.GenerateServerSession(), ctx, ct); + if (publishSideEffectsResult.IsFailure) + Log.Warning("Failed to publish post-commit score side effects for score {ScoreId}: {Error}", ctx.Score.Id, publishSideEffectsResult.Error); + } + + private UnitResult ValidateScorePerformance(Score score, CancellationToken ct) + { + var hasNonStandardModsForBanCheck = score.Mods.TryGetSelectedNotStandardMods() is not Mods.None; + var isScoreBannable = score.PerformancePoints >= Configuration.BannablePpThreshold + && !hasNonStandardModsForBanCheck + && score.LocalProperties.IsRanked; + + if (isScoreBannable) + return new ScoreProcessingError(ScoreProcessingErrorCode.BannablePpThreshold, "Too many PP - auto-restricted").ToUnit(); + + return UnitResult.Success(); + } + + private async Task CheckScoreClientVersion(string scoreOsuVersion, string formOsuVersion, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + + var versionString = !string.IsNullOrWhiteSpace(scoreOsuVersion) ? scoreOsuVersion : formOsuVersion; + var clientVersion = OsuVersion.TryParse(versionString); + if (clientVersion == null) + return; + + var latestVersion = await osuVersionService.GetLatestVersion(clientVersion.Stream); + if (latestVersion == null) + return; + + if (clientVersion < latestVersion) + Log.Warning("Score submitted with outdated osu! client version {ClientVersion} (stream: {Stream}, latest: {LatestVersion})", + clientVersion, + clientVersion.Stream, + latestVersion); + } + + private async Task RestrictUserIfErrorCodeIsBannable(int userId, ScoreProcessingErrorCode errorCode) + { + var reason = errorCode switch + { + ScoreProcessingErrorCode.BannablePpThreshold => "Auto-restricted for submitting impossible score", + ScoreProcessingErrorCode.InvalidChecksums => "Invalid checksums on score submission", + _ => null + }; + + if (reason != null) + { + Log.Error("Score submission failed with error code {ErrorCode} for user {UserId}. Restriction reason: {Reason}", + errorCode, + userId, + reason); + + await Database.Users.Moderation.RestrictPlayer(userId, null, reason); + } + } +} \ No newline at end of file diff --git a/Sunrise.Processing/Scores/Jobs/ScoreProcessingJob.cs b/Sunrise.Processing/Scores/Jobs/ScoreProcessingJob.cs new file mode 100644 index 00000000..8c1bb48a --- /dev/null +++ b/Sunrise.Processing/Scores/Jobs/ScoreProcessingJob.cs @@ -0,0 +1,230 @@ +using Hangfire; +using Microsoft.Extensions.DependencyInjection; +using Serilog; +using Sunrise.Processing.Scores.Handlers; +using Sunrise.Shared.Application; +using Sunrise.Shared.Database; +using Sunrise.Shared.Database.Models.Scores; +using Sunrise.Shared.Enums.Scores; +using Sunrise.Shared.Objects; +using Sunrise.Shared.Repositories; + +namespace Sunrise.Processing.Scores.Jobs; + +public class ScoreProcessingJob(IServiceScopeFactory scopeFactory) +{ + private const int DefaultBackoffMinutes = 1; + + [DisableConcurrentExecution(timeoutInSeconds: 120)] + [AutomaticRetry(Attempts = 0)] + public async Task ProcessQueue(CancellationToken ct) + { + var runStart = DateTime.UtcNow; + var totalProcessed = 0; + var outcome = "drained"; + + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeoutCts.CancelAfter(TimeSpan.FromSeconds(55)); + var token = timeoutCts.Token; + + Log.Information("Score queue poller tick starting (max concurrency {MaxConcurrency}, batch lease {Lease}s)", + Configuration.ScoreProcessingMaxConcurrency, + Configuration.ScoreProcessingBatchLeaseSeconds); + + try + { + while (!token.IsCancellationRequested) + { + List claimed; + + using (var claimScope = scopeFactory.CreateScope()) + { + var database = claimScope.ServiceProvider.GetRequiredService(); + claimed = await database.ScoreProcessingTasks.ClaimPendingBatch( + Configuration.ScoreProcessingMaxConcurrency, + Configuration.ScoreProcessingBatchLease, + token); + } + + if (claimed.Count == 0) + { + if (totalProcessed == 0) + outcome = "empty"; + break; + } + + Log.Information("Processing batch of {Count} queued score entries", claimed.Count); + + await Parallel.ForEachAsync(claimed, + new ParallelOptions + { + MaxDegreeOfParallelism = Configuration.ScoreProcessingMaxConcurrency, + CancellationToken = token + }, + async (entry, innerCt) => await ProcessEntry(entry, innerCt)); + + totalProcessed += claimed.Count; + + try + { + await Task.Delay(TimeSpan.FromSeconds(Configuration.ScoreProcessingPollerInterBatchDelaySeconds), token); + } + catch (OperationCanceledException) + { + outcome = "cancelled"; + break; + } + } + } + catch (Exception ex) + { + outcome = "error"; + Log.Error(ex, "Score queue poller tick failed unexpectedly"); + throw; + } + finally + { + var elapsed = (DateTime.UtcNow - runStart).TotalMilliseconds; + SunriseMetrics.ScoreProcessingPollerRunCounterInc(outcome, totalProcessed); + Log.Information("Score queue poller tick finished: outcome={Outcome}, processed={Processed}, elapsed_ms={ElapsedMs}", + outcome, + totalProcessed, + (long)elapsed); + } + } + + private async Task ProcessEntry(ScoreProcessingTask task, CancellationToken ct) + { + using var entryScope = scopeFactory.CreateScope(); + var entryDatabase = entryScope.ServiceProvider.GetRequiredService(); + int? affectedUserId = null; + + try + { + var handler = entryScope.ServiceProvider.GetRequiredKeyedService(task.TaskType); + var sessions = entryScope.ServiceProvider.GetRequiredService(); + affectedUserId = await ResolveAffectedUserId(entryDatabase, task, ct); + var result = await handler.ExecuteAsync(task, ct); + + using var bookkeepingScope = scopeFactory.CreateScope(); + var bookkeepingDatabase = bookkeepingScope.ServiceProvider.GetRequiredService(); + + if (result.IsSuccess) + { + await CleanupCompletedTask(bookkeepingDatabase, task, ct); + Log.Information("Successfully processed score task {TaskId} ({TaskType}) for user {UserId}", task.Id, task.TaskType, affectedUserId); + SunriseMetrics.ScoreProcessingEntryCounterInc("success", task.TaskType); + return; + } + + var error = result.Error; + var isDuplicateScore = error.Code == ScoreProcessingErrorCode.DuplicateScore; + + if (isDuplicateScore && task.TaskType == ScoreTaskType.Submission) + { + await CleanupCompletedTask(bookkeepingDatabase, task, ct); + Log.Information("Cleaned up duplicate submission task {TaskId} for user {UserId}", task.Id, affectedUserId); + SunriseMetrics.ScoreProcessingEntryCounterInc("success", task.TaskType, error.Code); + return; + } + + await bookkeepingDatabase.ScoreProcessingTasks.MarkAsFailed(task.Id, error, GetBackoffDelay(task.RetryCount), ct); + + var isPermanent = error.Disposition == ScoreProcessingDisposition.Permanent; + + Log.Warning("Score processing failed for task {TaskId} ({TaskType}), user {UserId}: [{Code}] {Error}", + task.Id, + task.TaskType, + affectedUserId, + error.Code, + error.Message); + + SunriseMetrics.ScoreProcessingEntryCounterInc( + isPermanent ? "permanent_failure" : "retryable_failure", + task.TaskType, + error.Code); + + if (isPermanent && task.TaskType == ScoreTaskType.Submission) + NotifyUserOfPermanentFailure(sessions, task, affectedUserId); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + await HandleUnexpectedEntryException(task, affectedUserId, ex); + } + } + + private static void NotifyUserOfPermanentFailure(SessionRepository sessions, ScoreProcessingTask task, int? affectedUserId) + { + Log.Warning("Score processing permanently failed for submission task {TaskId}, user {UserId}", task.Id, affectedUserId); + + if (affectedUserId.HasValue && sessions.TryGetSession(out var userSession, userId: affectedUserId.Value) && userSession != null) + userSession.SendNotification($"One of your submitted scores couldn't be processed. If you think this is a mistake, please contact the support with task ID: {task.Id}"); + } + + private static async Task CleanupCompletedTask(DatabaseService database, ScoreProcessingTask task, CancellationToken ct) + { + if (task is { TaskType: ScoreTaskType.Submission, ScoreSubmissionRequestId: not null }) + { + var cleanupResult = await database.CommitAsTransactionAsync(async () => + { + await database.ScoreProcessingTasks.MarkForDeletion(task.Id, ct); + await database.ScoreSubmissionRequests.DeleteById(task.ScoreSubmissionRequestId.Value, ct); + }, + ct); + + if (cleanupResult.IsFailure) + throw new ApplicationException($"Failed to clean up completed submission task {task.Id}: {cleanupResult.Error}"); + + return; + } + + await database.ScoreProcessingTasks.MarkForDeletion(task.Id, ct); + } + + private async Task HandleUnexpectedEntryException(ScoreProcessingTask task, int? affectedUserId, Exception ex) + { + Log.Error(ex, "Unexpected exception while processing score task {TaskId} ({TaskType}) for user {UserId}", task.Id, task.TaskType, affectedUserId); + SunriseMetrics.ScoreProcessingEntryCounterInc("unexpected", task.TaskType, ScoreProcessingErrorCode.Unexpected); + + try + { + using var failureScope = scopeFactory.CreateScope(); + var failureDatabase = failureScope.ServiceProvider.GetRequiredService(); + var unexpectedError = new ScoreProcessingError(ScoreProcessingErrorCode.Unexpected, ex.Message, ScoreProcessingDisposition.Retryable); + + await failureDatabase.ScoreProcessingTasks.MarkAsFailed(task.Id, unexpectedError, GetBackoffDelay(task.RetryCount)); + } + catch (Exception markFailedException) + { + Log.Error(markFailedException, + "Failed to mark score task {TaskId} as failed after unexpected exception for user {UserId}", + task.Id, + affectedUserId); + } + } + + private static async Task ResolveAffectedUserId(DatabaseService database, ScoreProcessingTask task, CancellationToken ct) + { + if (task.ScoreSubmissionRequestId.HasValue) + return await database.ScoreSubmissionRequests.GetUserIdByPayloadId(task.ScoreSubmissionRequestId.Value, ct); + + if (task.ScoreId.HasValue) + return await database.Scores.GetUserIdByScoreId(task.ScoreId.Value, ct); + + return null; + } + + private static TimeSpan GetBackoffDelay(int retryCount) + { + var schedule = Configuration.ScoreProcessingBackoffSchedule; + if (schedule.Length == 0) + return TimeSpan.FromMinutes(DefaultBackoffMinutes); + + var index = Math.Min(retryCount, schedule.Length - 1); + return schedule[index]; + } +} \ No newline at end of file diff --git a/Sunrise.Processing/Scores/Pipeline/ScoreCommitContext.cs b/Sunrise.Processing/Scores/Pipeline/ScoreCommitContext.cs new file mode 100644 index 00000000..c9a4fa97 --- /dev/null +++ b/Sunrise.Processing/Scores/Pipeline/ScoreCommitContext.cs @@ -0,0 +1,29 @@ +using Sunrise.Shared.Database.Models; +using Sunrise.Shared.Database.Models.Users; +using Sunrise.Shared.Enums.Scores; +using Sunrise.Shared.Objects; +using Sunrise.Shared.Objects.Serializable; + +namespace Sunrise.Processing.Scores.Pipeline; + +public sealed class ScoreCommitContext( + ScoreTaskType taskType, + Score score, + User user, + UserStats userStats, + UserGrades userGrades, + Beatmap? beatmap = null, + BeatmapSet? beatmapSet = null) +{ + public ScoreTaskType TaskType { get; } = taskType; + public ScoreStateSnapshot OriginalState { get; internal set; } + public UserBeatmapPeers? UserPersonalBestScores { get; internal set; } + public List? UnlockedMedals { get; internal set; } + + public Score Score { get; } = score; + public User User { get; } = user; + public UserStats UserStats { get; } = userStats; + public UserGrades UserGrades { get; } = userGrades; + public Beatmap? Beatmap { get; } = beatmap; + public BeatmapSet? BeatmapSet { get; } = beatmapSet; +} \ No newline at end of file diff --git a/Sunrise.Processing/Scores/Pipeline/ScoreCommitPipeline.cs b/Sunrise.Processing/Scores/Pipeline/ScoreCommitPipeline.cs new file mode 100644 index 00000000..489cdd71 --- /dev/null +++ b/Sunrise.Processing/Scores/Pipeline/ScoreCommitPipeline.cs @@ -0,0 +1,115 @@ +using CSharpFunctionalExtensions; +using Sunrise.Processing.Scores.Processors; +using Sunrise.Shared.Application; +using Sunrise.Shared.Attributes; +using Sunrise.Shared.Database; +using Sunrise.Shared.Database.Models; +using Sunrise.Shared.Database.Models.Scores; +using Sunrise.Shared.Enums.Scores; +using Sunrise.Shared.Extensions.Beatmaps; +using Sunrise.Shared.Objects.Serializable; + +namespace Sunrise.Processing.Scores.Pipeline; + +[TraceExecution] +public class ScoreCommitPipeline +{ + private readonly DatabaseService _database; + private readonly IScoreEntityProcessor[] _processors; + + public ScoreCommitPipeline(DatabaseService database, IEnumerable processors) + { + _database = database; + _processors = processors.OrderBy(p => p.Priority).ToArray(); + } + + public async Task Commit( + ScoreCommitContext ctx, + ScoreProcessingTask? task, + CancellationToken ct) + { + return await _database.CommitAsTransactionAsync(async () => { await ExecuteCommitAsync(ctx, task, ct); }, ct); + } + + private async Task ExecuteCommitAsync( + ScoreCommitContext ctx, + ScoreProcessingTask? task, + CancellationToken ct) + { + var score = ctx.Score; + + score.LocalProperties = new LocalProperties().FromScore(score); + + ctx.OriginalState = ScoreStateSnapshot.Capture(score); + + EnrichScoreWithBeatmapStatus(score, ctx.Beatmap); + + var excludeScoreId = ctx.TaskType == ScoreTaskType.Submission ? (int?)null : score.Id; + + var peers = await _database.Scores.GetUserBeatmapPeersForUpdate( + score.UserId, + score.BeatmapHash, + score.GameMode, + score.Mods, + excludeScoreId, + ct); + + ctx.UserPersonalBestScores = peers; + + foreach (var processor in _processors) + { + await DispatchProcessor(processor, ctx); + } + + var refreshClaimLeaseResult = await TryRefreshClaimLease(task, ct); + if (refreshClaimLeaseResult.IsFailure) + throw new ApplicationException(refreshClaimLeaseResult.Error); + } + + private static void EnrichScoreWithBeatmapStatus(Score score, Beatmap? beatmap) + { + var newBeatmapStatus = beatmap?.Status; + + if (!newBeatmapStatus.HasValue || newBeatmapStatus == score.BeatmapStatus) + return; + + score.BeatmapStatus = newBeatmapStatus.Value; + score.IsScoreable = newBeatmapStatus.Value.IsScoreable(); + score.LocalProperties = score.LocalProperties.FromScore(score); + } + + private async Task> TryRefreshClaimLease(ScoreProcessingTask? task, CancellationToken ct) + { + if (task == null || string.IsNullOrWhiteSpace(task.ClaimToken)) + return UnitResult.Success(); + + var claimToken = task.ClaimToken; + var leaseUntil = DateTime.UtcNow + Configuration.ScoreProcessingBatchLease; + var rowsAffected = await _database.ScoreProcessingTasks.RefreshClaimLease(task.Id, claimToken, leaseUntil, ct); + + return rowsAffected == 0 + ? UnitResult.Failure($"Task {task.Id} claim lost; rolling back") + : UnitResult.Success(); + } + + private static async Task DispatchProcessor(IScoreEntityProcessor processor, ScoreCommitContext ctx) + { + switch (ctx.TaskType) + { + case ScoreTaskType.Submission: + await processor.OnNewSubmission(ctx); + break; + case ScoreTaskType.Recalculation: + await processor.OnRecalculation(ctx); + break; + case ScoreTaskType.Delete: + await processor.OnDeletion(ctx); + break; + case ScoreTaskType.Restore: + await processor.OnRestoration(ctx); + break; + default: + throw new ArgumentOutOfRangeException(nameof(ctx.TaskType), ctx.TaskType, $"Unhandled task type: {ctx.TaskType}"); + } + } +} \ No newline at end of file diff --git a/Sunrise.Processing/Scores/Pipeline/ScoreStateSnapshot.cs b/Sunrise.Processing/Scores/Pipeline/ScoreStateSnapshot.cs new file mode 100644 index 00000000..0386c86a --- /dev/null +++ b/Sunrise.Processing/Scores/Pipeline/ScoreStateSnapshot.cs @@ -0,0 +1,21 @@ +using Sunrise.Shared.Database.Models; +using Sunrise.Shared.Extensions.Beatmaps; +using SubmissionStatus = Sunrise.Shared.Enums.Scores.SubmissionStatus; + +namespace Sunrise.Processing.Scores.Pipeline; + +public readonly record struct ScoreStateSnapshot( + SubmissionStatus SubmissionStatus, + bool IsScoreable, + bool IsPassed, + bool IsRanked) +{ + public static ScoreStateSnapshot Capture(Score score) + { + return new ScoreStateSnapshot( + score.SubmissionStatus, + score.IsScoreable, + score.IsPassed, + score.BeatmapStatus.IsRanked()); + } +} diff --git a/Sunrise.Processing/Scores/Processors/IScoreEntityProcessor.cs b/Sunrise.Processing/Scores/Processors/IScoreEntityProcessor.cs new file mode 100644 index 00000000..90dd4015 --- /dev/null +++ b/Sunrise.Processing/Scores/Processors/IScoreEntityProcessor.cs @@ -0,0 +1,16 @@ +using Sunrise.Processing.Scores.Pipeline; + +namespace Sunrise.Processing.Scores.Processors; + +public interface IScoreEntityProcessor +{ + int Priority { get; } + + Task OnNewSubmission(ScoreCommitContext ctx); + + Task OnRecalculation(ScoreCommitContext ctx); + + Task OnDeletion(ScoreCommitContext ctx); + + Task OnRestoration(ScoreCommitContext ctx); +} \ No newline at end of file diff --git a/Sunrise.Processing/Scores/Processors/LeaderboardProcessor.cs b/Sunrise.Processing/Scores/Processors/LeaderboardProcessor.cs new file mode 100644 index 00000000..7ded7b3f --- /dev/null +++ b/Sunrise.Processing/Scores/Processors/LeaderboardProcessor.cs @@ -0,0 +1,83 @@ +using Sunrise.Processing.Scores.Pipeline; +using Sunrise.Processing.Utils; +using Sunrise.Shared.Attributes; +using Sunrise.Shared.Database; +using Sunrise.Shared.Database.Extensions; +using SubmissionStatus = Sunrise.Shared.Enums.Scores.SubmissionStatus; + +namespace Sunrise.Processing.Scores.Processors; + +[TraceExecution] +public class LeaderboardProcessor(DatabaseService database) : ScoreEntityProcessorBase +{ + public override int Priority => 100; + + protected override Task OnNewSubmissionInternal(ScoreCommitContext ctx) + { + ReconcileSubmissionStatus(ctx); + return Task.CompletedTask; + } + + protected override Task OnRecalculationInternal(ScoreCommitContext ctx) + { + ReconcileSubmissionStatus(ctx); + return Task.CompletedTask; + } + + protected override Task OnDeletionInternal(ScoreCommitContext ctx) + { + ctx.Score.SubmissionStatus = SubmissionStatus.Deleted; + + ReconcileSubmissionStatus(ctx); + return Task.CompletedTask; + } + + protected override Task OnRestorationInternal(ScoreCommitContext ctx) + { + var score = ctx.Score; + + score.SubmissionStatus = score.IsPassed + ? SubmissionStatus.Submitted + : SubmissionStatus.Failed; + + ReconcileSubmissionStatus(ctx); + return Task.CompletedTask; + } + + protected override async Task AfterExecution(ScoreCommitContext ctx) + { + database.DbContext.UpdateEntity(ctx.Score); + + if (ctx.UserPersonalBestScores?.SameModsPeer?.BestScoreByScoreValue != null) + database.DbContext.UpdateEntity(ctx.UserPersonalBestScores.SameModsPeer.BestScoreByScoreValue); + + await database.DbContext.SaveChangesAsync(); + } + + private void ReconcileSubmissionStatus(ScoreCommitContext ctx) + { + var score = ctx.Score; + + var sameModsPeer = ctx.UserPersonalBestScores?.SameModsPeer?.BestScoreByScoreValue; + + if (score.SubmissionStatus != SubmissionStatus.Deleted) + score.UpdateSubmissionStatus(sameModsPeer); + + if (score.SubmissionStatus == SubmissionStatus.Best && sameModsPeer != null) + { + sameModsPeer.SubmissionStatus = sameModsPeer.IsPassed + ? SubmissionStatus.Submitted + : SubmissionStatus.Failed; + + return; + } + + var vacatedBest = ctx.OriginalState.SubmissionStatus == SubmissionStatus.Best + && score.SubmissionStatus != SubmissionStatus.Best; + + if (vacatedBest && sameModsPeer != null && sameModsPeer.SubmissionStatus != SubmissionStatus.Best) + { + sameModsPeer.SubmissionStatus = SubmissionStatus.Best; + } + } +} \ No newline at end of file diff --git a/Sunrise.Server/Services/MedalService.cs b/Sunrise.Processing/Scores/Processors/MedalScoreProcessor.cs similarity index 55% rename from Sunrise.Server/Services/MedalService.cs rename to Sunrise.Processing/Scores/Processors/MedalScoreProcessor.cs index 421e22cb..7d6dc513 100644 --- a/Sunrise.Server/Services/MedalService.cs +++ b/Sunrise.Processing/Scores/Processors/MedalScoreProcessor.cs @@ -1,7 +1,7 @@ using System.Collections.Concurrent; -using System.Diagnostics; using System.Linq.Dynamic.Core; using osu.Shared; +using Sunrise.Processing.Scores.Pipeline; using Sunrise.Shared.Attributes; using Sunrise.Shared.Database; using Sunrise.Shared.Database.Models; @@ -9,18 +9,50 @@ using Sunrise.Shared.Database.Seeders; using Sunrise.Shared.Enums; using Sunrise.Shared.Extensions.Beatmaps; -using Beatmap = Sunrise.Shared.Objects.Serializable.Beatmap; +using Sunrise.Shared.Objects.Serializable; -namespace Sunrise.Server.Services; +namespace Sunrise.Processing.Scores.Processors; -public class MedalService(DatabaseService database) +[TraceExecution] +public class MedalScoreProcessor(DatabaseService database) : ScoreEntityProcessorBase { - private static readonly ActivitySource ActivitySource = new("Sunrise.MedalService"); + public override int Priority => 300; - [TraceExecution] - public async Task UnlockAndGetNewMedals(Score score, Beatmap beatmap, UserStats userStats) + protected override async Task OnNewSubmissionInternal(ScoreCommitContext ctx) { - if (!score.IsPassed || !beatmap.Status.IsScoreable()) return string.Empty; + if (!ctx.Score.IsScoreable) + return; + + if (ctx.Beatmap == null) + throw new InvalidOperationException("Beatmap must be present in context to unlock medals."); + + ctx.UnlockedMedals = await UnlockAndGetNewMedals(ctx.Score, ctx.Beatmap, ctx.UserStats); + } + + protected override async Task OnRecalculationInternal(ScoreCommitContext ctx) + { + if (!ctx.Score.IsScoreable) + return; + + if (ctx.Beatmap == null) + throw new InvalidOperationException("Beatmap must be present in context to unlock medals."); + + ctx.UnlockedMedals = await UnlockAndGetNewMedals(ctx.Score, ctx.Beatmap, ctx.UserStats); + } + + protected override Task OnDeletionInternal(ScoreCommitContext ctx) + { + return Task.CompletedTask; + } + + protected override Task OnRestorationInternal(ScoreCommitContext ctx) + { + return Task.CompletedTask; + } + + private async Task> UnlockAndGetNewMedals(Score score, Beatmap beatmap, UserStats userStats) + { + if (!score.IsPassed || !beatmap.Status.IsScoreable()) return []; var medals = await database.Medals.GetMedals(score.GameMode); var userMedals = await database.Users.Medals.GetUserMedals(userStats.UserId); @@ -40,14 +72,14 @@ await Parallel.ForEachAsync( ); var newMedalsIds = eligibleMedals.Select(m => m.Id).ToList(); - await database.Users.Medals.UnlockMedals(userStats.UserId, newMedalsIds); + var unlockMedalsResult = await database.Users.Medals.UnlockMedals(userStats.UserId, newMedalsIds); + if (unlockMedalsResult.IsFailure) + throw new ApplicationException("Failed to unlock medals: " + unlockMedalsResult.Error); - return string.Join("/", eligibleMedals.Select(GetMedalString)); + return eligibleMedals.ToList(); ValueTask EvaluateMedal(Medal medal) { - using var activity = ActivitySource.StartActivity($"Evaluating medal {medal.Id}"); - var isConditionsAreMet = Evaluate(new MedalConditionContext { user = userStats, @@ -78,9 +110,4 @@ private static bool Evaluate(T obj, string expression) }.AsQueryable(); return objQueryable.Any(expression); } - - private static string GetMedalString(Medal medal) - { - return $"{medal.Id}+{medal.Name}+{medal.Description}"; - } } \ No newline at end of file diff --git a/Sunrise.Processing/Scores/Processors/ScoreEntityProcessorBase.cs b/Sunrise.Processing/Scores/Processors/ScoreEntityProcessorBase.cs new file mode 100644 index 00000000..efc77f37 --- /dev/null +++ b/Sunrise.Processing/Scores/Processors/ScoreEntityProcessorBase.cs @@ -0,0 +1,59 @@ +using Sunrise.Processing.Scores.Pipeline; + +namespace Sunrise.Processing.Scores.Processors; + +public abstract class ScoreEntityProcessorBase : IScoreEntityProcessor +{ + public abstract int Priority { get; } + + public async Task OnNewSubmission(ScoreCommitContext ctx) + { + await Execute(ctx, OnNewSubmissionInternal); + } + + public async Task OnRecalculation(ScoreCommitContext ctx) + { + await Execute(ctx, OnRecalculationInternal); + } + + public async Task OnDeletion(ScoreCommitContext ctx) + { + await Execute(ctx, OnDeletionInternal); + } + + public async Task OnRestoration(ScoreCommitContext ctx) + { + await Execute(ctx, OnRestorationInternal); + } + + protected virtual Task OnNewSubmissionInternal(ScoreCommitContext ctx) + { + throw new InvalidOperationException("OnNewSubmission must be implemented for new submission processing."); + } + + protected virtual Task OnRecalculationInternal(ScoreCommitContext ctx) + { + throw new InvalidOperationException("OnRecalculation must be implemented for new submission processing."); + } + + protected virtual Task OnDeletionInternal(ScoreCommitContext ctx) + { + throw new InvalidOperationException("OnDeletion must be implemented for new submission processing."); + } + + protected virtual Task OnRestorationInternal(ScoreCommitContext ctx) + { + throw new InvalidOperationException("OnRestoration must be implemented for new submission processing."); + } + + protected virtual Task AfterExecution(ScoreCommitContext ctx) + { + return Task.CompletedTask; + } + + private async Task Execute(ScoreCommitContext ctx, Func action) + { + await action(ctx); + await AfterExecution(ctx); + } +} \ No newline at end of file diff --git a/Sunrise.Processing/Scores/Processors/UserGradesScoreProcessor.cs b/Sunrise.Processing/Scores/Processors/UserGradesScoreProcessor.cs new file mode 100644 index 00000000..93c147e4 --- /dev/null +++ b/Sunrise.Processing/Scores/Processors/UserGradesScoreProcessor.cs @@ -0,0 +1,116 @@ +using osu.Shared; +using Sunrise.Processing.Scores.Pipeline; +using Sunrise.Shared.Attributes; +using Sunrise.Shared.Database; +using Sunrise.Shared.Database.Models; +using Sunrise.Shared.Database.Models.Users; +using Sunrise.Shared.Extensions.Scores; +using SubmissionStatus = Sunrise.Shared.Enums.Scores.SubmissionStatus; + +namespace Sunrise.Processing.Scores.Processors; + +[TraceExecution] +public class UserGradesScoreProcessor(DatabaseService database) : ScoreEntityProcessorBase +{ + public override int Priority => 200; + + protected override Task OnNewSubmissionInternal(ScoreCommitContext ctx) + { + IncrementWithScore(ctx); + return Task.CompletedTask; + } + + protected override Task OnRecalculationInternal(ScoreCommitContext ctx) + { + return Task.CompletedTask; + } + + protected override Task OnDeletionInternal(ScoreCommitContext ctx) + { + DecrementWithScore(ctx); + return Task.CompletedTask; + } + + protected override Task OnRestorationInternal(ScoreCommitContext ctx) + { + IncrementWithScore(ctx); + return Task.CompletedTask; + } + + protected override async Task AfterExecution(ScoreCommitContext ctx) + { + var updateUserGradesResult = await database.Users.Grades.UpdateUserGrades(ctx.UserGrades); + if (updateUserGradesResult.IsFailure) + throw new ApplicationException("Failed to persist user grades: " + updateUserGradesResult.Error); + } + + private static void IncrementWithScore(ScoreCommitContext ctx) + { + var score = ctx.Score; + var userGrades = ctx.UserGrades; + var previousOverallBest = ctx.UserPersonalBestScores?.OverallPeer?.BestScoreByScoreValue; + + var isFailed = !score.IsPassed && !score.Mods.HasFlag(Mods.NoFail); + if (isFailed || !score.IsScoreable || score.SubmissionStatus != SubmissionStatus.Best) + return; + + if (!IsOverallBestScore(score, previousOverallBest)) + return; + + if (previousOverallBest != null) + UpdateUserGradesCount(userGrades, previousOverallBest.Grade, -1); + + UpdateUserGradesCount(userGrades, score.Grade, 1); + } + + private static void DecrementWithScore(ScoreCommitContext ctx) + { + var score = ctx.Score; + var userGrades = ctx.UserGrades; + var original = ctx.OriginalState; + var promotedOverallBest = ctx.UserPersonalBestScores?.OverallPeer?.BestScoreByScoreValue; + + var isFailed = !original.IsPassed && !score.Mods.HasFlag(Mods.NoFail); + if (isFailed || !original.IsScoreable || original.SubmissionStatus != SubmissionStatus.Best) + return; + + if (!IsOverallBestScore(score, promotedOverallBest)) + return; + + UpdateUserGradesCount(userGrades, score.Grade, -1); + + if (promotedOverallBest != null) + UpdateUserGradesCount(userGrades, promotedOverallBest.Grade, 1); + } + + private static bool IsOverallBestScore(Score score, Score? peer) + { + if (peer == null) + return true; + + return new List + { + score, + peer + } + .SortScoresByTheirScoreValue() + .First() == score; + } + + private static void UpdateUserGradesCount(UserGrades userGrades, string grade, int delta) + { + switch (grade) + { + case "XH": userGrades.CountXH = Math.Max(0, userGrades.CountXH + delta); break; + case "X": userGrades.CountX = Math.Max(0, userGrades.CountX + delta); break; + case "SH": userGrades.CountSH = Math.Max(0, userGrades.CountSH + delta); break; + case "S": userGrades.CountS = Math.Max(0, userGrades.CountS + delta); break; + case "A": userGrades.CountA = Math.Max(0, userGrades.CountA + delta); break; + case "B": userGrades.CountB = Math.Max(0, userGrades.CountB + delta); break; + case "C": userGrades.CountC = Math.Max(0, userGrades.CountC + delta); break; + case "D": userGrades.CountD = Math.Max(0, userGrades.CountD + delta); break; + case "F": break; + default: throw new ArgumentOutOfRangeException($"Unknown grade: {grade} while updating user grades with score."); + } + } +} \ No newline at end of file diff --git a/Sunrise.Processing/Scores/Processors/UserStatsScoreProcessor.cs b/Sunrise.Processing/Scores/Processors/UserStatsScoreProcessor.cs new file mode 100644 index 00000000..f20d35cd --- /dev/null +++ b/Sunrise.Processing/Scores/Processors/UserStatsScoreProcessor.cs @@ -0,0 +1,175 @@ +using osu.Shared; +using Sunrise.Processing.Scores.Pipeline; +using Sunrise.Shared.Application; +using Sunrise.Shared.Attributes; +using Sunrise.Shared.Database; +using Sunrise.Shared.Database.Models; +using Sunrise.Shared.Database.Models.Users; +using Sunrise.Shared.Extensions.Beatmaps; +using Sunrise.Shared.Extensions.Scores; +using Sunrise.Shared.Services; +using GameMode = Sunrise.Shared.Enums.Beatmaps.GameMode; +using SubmissionStatus = Sunrise.Shared.Enums.Scores.SubmissionStatus; + +namespace Sunrise.Processing.Scores.Processors; + +[TraceExecution] +public class UserStatsScoreProcessor( + DatabaseService database, + CalculatorService calculatorService) : ScoreEntityProcessorBase +{ + public override int Priority => 200; + + protected override async Task OnNewSubmissionInternal(ScoreCommitContext ctx) + { + await IncrementUserStats(ctx); + } + + protected override async Task OnRecalculationInternal(ScoreCommitContext ctx) + { + await ApplyWeightedRefresh(ctx); + } + + protected override async Task OnDeletionInternal(ScoreCommitContext ctx) + { + await DecrementUserStats(ctx); + } + + protected override async Task OnRestorationInternal(ScoreCommitContext ctx) + { + await IncrementUserStats(ctx); + } + + protected override async Task AfterExecution(ScoreCommitContext ctx) + { + var updateUserStatsResult = await database.Users.Stats.UpdateUserStats(ctx.UserStats, ctx.User); + if (updateUserStatsResult.IsFailure) + throw new ApplicationException("Failed to persist user stats: " + updateUserStatsResult.Error); + } + + private async Task IncrementUserStats(ScoreCommitContext ctx) + { + var score = ctx.Score; + var userStats = ctx.UserStats; + var personalBestScores = ctx.UserPersonalBestScores?.OverallPeer; + var currentBest = personalBestScores?.BestScoreByScoreValue; + + var isFirstBeatmapScore = currentBest == null; + + var isBestScoreValue = IsBestByScoreValue(score, currentBest); + + var isBetterTotalScoreValue = isFirstBeatmapScore || isBestScoreValue; + var isBetterPerformanceValue = isFirstBeatmapScore || ( + Configuration.UseNewPerformanceCalculationAlgorithm + ? score.PerformancePoints > personalBestScores?.BestScoreForPerformanceCalculation.PerformancePoints + : isBetterTotalScoreValue); + + var isFailed = !score.IsPassed && !score.Mods.HasFlag(Mods.NoFail); + + userStats.TotalScore += score.TotalScore; + IncreaseTotalHits(userStats, score); + userStats.PlayTime += score.TimeElapsed; + userStats.PlayCount++; + + if (isFailed || !score.IsScoreable) + return; + + userStats.MaxCombo = Math.Max(userStats.MaxCombo, score.MaxCombo); + + if (!score.LocalProperties.IsRanked) + return; + + if (isBetterTotalScoreValue) + { + userStats.RankedScore += isFirstBeatmapScore + ? score.TotalScore + : score.TotalScore - currentBest!.TotalScore; + } + + if (isBetterPerformanceValue) + { + (userStats.PerformancePoints, userStats.Accuracy) = await calculatorService.CalculateUserWeightedStats(ctx.User, score.GameMode); + } + } + + private async Task DecrementUserStats(ScoreCommitContext ctx) + { + var score = ctx.Score; + var userStats = ctx.UserStats; + var original = ctx.OriginalState; + + var overallPeer = ctx.UserPersonalBestScores?.OverallPeer?.BestScoreByScoreValue; + var isGloballyBestTotalScore = IsBestByScoreValue(score, overallPeer); + + var isFailed = !original.IsPassed && !score.Mods.HasFlag(Mods.NoFail); + + userStats.TotalScore = Math.Max(0, userStats.TotalScore - score.TotalScore); + DecreaseTotalHits(userStats, score); + userStats.PlayTime = Math.Max(0, userStats.PlayTime - score.TimeElapsed); + userStats.PlayCount = Math.Max(0, userStats.PlayCount - 1); + + if (isFailed || !original.IsScoreable) + return; + + if (score.MaxCombo == userStats.MaxCombo) + { + var fallbackMax = await database.Scores.GetUserMaxComboExcluding(score.UserId, score.GameMode, score.Id); + userStats.MaxCombo = fallbackMax ?? 0; + } + + + if (!original.IsRanked) + return; + + if (original is { SubmissionStatus: SubmissionStatus.Best } && isGloballyBestTotalScore) + { + var promotedPeer = ctx.UserPersonalBestScores?.SameModsPeer?.BestScoreByScoreValue; + var rankedDecrement = promotedPeer != null + ? score.TotalScore - promotedPeer.TotalScore + : score.TotalScore; + + userStats.RankedScore = Math.Max(0, userStats.RankedScore - rankedDecrement); + } + + (userStats.PerformancePoints, userStats.Accuracy) = await calculatorService.CalculateUserWeightedStats(ctx.User, score.GameMode); + } + + private static bool IsBestByScoreValue(Score score, Score? competingScore) + { + if (competingScore == null) + return true; + + return new List + { + score, + competingScore + } + .SortScoresByTheirScoreValue() + .First().Id == score.Id; + } + + private async Task ApplyWeightedRefresh(ScoreCommitContext ctx) + { + var score = ctx.Score; + if (!score.LocalProperties.IsRanked || !score.IsScoreable || !score.IsPassed) + return; + + (ctx.UserStats.PerformancePoints, ctx.UserStats.Accuracy) = await calculatorService.CalculateUserWeightedStats(ctx.User, score.GameMode); + } + + private static void IncreaseTotalHits(UserStats userStats, Score score) + { + userStats.TotalHits += score.Count300 + score.Count100 + score.Count50; + if ((GameMode)score.GameMode.ToVanillaGameMode() is GameMode.Taiko or GameMode.Mania) + userStats.TotalHits += score.CountGeki + score.CountKatu; + } + + private static void DecreaseTotalHits(UserStats userStats, Score score) + { + var delta = score.Count300 + score.Count100 + score.Count50; + if ((GameMode)score.GameMode.ToVanillaGameMode() is GameMode.Taiko or GameMode.Mania) + delta += score.CountGeki + score.CountKatu; + + userStats.TotalHits = Math.Max(0, userStats.TotalHits - delta); + } +} \ No newline at end of file diff --git a/Sunrise.Processing/Services/ScoreSideEffectsPublisherService.cs b/Sunrise.Processing/Services/ScoreSideEffectsPublisherService.cs new file mode 100644 index 00000000..89d6b62d --- /dev/null +++ b/Sunrise.Processing/Services/ScoreSideEffectsPublisherService.cs @@ -0,0 +1,158 @@ +using CSharpFunctionalExtensions; +using Serilog; +using Sunrise.API.Enums; +using Sunrise.API.Objects; +using Sunrise.API.Serializable.Response; +using Sunrise.Processing.Scores.Pipeline; +using Sunrise.Processing.Utils; +using Sunrise.Shared.Application; +using Sunrise.Shared.Attributes; +using Sunrise.Shared.Database; +using Sunrise.Shared.Database.Models; +using Sunrise.Shared.Database.Models.Users; +using Sunrise.Shared.Database.Objects; +using Sunrise.Shared.Extensions.Beatmaps; +using Sunrise.Shared.Extensions.Scores; +using Sunrise.Shared.Objects.Sessions; +using Sunrise.Shared.Repositories; +using Sunrise.Shared.Services; +using WebSocketManager = Sunrise.API.Managers.WebSocketManager; + +namespace Sunrise.Processing.Services; + +[TraceExecution] +public class ScoreSideEffectsPublisherService( + DatabaseService database, + CalculatorService calculatorService, + WebSocketManager webSocketManager, + SessionRepository sessions, + ChatChannelRepository channels) +{ + public async Task BuildScoreSubmitResponse( + ScoreCommitContext ctx, + UserStats prevUserStats, + CancellationToken ct = default) + { + if (ctx.Beatmap == null) + throw new InvalidOperationException("Beatmap must be present in context to build score submit response."); + + var (newUserRank, _) = await database.Users.Stats.Ranks.GetUserRanks(ctx.User, ctx.UserStats.GameMode, ct: ct); + ctx.UserStats.LocalProperties.Rank = newUserRank; + + var scoresWithLeaderboardPosition = await database.Scores.EnrichScoresWithLeaderboardPosition(new List + { + ctx.Score, + ctx.UserPersonalBestScores?.OverallPeer?.BestScoreByScoreValue, + ctx.UserPersonalBestScores?.OverallPeer?.BestScoreForPerformanceCalculation + }.Where(s => s != null).Cast().ToList(), + ct); + + // Fill leaderboard position for the graphs + scoresWithLeaderboardPosition.ForEach(s => + { + if (s.Id == ctx.Score.Id) + ctx.Score.LocalProperties.LeaderboardPosition = s.LocalProperties.LeaderboardPosition; + else if (ctx.UserPersonalBestScores?.OverallPeer != null) + { + if (s.Id == ctx.UserPersonalBestScores.OverallPeer.BestScoreByScoreValue.Id) + ctx.UserPersonalBestScores.OverallPeer.BestScoreByScoreValue.LocalProperties.LeaderboardPosition = s.LocalProperties.LeaderboardPosition; + else if (s.Id == ctx.UserPersonalBestScores.OverallPeer.BestScoreForPerformanceCalculation.Id) + ctx.UserPersonalBestScores.OverallPeer.BestScoreForPerformanceCalculation.LocalProperties.LeaderboardPosition = s.LocalProperties.LeaderboardPosition; + } + }); + + var newAchievements = ctx.UnlockedMedals != null ? string.Join("/", ctx.UnlockedMedals.Select(ScoreMedalUtil.GetMedalScoreSubmissionResultString)) : null; + + return ScoreSubmissionUtil.GetScoreSubmitResponse(ctx.Beatmap, ctx.UserStats, prevUserStats, ctx.Score, ctx.UserPersonalBestScores?.OverallPeer, newAchievements); + } + + public async Task PublishScoreSubmissionSideEffects( + BaseSession beatmapRatelimitSession, + ScoreCommitContext ctx, + CancellationToken ct = default) + { + var score = ctx.Score; + score.User ??= ctx.User; + + var beatmap = ctx.Beatmap; + var beatmapSet = ctx.BeatmapSet; + + // If score is not scoreable - no side effects will be planned for it + if (!IsScoreScoreable(score)) + return Result.Success(); + + if (beatmap == null || beatmapSet == null) + return Result.Failure("Beatmap and beatmap set must be present in context to publish score side effects."); + + SunriseMetrics.ScoreSubmittedCounterInc(score.UserId, beatmap.Id, score.GameMode, score.Mods, score.PerformancePoints, score.Id); + + webSocketManager.BroadcastJsonAsync(new WebSocketMessage(WebSocketEventType.NewScoreSubmitted, new ScoreResponse(sessions, score))); + + if ((int)score.GameMode != beatmap.ModeInt || (int)score.Mods > 0) + { + var recalculateBeatmapResult = + await calculatorService.CalculateBeatmapPerformance(beatmapRatelimitSession, score.BeatmapId, score.GameMode, score.Mods); + + if (recalculateBeatmapResult.IsFailure) + { + Log.Warning("Failed to recalculate beatmap performance for beatmap {BeatmapId} with mods {Mods} after score submission: {Error}", + beatmap.Id, + score.Mods, + recalculateBeatmapResult.Error); + } + else + { + beatmap.UpdateBeatmapWithPerformance(score.Mods, recalculateBeatmapResult.Value); + } + } + + var (globalScores, _) = await database.Scores.GetBeatmapScores( + score.BeatmapHash, + score.GameMode, + options: new QueryOptions(new Pagination(1, 2)) + { + AsNoTracking = true, + IgnoreCountQueryIfExists = true + }, + ct: ct); + + var isScoreFirstPlace = globalScores.FirstOrDefault()?.ScoreHash == score.ScoreHash; + + var secondBeatmapsBestFromDifferentUser = globalScores.Find(s => s.UserId != score.UserId); + + var isPeerWasFirstPlace = IsOverallBestScore(ctx.UserPersonalBestScores?.OverallPeer?.BestScoreByScoreValue, secondBeatmapsBestFromDifferentUser); + + var shouldAnnounceNewFirstPlace = isScoreFirstPlace && !isPeerWasFirstPlace; + + if (shouldAnnounceNewFirstPlace) + { + channels.GetScoreAnnouncementChannel() + ?.SendToChannel(ScoreSubmissionUtil.GetNewFirstPlaceString(score, beatmapSet, beatmap)); + } + + return Result.Success(); + } + + private static bool IsOverallBestScore(Score? scoreA, Score? scoreB) + { + if (scoreB == null) + return true; + + if (scoreA == null) + return false; + + return new List + { + scoreA, + scoreB + } + .SortScoresByTheirScoreValue() + .First() == scoreA; + } + + private static bool IsScoreScoreable(Score score) + { + var isCurrentScoreFailed = ScoreSubmissionUtil.IsScoreFailed(score); + return !isCurrentScoreFailed && score.IsScoreable; + } +} \ No newline at end of file diff --git a/Sunrise.Processing/Sunrise.Processing.csproj b/Sunrise.Processing/Sunrise.Processing.csproj new file mode 100644 index 00000000..398a58ea --- /dev/null +++ b/Sunrise.Processing/Sunrise.Processing.csproj @@ -0,0 +1,21 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + + + diff --git a/Sunrise.Processing/Utils/ScoreCandidateBuilderUtil.cs b/Sunrise.Processing/Utils/ScoreCandidateBuilderUtil.cs new file mode 100644 index 00000000..5fff52fd --- /dev/null +++ b/Sunrise.Processing/Utils/ScoreCandidateBuilderUtil.cs @@ -0,0 +1,117 @@ +using CSharpFunctionalExtensions; +using Serilog; +using Sunrise.Shared.Database.Models; +using Sunrise.Shared.Database.Models.Scores; +using Sunrise.Shared.Enums.Scores; +using Sunrise.Shared.Extensions.Beatmaps; +using Sunrise.Shared.Extensions.Scores; +using Sunrise.Shared.Objects; +using Sunrise.Shared.Objects.Serializable; +using Sunrise.Shared.Utils; + +namespace Sunrise.Processing.Utils; + +public static class ScoreCandidateBuilderUtil +{ + public static Result<(SubmittedScore submittedScore, Score score), ScoreProcessingError> Build(ScoreSubmissionRequest queueEntry, Beatmap beatmap) + { + var parsedScoreResult = queueEntry.ScoreSerialized.TryParseBaseScore(queueEntry.WhenPlayed); + + if (parsedScoreResult.IsFailure) + { + return new ScoreProcessingError(ScoreProcessingErrorCode.ParsedScoreInvalid, parsedScoreResult.Error) + .ToResult<(SubmittedScore submittedScore, Score score)>(); + } + + var submittedScore = parsedScoreResult.Value; + var score = submittedScore.ToScore(queueEntry.UserId, beatmap, queueEntry.TimeElapsed); + + if (queueEntry.ReplayFileId.HasValue) + score.ReplayFileId = queueEntry.ReplayFileId.Value; + + return (submittedScore, score); + } + + public static UnitResult ValidateBuiltScore(ScoreSubmissionRequest queueEntry, Score score, SubmittedScore submittedScore, string onlineBeatmapChecksum) + { + var failureValidators = new[] + { + () => AssertPassedScoreHasReplay(score, queueEntry.ScoreSerialized), + () => AssertScoreMods(score, queueEntry.ScoreSerialized), + () => AssertScoreHashes( + queueEntry.UserHash, + score, + queueEntry.ClientHash, + queueEntry.BeatmapHash, + onlineBeatmapChecksum, + queueEntry.StoryboardHash, + submittedScore.PlayerUsername) + }; + + foreach (var validate in failureValidators) + { + var result = validate(); + if (result.IsFailure) + return result; + } + + return UnitResult.Success(); + } + + private static UnitResult AssertScoreHashes(string userHash, Score score, string clientHash, + string beatmapHash, string onlineBeatmapHash, string? storyboardHash, string sessionUsername) + { + var computedOnlineHash = score.ComputeOnlineHash(sessionUsername.Trim(), clientHash, storyboardHash); + var checks = new[] + { + string.Equals(clientHash, userHash, StringComparison.Ordinal), + string.Equals(score.ScoreHash, computedOnlineHash, StringComparison.Ordinal), + string.Equals(beatmapHash, onlineBeatmapHash, StringComparison.Ordinal) + }; + + foreach (var (isHashCorrect, i) in checks.Select((value, index) => (value, index))) + { + if (isHashCorrect) + continue; + + Log.Warning( + "Score submission rejected for user {UserId}. ClientHash: {ClientHash}, UserHash: {UserHash}, ScoreHash: {ScoreHash}, ComputedOnlineHash: {ComputedOnlineHash}, BeatmapHash: {BeatmapHash}, OnlineBeatmapHash: {OnlineBeatmapHash}, StoryboardHash: {StoryboardHash} (Invalid checksums on score submission)", + score.UserId, + clientHash, + userHash, + score.ScoreHash, + computedOnlineHash, + beatmapHash, + onlineBeatmapHash, + storyboardHash); + + return new ScoreProcessingError(ScoreProcessingErrorCode.InvalidChecksums, $"Invalid checksums for entry with index: {i}").ToUnit(); + } + + return UnitResult.Success(); + } + + private static UnitResult AssertPassedScoreHasReplay(Score score, string scoreSerialized) + { + var isCurrentScoreFailed = ScoreSubmissionUtil.IsScoreFailed(score); + + if (isCurrentScoreFailed || score.ReplayFileId != null) + return UnitResult.Success(); + + Log.Error("Replay file not found for passed score {score}", scoreSerialized); + return new ScoreProcessingError(ScoreProcessingErrorCode.ReplayMissing, "Replay file not found for passed score").ToUnit(); + } + + private static UnitResult AssertScoreMods(Score score, string scoreSerialized) + { + var validateScoreModsResult = ModsValidationUtil.ValidateMods(score.Mods, score.GameMode.ToVanillaGameMode()); + + if (validateScoreModsResult.IsFailure) + { + Log.Warning("Invalid mods found on score {score}, {errorMsg}", scoreSerialized, validateScoreModsResult.Error); + return new ScoreProcessingError(ScoreProcessingErrorCode.InvalidMods, validateScoreModsResult.Error).ToUnit(); + } + + return UnitResult.Success(); + } +} \ No newline at end of file diff --git a/Sunrise.Processing/Utils/ScoreMedalUtil.cs b/Sunrise.Processing/Utils/ScoreMedalUtil.cs new file mode 100644 index 00000000..56f9c505 --- /dev/null +++ b/Sunrise.Processing/Utils/ScoreMedalUtil.cs @@ -0,0 +1,11 @@ +using Sunrise.Shared.Database.Models; + +namespace Sunrise.Processing.Utils; + +public static class ScoreMedalUtil +{ + public static string GetMedalScoreSubmissionResultString(Medal medal) + { + return $"{medal.Id}+{medal.Name}+{medal.Description}"; + } +} \ No newline at end of file diff --git a/Sunrise.Server/Services/Helpers/Scores/SubmitScoreHelper.cs b/Sunrise.Processing/Utils/ScoreSubmissionUtil.cs similarity index 68% rename from Sunrise.Server/Services/Helpers/Scores/SubmitScoreHelper.cs rename to Sunrise.Processing/Utils/ScoreSubmissionUtil.cs index 9d013333..96b23e8b 100644 --- a/Sunrise.Server/Services/Helpers/Scores/SubmitScoreHelper.cs +++ b/Sunrise.Processing/Utils/ScoreSubmissionUtil.cs @@ -1,4 +1,4 @@ -using osu.Shared; +using osu.Shared; using Sunrise.Shared.Application; using Sunrise.Shared.Database.Models; using Sunrise.Shared.Database.Models.Users; @@ -6,33 +6,23 @@ using Sunrise.Shared.Extensions.Scores; using Sunrise.Shared.Extensions.Users; using Sunrise.Shared.Objects; -using Sunrise.Shared.Objects.Keys; using Sunrise.Shared.Objects.Serializable; -using Sunrise.Shared.Objects.Sessions; using SubmissionStatus = Sunrise.Shared.Enums.Scores.SubmissionStatus; -namespace Sunrise.Server.Services.Helpers.Scores; +namespace Sunrise.Processing.Utils; -public static class SubmitScoreHelper +public static class ScoreSubmissionUtil { - private const string MetricsError = "Score {0} by (user id: {1}) rejected with reason: {2}"; private const string AnnounceNewFirstPlaceString = "{0} achieved #1 on {1}"; - - public static string GetNewFirstPlaceString(Session session, Score score, BeatmapSet beatmapSet, Beatmap beatmap) + public static string GetNewFirstPlaceString(Score score, BeatmapSet beatmapSet, Beatmap beatmap) { - var scoreMessage = score.GetBeatmapInGameChatString(beatmapSet, session).Result; + var scoreMessage = score.GetBeatmapInGameChatString(beatmapSet, beatmap); var message = string.Format(AnnounceNewFirstPlaceString, score.User.GetUserInGameChatString(), scoreMessage); return message; } - public static void ReportRejectionToMetrics(Session session, string scoreData, string reason) - { - var message = string.Format(MetricsError, scoreData, session.UserId, reason); - SunriseMetrics.RequestReturnedErrorCounterInc(RequestType.OsuSubmitScore, null, message); - } - public static void UpdateSubmissionStatus(this Score score, Score? prevPBest) { if (IsScoreFailed(score)) @@ -65,30 +55,6 @@ public static void UpdateSubmissionStatus(this Score score, Score? prevPBest) score.SubmissionStatus = SubmissionStatus.Submitted; } - public static bool IsScoreValid(Session session, Score score, string clientHash, - string beatmapHash, string onlineBeatmapHash, string? storyboardHash, string sessionUsername) - { - var computedOnlineHash = score.ComputeOnlineHash(sessionUsername.Trim(), clientHash, storyboardHash); - - var checks = new[] - { - string.Equals(clientHash, session.Attributes.UserHash, StringComparison.Ordinal), - string.Equals(score.ScoreHash, computedOnlineHash, StringComparison.Ordinal), - string.Equals(beatmapHash, - onlineBeatmapHash, - StringComparison - .Ordinal) // Since we got beatmap from client hash, this is not really needed. But just for obscure cases. - }; - - if (checks.All(x => x)) - { - return true; - } - - ReportRejectionToMetrics(session, $"{clientHash}|{session.Attributes.UserHash}|{score.ScoreHash}|{computedOnlineHash}|{beatmapHash}|{onlineBeatmapHash}.storyboard.{storyboardHash}", "Invalid checksums on score submission"); - return false; - } - public static string GetScoreSubmitResponse(Beatmap beatmap, UserStats userStats, UserStats prevUserStats, Score newScore, UserPersonalBestScores? prevUserPersonalBestScores, string? newAchievements = null) @@ -99,7 +65,7 @@ public static string GetScoreSubmitResponse(Beatmap beatmap, UserStats userStats var beatmapInfo = $"beatmapId:{beatmap.Id}|beatmapSetId:{beatmap.BeatmapsetId}|beatmapPlaycount:{beatmap.Playcount}|beatmapPasscount:{beatmap.Passcount}|approvedDate:{beatmap.LastUpdated:yyyy-MM-dd}"; var beatmapRanking = $"chartId:beatmap|chartUrl:{beatmap.Url}|chartName:Beatmap Ranking"; - var scoreInfo = string.Join("|", GetChart(prevUserPersonalBestScores?.BestScoreBasedByTotalScore, prevUserPersonalBestScores?.BestScoreForPerformanceCalculation, newScore, dontShowPp)); + var scoreInfo = string.Join("|", GetChart(prevUserPersonalBestScores?.BestScoreByScoreValue, prevUserPersonalBestScores?.BestScoreForPerformanceCalculation, newScore, dontShowPp)); var playerInfo = $"chartId:overall|chartUrl:{userUrl}|chartName:Overall Ranking|" + string.Join("|", GetChart(prevUserStats, null, userStats)); @@ -107,16 +73,7 @@ public static string GetScoreSubmitResponse(Beatmap beatmap, UserStats userStats $"{beatmapInfo}\n{beatmapRanking}|{scoreInfo}|onlineScoreId:{newScore.Id}\n{playerInfo}|achievements-new:{newAchievements}"; } - public static bool IsHasInvalidMods(Mods mods) - { - return mods.HasFlag(Mods.Target) || - mods.HasFlag(Mods.Random) || - mods.HasFlag(Mods.KeyCoop) || - mods.HasFlag(Mods.Cinema) || - mods.HasFlag(Mods.Autoplay); - } - - public static int GetTimeElapsed(Score score, int scoreTime, int scoreFailTime) + public static int GetTimeElapsed(SubmittedScore score, int scoreTime, int scoreFailTime) { var isPassed = score.IsPassed || score.Mods.HasFlag(Mods.NoFail); return isPassed ? scoreTime : scoreFailTime; diff --git a/Sunrise.Server.Tests/API/UserController/ApiUserCountryChangeTests.cs b/Sunrise.Server.Tests/API/UserController/ApiUserCountryChangeTests.cs index d48e8930..4e04891f 100644 --- a/Sunrise.Server.Tests/API/UserController/ApiUserCountryChangeTests.cs +++ b/Sunrise.Server.Tests/API/UserController/ApiUserCountryChangeTests.cs @@ -1,16 +1,18 @@ -using System.Net; +using System.Net; using System.Net.Http.Json; using System.Text; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; using Sunrise.API.Objects.Keys; using Sunrise.API.Serializable.Request; using Sunrise.Shared.Application; using Sunrise.Shared.Database.Models.Users; using Sunrise.Shared.Enums.Beatmaps; using Sunrise.Shared.Enums.Users; -using Sunrise.Shared.Extensions.Users; using Sunrise.Shared.Objects; using Sunrise.Shared.Objects.Serializable.Events; +using Sunrise.Shared.Services; +using Sunrise.Shared.Utils.Calculators; using Sunrise.Tests.Abstracts; using Sunrise.Tests.Extensions; using Sunrise.Tests.Services.Mock; @@ -73,7 +75,7 @@ public async Task TestRankChangeAfterCountryChange(GameMode mode) var gamemodeUserStats = user.UserStats.First(s => s.GameMode == mode); - await gamemodeUserStats.UpdateWithScore(newScore, null, 0); + gamemodeUserStats.UpdateWithDbScore(newScore); var updateUserStatsResult = await Database.Users.Stats.UpdateUserStats(gamemodeUserStats, user); if (updateUserStatsResult.IsFailure) throw new Exception(updateUserStatsResult.Error); @@ -158,6 +160,9 @@ public async Task TestPromoteOtherUserCountryAfterChange() } }; + var calculatorService = App.Services.GetRequiredService(); + + foreach (var (user, pp) in mockUserScoresData) { await CreateTestUser(user); @@ -171,7 +176,9 @@ public async Task TestPromoteOtherUserCountryAfterChange() var gamemodeUserStats = user.UserStats.First(s => s.GameMode == GameMode.Standard); - await gamemodeUserStats.UpdateWithScore(newScore, null, 0); + gamemodeUserStats.UpdateWithDbScore(newScore); + (gamemodeUserStats.PerformancePoints, gamemodeUserStats.Accuracy) = PerformanceCalculator.CalculateUserWeightedStats([newScore]); + var updateUserStatsResult = await Database.Users.Stats.UpdateUserStats(gamemodeUserStats, user); if (updateUserStatsResult.IsFailure) throw new Exception(updateUserStatsResult.Error); @@ -264,7 +271,7 @@ public async Task TestPromoteOtherUserCountryAfterChangeInMultipleGameModes() var gamemodeUserStats = user.UserStats.First(s => s.GameMode == mode); - await gamemodeUserStats.UpdateWithScore(newScore, null, 0); + gamemodeUserStats.UpdateWithDbScore(newScore); var updateUserStatsResult = await Database.Users.Stats.UpdateUserStats(gamemodeUserStats, user); if (updateUserStatsResult.IsFailure) throw new Exception(updateUserStatsResult.Error); diff --git a/Sunrise.Server.Tests/Extensions/UserStatsExtensionsTests.cs b/Sunrise.Server.Tests/Extensions/UserStatsExtensionsTests.cs deleted file mode 100644 index 61b68331..00000000 --- a/Sunrise.Server.Tests/Extensions/UserStatsExtensionsTests.cs +++ /dev/null @@ -1,334 +0,0 @@ -using osu.Shared; -using Sunrise.Shared.Extensions.Beatmaps; -using Sunrise.Shared.Extensions.Users; -using Sunrise.Shared.Objects; -using Sunrise.Tests.Abstracts; -using Sunrise.Tests.Services.Mock; -using GameMode = Sunrise.Shared.Enums.Beatmaps.GameMode; -using Sunrise.Tests; - -namespace Sunrise.Server.Tests.Extensions; - -[Collection("Integration tests collection")] -public class UserStatsExtensionsDatabaseTests(IntegrationDatabaseFixture fixture) : DatabaseTest(fixture) -{ - private readonly MockService _mocker = new(); - - [Fact] - public async Task TestUpdateWithScoreWithRankedScore() - { - // Arrange - var user = await CreateTestUser(); - - var score = _mocker.Score.GetBestScoreableRandomScore(); - score.LocalProperties.IsRanked = true; - score.PerformancePoints = 100; - - var userStats = await Database.Users.Stats.GetUserStats(user.Id, score.GameMode); - var prevStats = userStats.Clone(); - - // Act - await userStats.UpdateWithScore(score, null, 100); - - // Assert - var shouldIncludeKatuGeki = (GameMode)score.GameMode.ToVanillaGameMode() is GameMode.Taiko or GameMode.Mania; - var expectedTotalHits = prevStats.TotalHits + score.Count300 + score.Count100 + score.Count50 + (shouldIncludeKatuGeki ? score.CountGeki + score.CountKatu : 0); - - Assert.Equal(expectedTotalHits, userStats.TotalHits); - Assert.Equal(prevStats.PlayTime + 100, userStats.PlayTime); - Assert.Equal(prevStats.PlayCount + 1, userStats.PlayCount); - Assert.Equal(prevStats.TotalScore + score.TotalScore, userStats.TotalScore); - Assert.Equal(prevStats.RankedScore + score.TotalScore, userStats.RankedScore); - Assert.Equal(score.MaxCombo, userStats.MaxCombo); - - const double weightedTolerance = 0.5; - Assert.True(Math.Abs(prevStats.PerformancePoints + 100 - userStats.PerformancePoints) < weightedTolerance); - Assert.True(Math.Abs(score.Accuracy - userStats.Accuracy) < weightedTolerance); - } - - [Fact] - public async Task TestUpdateWithScoreWithBetterRankedScore() - { - // Arrange - var user = await CreateTestUser(); - - var score = _mocker.Score.GetBestScoreableRandomScore(); - score.LocalProperties.IsRanked = true; - score.PerformancePoints = 100; - - var oldScore = _mocker.Score.GetBestScoreableRandomScore(); - oldScore.TotalScore = score.TotalScore - 1; - - var userStats = await Database.Users.Stats.GetUserStats(user.Id, score.GameMode); - var prevStats = userStats.Clone(); - - // Act - await userStats.UpdateWithScore(score, new UserPersonalBestScores(oldScore), 100); - - // Assert - var shouldIncludeKatuGeki = (GameMode)score.GameMode.ToVanillaGameMode() is GameMode.Taiko or GameMode.Mania; - var expectedTotalHits = prevStats.TotalHits + score.Count300 + score.Count100 + score.Count50 + (shouldIncludeKatuGeki ? score.CountGeki + score.CountKatu : 0); - - Assert.Equal(expectedTotalHits, userStats.TotalHits); - Assert.Equal(prevStats.PlayTime + 100, userStats.PlayTime); - Assert.Equal(prevStats.PlayCount + 1, userStats.PlayCount); - Assert.Equal(score.TotalScore, userStats.TotalScore); - Assert.Equal(score.TotalScore - oldScore.TotalScore, userStats.RankedScore); - Assert.Equal(score.MaxCombo, userStats.MaxCombo); - - const double weightedTolerance = 0.5; - Assert.True(Math.Abs(prevStats.PerformancePoints + 100 - userStats.PerformancePoints) < weightedTolerance); - Assert.True(Math.Abs(score.Accuracy - userStats.Accuracy) < weightedTolerance); - } - - [Fact] - public async Task TestUpdateWithScoreWithBetterRankedScoreUsingNewPerformanceCalculationAlgorithmUpdateRankedScoreOnly() - { - // Arrange - var user = await CreateTestUser(); - - var score = _mocker.Score.GetBestScoreableRandomScore(); - score.LocalProperties.IsRanked = true; - score.PerformancePoints = 100; - - var oldScore = _mocker.Score.GetBestScoreableRandomScore(); - oldScore.TotalScore = score.TotalScore + 1; - - var userStats = await Database.Users.Stats.GetUserStats(user.Id, score.GameMode); - var prevStats = userStats.Clone(); - - // Act - await userStats.UpdateWithScore(score, new UserPersonalBestScores(oldScore, oldScore), 100); - - // Assert - var shouldIncludeKatuGeki = (GameMode)score.GameMode.ToVanillaGameMode() is GameMode.Taiko or GameMode.Mania; - var expectedTotalHits = prevStats.TotalHits + score.Count300 + score.Count100 + score.Count50 + (shouldIncludeKatuGeki ? score.CountGeki + score.CountKatu : 0); - - Assert.Equal(expectedTotalHits, userStats.TotalHits); - Assert.Equal(prevStats.PlayTime + 100, userStats.PlayTime); - Assert.Equal(prevStats.PlayCount + 1, userStats.PlayCount); - Assert.Equal(score.TotalScore, userStats.TotalScore); - Assert.Equal(0, userStats.RankedScore); // No updates - Assert.Equal(score.MaxCombo, userStats.MaxCombo); - - Assert.Equal(prevStats.PerformancePoints, userStats.PerformancePoints); - Assert.Equal(prevStats.Accuracy, userStats.Accuracy); - } - - [Fact] - public async Task TestUpdateWithScoreWithBetterRankedScoreUsingNewPerformanceCalculationAlgorithmUpdateOnlyPerformancePoints() - { - // Arrange - var user = await CreateTestUser(); - - EnvManager.Set("General:UseNewPerformanceCalculationAlgorithm", "true"); - - var score = _mocker.Score.GetBestScoreableRandomScore(); - score.LocalProperties.IsRanked = true; - score.PerformancePoints = 100; - score.GameMode = GameMode.Standard; - score.Mods = Mods.None; - - var oldScore = _mocker.Score.GetBestScoreableRandomScore(); - oldScore.TotalScore = score.TotalScore + 1; - oldScore.PerformancePoints = score.PerformancePoints - 1; - oldScore.GameMode = score.GameMode; - oldScore.Mods = score.Mods; - - var userStats = await Database.Users.Stats.GetUserStats(user.Id, score.GameMode); - var prevStats = userStats.Clone(); - - // Act - await userStats.UpdateWithScore(score, new UserPersonalBestScores(oldScore, oldScore), 100); - - // Assert - var shouldIncludeKatuGeki = (GameMode)score.GameMode.ToVanillaGameMode() is GameMode.Taiko or GameMode.Mania; - var expectedTotalHits = prevStats.TotalHits + score.Count300 + score.Count100 + score.Count50 + (shouldIncludeKatuGeki ? score.CountGeki + score.CountKatu : 0); - - Assert.Equal(expectedTotalHits, userStats.TotalHits); - Assert.Equal(prevStats.PlayTime + 100, userStats.PlayTime); - Assert.Equal(prevStats.PlayCount + 1, userStats.PlayCount); - Assert.Equal(score.TotalScore, userStats.TotalScore); - Assert.Equal(0, userStats.RankedScore); // No updates - Assert.Equal(score.MaxCombo, userStats.MaxCombo); - - const double weightedTolerance = 0.5; - Assert.True(Math.Abs(prevStats.PerformancePoints + 100 - userStats.PerformancePoints) < weightedTolerance); - Assert.True(Math.Abs(score.Accuracy - userStats.Accuracy) < weightedTolerance); - } - - [Fact] - public async Task TestUpdateWithScoreWithWorseRankedScore() - { - // Arrange - var user = await CreateTestUser(); - - var oldScore = _mocker.Score.GetBestScoreableRandomScore(); - oldScore.LocalProperties.IsRanked = true; - - var score = _mocker.Score.GetBestScoreableRandomScore(); - score.TotalScore = oldScore.TotalScore - 1; - score.PerformancePoints = 100; - - var userStats = await Database.Users.Stats.GetUserStats(user.Id, score.GameMode); - var prevStats = userStats.Clone(); - - // Act - await userStats.UpdateWithScore(score, new UserPersonalBestScores(oldScore), 100); - - // Assert - var shouldIncludeKatuGeki = (GameMode)score.GameMode.ToVanillaGameMode() is GameMode.Taiko or GameMode.Mania; - var expectedTotalHits = prevStats.TotalHits + score.Count300 + score.Count100 + score.Count50 + (shouldIncludeKatuGeki ? score.CountGeki + score.CountKatu : 0); - - Assert.Equal(expectedTotalHits, userStats.TotalHits); - Assert.Equal(prevStats.PlayTime + 100, userStats.PlayTime); - Assert.Equal(prevStats.PlayCount + 1, userStats.PlayCount); - Assert.Equal(score.TotalScore, userStats.TotalScore); - Assert.Equal(0, userStats.RankedScore); - - const double weightedTolerance = 0.5; - Assert.True(Math.Abs(prevStats.PerformancePoints - userStats.PerformancePoints) < weightedTolerance); - Assert.True(Math.Abs(userStats.Accuracy - userStats.Accuracy) < weightedTolerance); - } -} - -public class UserStatsExtensionsTests : BaseTest -{ - private readonly MockService _mocker = new(); - - public static IEnumerable GetGameModes() - { - return Enum.GetValues(typeof(GameMode)).Cast().Select(mode => new object[] - { - mode - }); - } - - [Theory] - [InlineData(false, false)] - [InlineData(true, true)] - [InlineData(false, true)] - public async Task TestUpdateWithScoreWithUnscoreableScore(bool isScoreScoreable, bool isScoreFailed) - { - // Arrange - var score = _mocker.Score.GetBestScoreableRandomScore(); - score.MaxCombo = int.MaxValue; - score.IsScoreable = isScoreScoreable; - score.IsPassed = !isScoreFailed; - score.LocalProperties.FromScore(score); - - var userStats = _mocker.User.GetRandomUserStats(); - userStats.MaxCombo = 0; - userStats.GameMode = score.GameMode; - - var prevStats = userStats.Clone(); - - // Act - await userStats.UpdateWithScore(score, null, 100); - - // Assert - var shouldIncludeKatuGeki = (GameMode)score.GameMode.ToVanillaGameMode() is GameMode.Taiko or GameMode.Mania; - var expectedTotalHits = prevStats.TotalHits + score.Count300 + score.Count100 + score.Count50 + (shouldIncludeKatuGeki ? score.CountGeki + score.CountKatu : 0); - - Assert.Equal(expectedTotalHits, userStats.TotalHits); - Assert.Equal(prevStats.PlayTime + 100, userStats.PlayTime); - Assert.Equal(prevStats.PlayCount + 1, userStats.PlayCount); - Assert.Equal(prevStats.TotalScore + score.TotalScore, userStats.TotalScore); - - var shouldUpdateMaxCombo = isScoreScoreable && !isScoreFailed; - Assert.Equal(shouldUpdateMaxCombo ? score.MaxCombo : prevStats.MaxCombo, userStats.MaxCombo); - - Assert.Equal(prevStats.RankedScore, userStats.RankedScore); - Assert.Equal(prevStats.PerformancePoints, userStats.PerformancePoints); - Assert.Equal(prevStats.Accuracy, userStats.Accuracy); - } - - [Fact] - public async Task TestUpdateWithScoreWithWorseNewScore() - { - // Arrange - var score = _mocker.Score.GetBestScoreableRandomScore(); - score.LocalProperties.IsRanked = true; - score.MaxCombo = int.MaxValue; - score.TotalScore = 0; - - var oldScore = score; - oldScore.TotalScore += 1; - - var userStats = _mocker.User.GetRandomUserStats(); - userStats.MaxCombo = 0; - userStats.GameMode = score.GameMode; - - var prevStats = userStats.Clone(); - - // Act - await userStats.UpdateWithScore(score, new UserPersonalBestScores(oldScore), 100); - - // Assert - var shouldIncludeKatuGeki = (GameMode)score.GameMode.ToVanillaGameMode() is GameMode.Taiko or GameMode.Mania; - var expectedTotalHits = prevStats.TotalHits + score.Count300 + score.Count100 + score.Count50 + (shouldIncludeKatuGeki ? score.CountGeki + score.CountKatu : 0); - - Assert.Equal(expectedTotalHits, userStats.TotalHits); - Assert.Equal(prevStats.PlayTime + 100, userStats.PlayTime); - Assert.Equal(prevStats.PlayCount + 1, userStats.PlayCount); - Assert.Equal(prevStats.TotalScore + score.TotalScore, userStats.TotalScore); - - Assert.Equal(score.MaxCombo, userStats.MaxCombo); - - Assert.Equal(prevStats.RankedScore, userStats.RankedScore); - Assert.Equal(prevStats.PerformancePoints, userStats.PerformancePoints); - Assert.Equal(prevStats.Accuracy, userStats.Accuracy); - } - - /// - /// Happens if we submitted score on a loved beatmap. It is not ranked, but it is scoreable. - /// - [Fact] - public async Task TestUpdateWithScoreShouldUpdateMaxComboIfScoreScoreable() - { - // Arrange - var score = _mocker.Score.GetBestScoreableRandomScore(); - score.LocalProperties.IsRanked = false; - score.MaxCombo = int.MaxValue; - - var userStats = _mocker.User.GetRandomUserStats(); - userStats.GameMode = score.GameMode; - userStats.MaxCombo = 0; - - // Act - await userStats.UpdateWithScore(score, null, 100); - - // Assert - Assert.Equal(score.MaxCombo, userStats.MaxCombo); - } - - [Theory] - [MemberData(nameof(GetGameModes))] - public async Task TestUpdateWithScoreUpdatesTotalHits(GameMode mode) - { - // Arrange - var score = _mocker.Score.GetBestScoreableRandomScore(); - score.GameMode = mode; - score.IsScoreable = false; - - score.Count50 = 1; - score.Count100 = 1; - score.Count300 = 1; - score.CountGeki = 1; - score.CountKatu = 1; - - var userStats = _mocker.User.GetRandomUserStats(); - userStats.GameMode = mode; - - var prevStats = userStats.Clone(); - - // Act - await userStats.UpdateWithScore(score, null, 100); - - // Assert - var shouldIncludeKatuGeki = (GameMode)mode.ToVanillaGameMode() is GameMode.Taiko or GameMode.Mania; - var expectedTotalHits = shouldIncludeKatuGeki ? 5 : 3; - - Assert.Equal(prevStats.TotalHits + expectedTotalHits, userStats.TotalHits); - } -} \ No newline at end of file diff --git a/Sunrise.Server.Tests/Services/ScoreService/ScoreServiceSubmitScoreTests.cs b/Sunrise.Server.Tests/Services/ScoreService/ScoreServiceSubmitScoreTests.cs index 8011d7af..a15e65af 100644 --- a/Sunrise.Server.Tests/Services/ScoreService/ScoreServiceSubmitScoreTests.cs +++ b/Sunrise.Server.Tests/Services/ScoreService/ScoreServiceSubmitScoreTests.cs @@ -1,4 +1,6 @@ +using System.Text.RegularExpressions; using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using osu.Shared; using Sunrise.Server.Commands.ChatCommands.System; @@ -7,15 +9,14 @@ using Sunrise.Shared.Enums; using Sunrise.Shared.Enums.Beatmaps; using Sunrise.Shared.Enums.Leaderboards; +using Sunrise.Shared.Enums.Scores; using Sunrise.Shared.Extensions.Beatmaps; using Sunrise.Shared.Extensions.Scores; -using Sunrise.Shared.Extensions.Users; using Sunrise.Shared.Objects.Serializable; using Sunrise.Shared.Objects.Serializable.Performances; using Sunrise.Tests.Abstracts; using Sunrise.Tests.Extensions; using Sunrise.Tests.Services; -using Sunrise.Server.Controllers; using Sunrise.Tests.Services.Mock; using GameMode = Sunrise.Shared.Enums.Beatmaps.GameMode; using SubmissionStatus = Sunrise.Shared.Enums.Scores.SubmissionStatus; @@ -318,7 +319,7 @@ public async Task TestMultipleUnlockedMedalsPersistedInDatabase() beatmap.DifficultyRating = 5.0; await _mocker.Beatmap.MockBeatmapSet(beatmapSet); - App.MockHttpClient?.MockPerformanceCalculation(500, 5.0); + App.MockHttpClient?.MockPerformanceCalculation(); // Act var resultString = await scoreService.SubmitScore( @@ -336,7 +337,7 @@ public async Task TestMultipleUnlockedMedalsPersistedInDatabase() // Assert Assert.DoesNotContain("error", resultString); - var achievementsMatch = System.Text.RegularExpressions.Regex.Match(resultString, @"achievements-new:(.*)"); + var achievementsMatch = Regex.Match(resultString, @"achievements-new:(.*)"); Assert.True(achievementsMatch.Success, "Response should contain achievements-new section"); var achievementsRaw = achievementsMatch.Groups[1].Value.Trim(); @@ -780,6 +781,7 @@ public async Task TestUpdateUserStatsForNonScoreableScoreUponSubmitScore() var beatmapSet = _mocker.Beatmap.GetRandomBeatmapSet(); var beatmap = beatmapSet.Beatmaps.First() ?? throw new Exception("Beatmap is null"); beatmap.EnrichWithScoreData(score); + beatmap.StatusString = BeatmapStatusWeb.Pending.BeatmapStatusWebToString(); await _mocker.Beatmap.MockBeatmapSet(beatmapSet); App.MockHttpClient?.MockPerformanceCalculation(); @@ -861,6 +863,44 @@ public async Task TestUserRestrictInvalidChecksumUponSubmitScore() Assert.Contains("Invalid checksums on score submission", restrictionReason); } + [Fact] + public async Task TestMissingReplayDoesNotRestrictUser() + { + // Arrange + var scoreService = Scope.ServiceProvider.GetRequiredService(); + + var (session, user) = await CreateTestSession(); + + var score = _mocker.Score.GetBestScoreableRandomScore(); + score.EnrichWithSessionData(session); + + var beatmapSet = _mocker.Beatmap.GetRandomBeatmapSet(); + var beatmap = beatmapSet.Beatmaps.First() ?? throw new Exception("Beatmap is null"); + beatmap.EnrichWithScoreData(score); + + await _mocker.Beatmap.MockBeatmapSet(beatmapSet); + App.MockHttpClient?.MockPerformanceCalculation(); + + // Act + var resultString = await scoreService.SubmitScore( + session, + score.ToScoreString(user.Username), + score.BeatmapHash, + _mocker.GetRandomInteger(), + _mocker.GetRandomInteger(), + _mocker.GetRandomString(), + session.Attributes.UserHash, + null, + null + ); + + // Assert + Assert.Contains("error", resultString); + + var isRestricted = await Database.Users.Moderation.IsUserRestricted(session.UserId); + Assert.False(isRestricted); + } + [Fact] public async Task TestUponSubmittingBetterScoreThanPreviousOneUpdateSubmissionStatus() { @@ -1339,6 +1379,8 @@ public async Task TestUponSubmittingBetterScoreThanPreviousOneUpdateUserGrades() oldScore.Grade = "A"; oldScore.SubmissionStatus = SubmissionStatus.Best; oldScore.PerformancePoints = -1; + oldScore.Mods = Mods.None; + oldScore.GameMode = GameMode.Standard; oldScore.EnrichWithSessionData(session); @@ -1402,6 +1444,80 @@ public async Task TestUponSubmittingBetterScoreThanPreviousOneUpdateUserGrades() Assert.Equivalent(userGrades, updatedUserGrades); } + [Fact] + public async Task TestUponSubmittingBestScoreInModsButWorseThanBestOverallDontUpdateUserGrades() + { + // Arrange + var scoreService = Scope.ServiceProvider.GetRequiredService(); + + var (session, user) = await CreateTestSession(); + + var oldScore = _mocker.Score.GetBestScoreableRandomScore(); + oldScore.Grade = "A"; + oldScore.SubmissionStatus = SubmissionStatus.Best; + oldScore.PerformancePoints = -1; + oldScore.Mods = Mods.Hidden; + oldScore.GameMode = GameMode.Standard; + + oldScore.EnrichWithSessionData(session); + + + var userGrades = await Database.Users.Grades.GetUserGrades(oldScore.UserId, oldScore.GameMode); + if (userGrades == null) + throw new Exception("UserGrades is null"); + + userGrades = _mocker.User.SetRandomUserGrades(userGrades); + userGrades.CountA++; + + var arrangeUserGradesResult = await Database.Users.Grades.UpdateUserGrades(userGrades); + + if (arrangeUserGradesResult.IsFailure) + throw new Exception(arrangeUserGradesResult.Error); + + var score = _mocker.Score.GetBestScoreableRandomScore(); + score.GameMode = oldScore.GameMode; + score.Mods = Mods.DoubleTime; + score.BeatmapId = oldScore.BeatmapId; + score.BeatmapHash = oldScore.BeatmapHash; + score.Grade = "B"; + + score.TotalScore = oldScore.TotalScore - 1; + + score.EnrichWithSessionData(session); + + var beatmapSet = _mocker.Beatmap.GetRandomBeatmapSet(); + var beatmap = beatmapSet.Beatmaps.First() ?? throw new Exception("Beatmap is null"); + beatmap.EnrichWithScoreData(score); + + await _mocker.Beatmap.MockBeatmapSet(beatmapSet); + App.MockHttpClient?.MockPerformanceCalculation(); + + await Database.Scores.AddScore(oldScore); + + // Act + var resultString = await scoreService.SubmitScore( + session, + score.ToScoreString(user.Username), + score.BeatmapHash, + _mocker.GetRandomInteger(), + _mocker.GetRandomInteger(), + _mocker.GetRandomString(), + session.Attributes.UserHash, + _replayService.GenerateReplayFormFile(), + null + ); + + // Assert + Assert.DoesNotContain("error", resultString); + + var updatedUserGrades = await Database.Users.Grades.GetUserGrades(session.UserId, oldScore.GameMode); + + Assert.NotNull(updatedUserGrades); + userGrades.User = null!; // Ignore for comparison + + Assert.Equivalent(userGrades, updatedUserGrades); + } + [Fact] public async Task TestUponSubmittingEqualScoreThanPreviousOneUpdateSubmissionStatus() { @@ -1414,6 +1530,8 @@ public async Task TestUponSubmittingEqualScoreThanPreviousOneUpdateSubmissionSta oldScore.SubmissionStatus = SubmissionStatus.Best; oldScore.EnrichWithSessionData(session); + oldScore.WhenPlayed = DateTime.UtcNow.AddMinutes(-5); + oldScore.ClientTime = oldScore.WhenPlayed; var score = _mocker.Score.GetBestScoreableRandomScore(); score.GameMode = oldScore.GameMode; @@ -1684,7 +1802,7 @@ public async Task TestUserRankingWhenUserAHasSubmittedScoreWith100PpAndUserBSubm if (userStatsA == null) throw new Exception("User stats are null"); - await userStatsA.UpdateWithScore(scoreA, null, 0); + userStatsA.UpdateWithDbScore(scoreA); await Database.Users.Stats.UpdateUserStats(userStatsA, userA); // Create User B and submit score with 100pp @@ -1773,7 +1891,7 @@ public async Task TestUserRankingWhenBothUsersHaveSamePpAndUserBSubmitsHigherPpM if (userStatsA == null) throw new Exception("User stats are null"); - await userStatsA.UpdateWithScore(scoreA, null, 1); + userStatsA.UpdateWithDbScore(scoreA); await Database.Users.Stats.UpdateUserStats(userStatsA, userA); // Create User B with 100pp @@ -1801,7 +1919,7 @@ public async Task TestUserRankingWhenBothUsersHaveSamePpAndUserBSubmitsHigherPpM if (userStatsB == null) throw new Exception("User stats are null"); - await userStatsB.UpdateWithScore(scoreB1, null, 0); + userStatsB.UpdateWithDbScore(scoreB1); await Database.Users.Stats.UpdateUserStats(userStatsB, userB); // Verify initial ranks: User A should be rank 1, User B should be rank 2 @@ -1910,7 +2028,7 @@ public async Task TestMedalNotAwardedWithDifficultyReducingMods(bool hasNonEligi } [Fact] - public async Task TestSuccessfulSubmitScoreWithBeatmapSetRetrievalFallback() + public async Task TestScoreQueuedWhenBeatmapRetrievalFails() { // Arrange var scoreService = Scope.ServiceProvider.GetRequiredService(); @@ -1956,16 +2074,20 @@ public async Task TestSuccessfulSubmitScoreWithBeatmapSetRetrievalFallback() ); // Assert - Assert.DoesNotContain("error", resultString); + Assert.Equal("error: no", resultString); - var databaseScore = await Database.Scores.GetScore(score.ScoreHash); - Assert.NotNull(databaseScore); + var queueEntry = await Database.DbContext.ScoreProcessingTasks + .OrderByDescending(x => x.CreatedAt) + .FirstOrDefaultAsync(); - Assert.Equal(SubmissionStatus.Best, databaseScore.SubmissionStatus); + Assert.NotNull(queueEntry); + Assert.Equal(ScoreProcessingStatus.Pending, queueEntry!.Status); + Assert.Equal(ScoreTaskType.Submission, queueEntry.TaskType); + Assert.NotNull(queueEntry.ScoreSubmissionRequestId); } [Fact] - public async Task TestSuccessfulSubmitScoreWithPerformanceCalculationFallback() + public async Task TestScoreQueuedWhenPerformanceCalculationFails() { // Arrange var scoreService = Scope.ServiceProvider.GetRequiredService(); @@ -2032,74 +2154,15 @@ public async Task TestSuccessfulSubmitScoreWithPerformanceCalculationFallback() ); // Assert - Assert.DoesNotContain("error", resultString); - - var databaseScore = await Database.Scores.GetScore(score.ScoreHash); - Assert.NotNull(databaseScore); + Assert.Equal("error: no", resultString); - Assert.Equal(SubmissionStatus.Best, databaseScore.SubmissionStatus); - - Assert.Equal(500, databaseScore.PerformancePoints); - } + var queueEntry = await Database.DbContext.ScoreProcessingTasks + .OrderByDescending(x => x.CreatedAt) + .FirstOrDefaultAsync(); - [Fact] - public async Task TestDuplicateScoreSubmissionIsRejectedWhileOriginalIsProcessed() - { - // Arrange - var scoreService = Scope.ServiceProvider.GetRequiredService(); - - var secondScope = App.Server.Services.CreateScope(); - var secondScoreService = secondScope.ServiceProvider.GetRequiredService(); - - var (session, user) = await CreateTestSession(); - - var (replay, beatmapId) = GetValidTestReplay(); - - var score = replay.GetScore(); - score.BeatmapId = beatmapId; - - score.EnrichWithSessionData(session); - - var beatmapSet = _mocker.Beatmap.GetRandomBeatmapSet(); - var beatmap = beatmapSet.Beatmaps.First() ?? throw new Exception("Beatmap is null"); - beatmap.EnrichWithScoreData(score); - - await _mocker.Beatmap.MockBeatmapSet(beatmapSet); - App.MockHttpClient?.MockPerformanceCalculation(); - - // Act - var results = await Task.WhenAll(scoreService.SubmitScore( - session, - score.ToScoreString(user.Username), - score.BeatmapHash, - _mocker.GetRandomInteger(), - _mocker.GetRandomInteger(), - _mocker.GetRandomString(), - session.Attributes.UserHash, - _replayService.GenerateReplayFormFile(), - null - ), - secondScoreService.SubmitScore( - session, - score.ToScoreString(user.Username), - score.BeatmapHash, - _mocker.GetRandomInteger(), - _mocker.GetRandomInteger(), - _mocker.GetRandomString(), - session.Attributes.UserHash, - _replayService.GenerateReplayFormFile(), - null - )); - - // Assert - var processedCount = results.Count(r => !r.Contains("error")); - var errorCount = results.Count(r => r.Contains("error")); - Assert.Equal(1, processedCount); - Assert.Equal(1, errorCount); - - var databaseScore = await Database.Scores.GetScore(score.ScoreHash); - Assert.NotNull(databaseScore); - - Assert.Equal(SubmissionStatus.Best, databaseScore.SubmissionStatus); + Assert.NotNull(queueEntry); + Assert.Equal(ScoreProcessingStatus.Pending, queueEntry!.Status); + Assert.Equal(ScoreTaskType.Submission, queueEntry.TaskType); + Assert.NotNull(queueEntry.ScoreSubmissionRequestId); } } \ No newline at end of file diff --git a/Sunrise.Server.Tests/Utils/Calculators/PerformanceCalculator/CalculateAccuracyTests.cs b/Sunrise.Server.Tests/Utils/Calculators/PerformanceCalculator/CalculateAccuracyTests.cs index 8733dadc..bb15601a 100644 --- a/Sunrise.Server.Tests/Utils/Calculators/PerformanceCalculator/CalculateAccuracyTests.cs +++ b/Sunrise.Server.Tests/Utils/Calculators/PerformanceCalculator/CalculateAccuracyTests.cs @@ -1,5 +1,6 @@ using Sunrise.Shared.Database.Models; using Sunrise.Shared.Enums.Beatmaps; +using Sunrise.Shared.Objects; using Sunrise.Tests.Abstracts; using Mods = osu.Shared.Mods; using PerformanceCalculatorClass = Sunrise.Shared.Utils.Calculators.PerformanceCalculator; @@ -142,4 +143,216 @@ public void CalculateScoreAccuracyForOsuManiaV2Test() // Assert Assert.Equal(87.16, Math.Round(scoreAccuracy, 2)); } + + [Fact] + public void CalculateSubmittedScoreAccuracyForOsuStandardTest() + { + // Arrange + var score = new SubmittedScore + { + Count300 = 317, + CountGeki = 69, + Count100 = 38, + CountKatu = 22, + Count50 = 2, + CountMiss = 1, + GameMode = GameMode.Standard, + PlayerUsername = null, + ScoreHash = null, + BeatmapHash = null, + TotalScore = 0, + MaxCombo = 0, + Perfect = false, + Mods = Mods.None, + Grade = null, + IsPassed = false, + WhenPlayed = default, + OsuVersion = null, + ClientTime = default, + Accuracy = 0 + }; + + // Act + var scoreAccuracy = PerformanceCalculatorClass.CalculateAccuracy(score); + + // Assert + Assert.Equal(92.18, Math.Round(scoreAccuracy, 2)); + } + + [Fact] + public void CalculateSubmittedScoreAccuracyForEmptyScoreTest() + { + // Arrange + var score = new SubmittedScore + { + Count300 = 0, + CountGeki = 0, + Count100 = 0, + CountKatu = 0, + Count50 = 0, + CountMiss = 0, + GameMode = GameMode.Standard, + PlayerUsername = null, + ScoreHash = null, + BeatmapHash = null, + TotalScore = 0, + MaxCombo = 0, + Perfect = false, + Mods = Mods.None, + Grade = null, + IsPassed = false, + WhenPlayed = default, + OsuVersion = null, + ClientTime = default, + Accuracy = 0 + }; + + // Act + var scoreAccuracy = PerformanceCalculatorClass.CalculateAccuracy(score); + + // Assert + Assert.Equal(0, Math.Round(scoreAccuracy, 2)); + } + + + + [Fact] + public void CalculateSubmittedScoreAccuracyForOsuTaikoTest() + { + // Arrange + var score = new SubmittedScore + { + Count300 = 97, + CountGeki = 0, + Count100 = 16, + CountKatu = 0, + Count50 = 0, + CountMiss = 2, + GameMode = GameMode.Taiko, + PlayerUsername = null, + ScoreHash = null, + BeatmapHash = null, + TotalScore = 0, + MaxCombo = 0, + Perfect = false, + Mods = Mods.None, + Grade = null, + IsPassed = false, + WhenPlayed = default, + OsuVersion = null, + ClientTime = default, + Accuracy = 0 + }; + + // Act + var scoreAccuracy = PerformanceCalculatorClass.CalculateAccuracy(score); + + // Assert + Assert.Equal(91.30, Math.Round(scoreAccuracy, 2)); + } + + [Fact] + public void CalculateSubmittedScoreAccuracyForOsuCatchTheBeatTest() + { + // Arrange + var score = new SubmittedScore + { + Count300 = 84, + CountGeki = 11, + Count100 = 0, + CountKatu = 1, + Count50 = 155, + CountMiss = 0, + GameMode = GameMode.CatchTheBeat, + PlayerUsername = null, + ScoreHash = null, + BeatmapHash = null, + TotalScore = 0, + MaxCombo = 0, + Perfect = false, + Mods = Mods.None, + Grade = null, + IsPassed = false, + WhenPlayed = default, + OsuVersion = null, + ClientTime = default, + Accuracy = 0 + }; + + // Act + var scoreAccuracy = PerformanceCalculatorClass.CalculateAccuracy(score); + + // Assert + Assert.Equal(99.58, Math.Round(scoreAccuracy, 2)); + } + + [Fact] + public void CalculateSubmittedScoreAccuracyForOsuManiaTest() + { + // Arrange + var score = new SubmittedScore + { + Count300 = 2335, + CountGeki = 8321, + Count100 = 19, + CountKatu = 115, + Count50 = 11, + CountMiss = 27, + GameMode = GameMode.Mania, + PlayerUsername = null, + ScoreHash = null, + BeatmapHash = null, + TotalScore = 0, + MaxCombo = 0, + Perfect = false, + Mods = Mods.None, + Grade = null, + IsPassed = false, + WhenPlayed = default, + OsuVersion = null, + ClientTime = default, + Accuracy = 0 + }; + + // Act + var scoreAccuracy = PerformanceCalculatorClass.CalculateAccuracy(score); + + // Assert + Assert.Equal(99.19, Math.Round(scoreAccuracy, 2)); + } + + [Fact] + public void CalculateSubmittedScoreAccuracyForOsuManiaV2Test() + { + // Arrange + var score = new SubmittedScore + { + Count300 = 407, + CountGeki = 469, + Count100 = 61, + CountKatu = 203, + Count50 = 5, + CountMiss = 29, + GameMode = GameMode.Mania, + Mods = Mods.ScoreV2, + PlayerUsername = null, + ScoreHash = null, + BeatmapHash = null, + TotalScore = 0, + MaxCombo = 0, + Perfect = false, + Grade = null, + IsPassed = false, + WhenPlayed = default, + OsuVersion = null, + ClientTime = default, + Accuracy = 0 + }; + + // Act + var scoreAccuracy = PerformanceCalculatorClass.CalculateAccuracy(score); + + // Assert + Assert.Equal(87.16, Math.Round(scoreAccuracy, 2)); + } } \ No newline at end of file diff --git a/Sunrise.Server/Bootstrap.cs b/Sunrise.Server/Bootstrap.cs index 8cb098ff..cfa55de0 100644 --- a/Sunrise.Server/Bootstrap.cs +++ b/Sunrise.Server/Bootstrap.cs @@ -31,6 +31,11 @@ using StackExchange.Redis; using Sunrise.API.Controllers; using Sunrise.API.Serializable.Response; +using Sunrise.Processing.Scores.Handlers; +using Sunrise.Processing.Scores.Jobs; +using Sunrise.Processing.Scores.Pipeline; +using Sunrise.Processing.Scores.Processors; +using Sunrise.Processing.Services; using Sunrise.Server.Middlewares; using Sunrise.Server.Repositories; using Sunrise.Server.Services; @@ -42,6 +47,7 @@ using Sunrise.Shared.Database.Services.Beatmaps; using Sunrise.Shared.Database.Services.Events; using Sunrise.Shared.Database.Services.Users; +using Sunrise.Shared.Enums.Scores; using Sunrise.Shared.Enums.Users; using Sunrise.Shared.Extensions; using Sunrise.Shared.Repositories; @@ -126,7 +132,7 @@ public static void AddCustomLogging(this WebApplicationBuilder builder) public static void AddTelemetry(this WebApplicationBuilder builder) { - if (string.IsNullOrEmpty(Configuration.TempoUri) && Configuration.UseMetrics == false) + if (string.IsNullOrEmpty(Configuration.TempoUri) && !Configuration.UseMetrics) return; var openTelemetryBuilder = builder.Services @@ -437,6 +443,8 @@ public static void AddDatabaseServices(this WebApplicationBuilder builder) builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -446,7 +454,6 @@ public static void AddDatabaseServices(this WebApplicationBuilder builder) public static void AddServices(this WebApplicationBuilder builder) { builder.Services.AddScoped(); - builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -454,10 +461,20 @@ public static void AddServices(this WebApplicationBuilder builder) builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddKeyedScoped(ScoreTaskType.Submission); + builder.Services.AddKeyedScoped(ScoreTaskType.Recalculation); + builder.Services.AddKeyedScoped(ScoreTaskType.Delete); + builder.Services.AddKeyedScoped(ScoreTaskType.Restore); + builder.Services.AddScoped(); builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/Sunrise.Server/Commands/ChatCommands/System/CancelScoreTaskCommand.cs b/Sunrise.Server/Commands/ChatCommands/System/CancelScoreTaskCommand.cs new file mode 100644 index 00000000..bf35f955 --- /dev/null +++ b/Sunrise.Server/Commands/ChatCommands/System/CancelScoreTaskCommand.cs @@ -0,0 +1,35 @@ +using Sunrise.Server.Attributes; +using Sunrise.Server.Repositories; +using Sunrise.Shared.Application; +using Sunrise.Shared.Database; +using Sunrise.Shared.Enums.Users; +using Sunrise.Shared.Objects; +using Sunrise.Shared.Objects.Sessions; + +namespace Sunrise.Server.Commands.ChatCommands.System; + +[ChatCommand("cancelscoretask", requiredPrivileges: UserPrivilege.SuperUser)] +public class CancelScoreTaskCommand : IChatCommand +{ + public async Task Handle(Session session, ChatChannel? channel, string[]? args) + { + if (args == null || args.Length < 1 || !int.TryParse(args[0], out var taskId)) + { + ChatCommandRepository.SendMessage(session, + $"Usage: {Configuration.BotPrefix}cancelscoretask "); + return; + } + + using var scope = ServicesProviderHolder.CreateScope(); + var database = scope.ServiceProvider.GetRequiredService(); + + var cancelResult = await database.ScoreProcessingTasks.CancelTask(taskId); + if (cancelResult.IsFailure) + { + ChatCommandRepository.SendMessage(session, cancelResult.Error); + return; + } + + ChatCommandRepository.SendMessage(session, $"Score task {taskId} was cancelled."); + } +} diff --git a/Sunrise.Server/Commands/ChatCommands/System/DeleteScoreCommand.cs b/Sunrise.Server/Commands/ChatCommands/System/DeleteScoreCommand.cs new file mode 100644 index 00000000..bc12ecd5 --- /dev/null +++ b/Sunrise.Server/Commands/ChatCommands/System/DeleteScoreCommand.cs @@ -0,0 +1,69 @@ +using Hangfire; +using Sunrise.Server.Attributes; +using Sunrise.Server.Repositories; +using Sunrise.Shared.Application; +using Sunrise.Shared.Database; +using Sunrise.Shared.Database.Models.Scores; +using Sunrise.Shared.Enums.Scores; +using Sunrise.Shared.Enums.Users; +using Sunrise.Shared.Objects; +using Sunrise.Shared.Objects.Sessions; +using Sunrise.Shared.Services; + +namespace Sunrise.Server.Commands.ChatCommands.System; + +[ChatCommand("deletescore", requiredPrivileges: UserPrivilege.SuperUser)] +public class DeleteScoreCommand : IChatCommand +{ + public Task Handle(Session session, ChatChannel? channel, string[]? args) + { + if (args == null || args.Length < 1 || !int.TryParse(args[0], out var scoreId)) + { + ChatCommandRepository.SendMessage(session, + $"Usage: {Configuration.BotPrefix}deletescore "); + return Task.CompletedTask; + } + + BackgroundTaskService.TryStartNewBackgroundJob( + () => DeleteScore(session.UserId, scoreId, CancellationToken.None), + message => ChatCommandRepository.TrySendMessage(session.UserId, message)); + + return Task.CompletedTask; + } + + [AutomaticRetry(Attempts = 0)] + public async Task DeleteScore(int userId, int scoreId, CancellationToken ct) + { + await BackgroundTaskService.ExecuteBackgroundTask( + async () => + { + using var scope = ServicesProviderHolder.CreateScope(); + var database = scope.ServiceProvider.GetRequiredService(); + var score = await database.Scores.GetScore(scoreId, filterValidScores: false, ct: ct); + + if (score == null) + { + ChatCommandRepository.TrySendMessage(userId, $"Score {scoreId} was not found."); + return; + } + + var queued = await database.ScoreProcessingTasks.TryAddQueueEntry(new ScoreProcessingTask + { + TaskType = ScoreTaskType.Delete, + ScoreId = score.Id, + Priority = (int)ScoreProcessingPriority.Normal, + CreatedAt = DateTime.UtcNow + }, + ct); + + if (!queued) + { + ChatCommandRepository.TrySendMessage(userId, $"Score {scoreId} already has an active queued task."); + return; + } + + ChatCommandRepository.TrySendMessage(userId, $"Score {scoreId} was queued for deletion."); + }, + message => ChatCommandRepository.TrySendMessage(userId, message)); + } +} \ No newline at end of file diff --git a/Sunrise.Server/Commands/ChatCommands/System/RecalculateScoreCommand.cs b/Sunrise.Server/Commands/ChatCommands/System/RecalculateScoreCommand.cs new file mode 100644 index 00000000..9e78bdcf --- /dev/null +++ b/Sunrise.Server/Commands/ChatCommands/System/RecalculateScoreCommand.cs @@ -0,0 +1,69 @@ +using Hangfire; +using Sunrise.Server.Attributes; +using Sunrise.Server.Repositories; +using Sunrise.Shared.Application; +using Sunrise.Shared.Database; +using Sunrise.Shared.Database.Models.Scores; +using Sunrise.Shared.Enums.Scores; +using Sunrise.Shared.Enums.Users; +using Sunrise.Shared.Objects; +using Sunrise.Shared.Objects.Sessions; +using Sunrise.Shared.Services; + +namespace Sunrise.Server.Commands.ChatCommands.System; + +[ChatCommand("recalculatescore", requiredPrivileges: UserPrivilege.SuperUser)] +public class RecalculateScoreCommand : IChatCommand +{ + public Task Handle(Session session, ChatChannel? channel, string[]? args) + { + if (args == null || args.Length < 1 || !int.TryParse(args[0], out var scoreId)) + { + ChatCommandRepository.SendMessage(session, + $"Usage: {Configuration.BotPrefix}reprocessscore "); + return Task.CompletedTask; + } + + BackgroundTaskService.TryStartNewBackgroundJob( + () => ReprocessScore(session.UserId, scoreId, CancellationToken.None), + message => ChatCommandRepository.TrySendMessage(session.UserId, message)); + + return Task.CompletedTask; + } + + [AutomaticRetry(Attempts = 0)] + public async Task ReprocessScore(int userId, int scoreId, CancellationToken ct) + { + await BackgroundTaskService.ExecuteBackgroundTask( + async () => + { + using var scope = ServicesProviderHolder.CreateScope(); + var database = scope.ServiceProvider.GetRequiredService(); + var score = await database.Scores.GetScore(scoreId, filterValidScores: false, ct: ct); + + if (score == null) + { + ChatCommandRepository.TrySendMessage(userId, $"Score {scoreId} was not found."); + return; + } + + var queued = await database.ScoreProcessingTasks.TryAddQueueEntry(new ScoreProcessingTask + { + TaskType = ScoreTaskType.Recalculation, + ScoreId = score.Id, + Priority = (int)ScoreProcessingPriority.Normal, + CreatedAt = DateTime.UtcNow + }, + ct); + + if (!queued) + { + ChatCommandRepository.TrySendMessage(userId, $"Score {scoreId} already has an active queued task."); + return; + } + + ChatCommandRepository.TrySendMessage(userId, $"Score {scoreId} was queued for recalculation."); + }, + message => ChatCommandRepository.TrySendMessage(userId, message)); + } +} \ No newline at end of file diff --git a/Sunrise.Server/Commands/ChatCommands/System/RecalculateScoresCommand.cs b/Sunrise.Server/Commands/ChatCommands/System/RecalculateScoresCommand.cs index 539ef253..1faaa147 100644 --- a/Sunrise.Server/Commands/ChatCommands/System/RecalculateScoresCommand.cs +++ b/Sunrise.Server/Commands/ChatCommands/System/RecalculateScoresCommand.cs @@ -1,16 +1,16 @@ -using System.Net; using Hangfire; using Sunrise.Server.Attributes; using Sunrise.Server.Repositories; using Sunrise.Shared.Application; using Sunrise.Shared.Database; +using Sunrise.Shared.Database.Models.Scores; using Sunrise.Shared.Database.Objects; using Sunrise.Shared.Enums.Beatmaps; +using Sunrise.Shared.Enums.Scores; using Sunrise.Shared.Enums.Users; using Sunrise.Shared.Objects; using Sunrise.Shared.Objects.Sessions; using Sunrise.Shared.Services; -using Sunrise.Shared.Utils.Calculators; namespace Sunrise.Server.Commands.ChatCommands.System; @@ -63,18 +63,12 @@ await BackgroundTaskService.ExecuteBackgroundTask( var pageSize = 100; var scoresReviewedTotal = 0; + var scoresSkippedTotal = 0; for (var x = 1;; x++) { using var scope = ServicesProviderHolder.CreateScope(); var database = scope.ServiceProvider.GetRequiredService(); - var calculatorService = scope.ServiceProvider.GetRequiredService(); - - var user = await database.Users.GetUser(userId); - if (user == null) - return; - - var session = BaseSession.GenerateServerSession(); var (pageScores, _) = await database.Scores.GetScores(mode, new QueryOptions(new Pagination(x, pageSize)) @@ -85,84 +79,31 @@ await BackgroundTaskService.ExecuteBackgroundTask( foreach (var score in pageScores) { - var oldPerformancePoints = score.PerformancePoints; - var oldAccuracy = score.Accuracy; - - score.Accuracy = PerformanceCalculator.CalculateAccuracy(score); - - var retryCount = 0; - - while (retryCount < 10) + var queued = await database.ScoreProcessingTasks.TryAddQueueEntry(new ScoreProcessingTask { - var scorePerformanceResult = await calculatorService.CalculateScorePerformance(session, score); - - if (scorePerformanceResult.IsFailure) - { - if (scorePerformanceResult.Error.Status == HttpStatusCode.NotFound) - { - var result = await database.Scores.MarkScoreAsDeleted(score); - - if (result.IsFailure) - { - ChatCommandRepository.TrySendMessage(userId, $"Failed to update score {score.Id} as DELETED, error: {result.Error}"); - throw new Exception($"Failed to update score {score.Id}, error: {result.Error} "); - } - - ChatCommandRepository.TrySendMessage(userId, $"Updated score id {score.Id} in gamemode {score.GameMode} to be marked as DELETED, since we couldn't find beatmap it was played on"); - break; - } - } - - if (scorePerformanceResult.IsSuccess) - { - score.PerformancePoints = scorePerformanceResult.Value.PerformancePoints; - - ct.ThrowIfCancellationRequested(); - - var result = await database.Scores.UpdateScore(score); + TaskType = ScoreTaskType.Recalculation, + ScoreId = score.Id, + Priority = (int)ScoreProcessingPriority.Low, + CreatedAt = DateTime.UtcNow + }, ct); - if (result.IsFailure) - { - ChatCommandRepository.TrySendMessage(userId, $"Failed to update score {score.Id}, error: {result.Error}"); - throw new Exception($"Failed to update score {score.Id}, error: {result.Error} "); - } + ct.ThrowIfCancellationRequested(); + scoresReviewedTotal++; - scoresReviewedTotal++; - - if (scoresReviewedTotal % 100 == 0) - { - ChatCommandRepository.TrySendMessage(userId, $"Scores reviewed in total: {scoresReviewedTotal}"); - } - - const float tolerance = 0.0001f; - - if (Math.Abs(oldAccuracy - score.Accuracy) > tolerance) - ChatCommandRepository.TrySendMessage(userId, $"Updated score id {score.Id} in gamemode {score.GameMode} acc value from {oldAccuracy} to {score.Accuracy}"); - - if (Math.Abs(oldPerformancePoints - score.PerformancePoints) > tolerance) - ChatCommandRepository.TrySendMessage(userId, $"Updated score id {score.Id} in gamemode {score.GameMode} pp value from {oldPerformancePoints} to {score.PerformancePoints}"); - - break; - } - - retryCount++; - - if (retryCount >= 10) - { - ChatCommandRepository.TrySendMessage(userId, $"Failed to update {score.Id} after 10 retries: {scorePerformanceResult.Error}"); - ChatCommandRepository.TrySendMessage(userId, "Stopping the recalculation process... Please try again later."); - Configuration.OnMaintenance = false; - ChatCommandRepository.TrySendMessage(userId, "Recalculation is paused. Server is back online."); - throw new Exception($"Failed to update {score.Id} after 10 retries: {scorePerformanceResult.Error}"); - } - - ChatCommandRepository.TrySendMessage(userId, $"Retrying update for score {score.Id} (Attempt {retryCount}/10)..."); - await Task.Delay(10_000, ct); + if (!queued) + { + scoresSkippedTotal++; + continue; } + + if (scoresReviewedTotal % 100 == 0) + ChatCommandRepository.TrySendMessage(userId, $"Scores reviewed: {scoresReviewedTotal}. Queued: {scoresReviewedTotal - scoresSkippedTotal}. Skipped active: {scoresSkippedTotal}"); } if (pageScores.Count < pageSize) break; } + + ChatCommandRepository.TrySendMessage(userId, $"Recalculation finished. Reviewed: {scoresReviewedTotal}. Queued: {scoresReviewedTotal - scoresSkippedTotal}. Skipped active: {scoresSkippedTotal}"); }, message => ChatCommandRepository.TrySendMessage(userId, message)); } diff --git a/Sunrise.Server/Commands/ChatCommands/System/RecalculateUserGradesCommand.cs b/Sunrise.Server/Commands/ChatCommands/System/RecalculateUserGradesCommand.cs index 77a206b7..2dafab03 100644 --- a/Sunrise.Server/Commands/ChatCommands/System/RecalculateUserGradesCommand.cs +++ b/Sunrise.Server/Commands/ChatCommands/System/RecalculateUserGradesCommand.cs @@ -14,6 +14,7 @@ namespace Sunrise.Server.Commands.ChatCommands.System; +[Obsolete("While this is very optimised for recalcualtion, I will prefer to deprecate this in favour of a single all score recalcualtions for consistency and to reduce the amount of code that needs to be maintained. This will be removed in a future update.")] [ChatCommand("recalculateusergrades", requiredPrivileges: UserPrivilege.SuperUser)] public class RecalculateUserGradesCommand : IChatCommand { diff --git a/Sunrise.Server/Commands/ChatCommands/System/RecalculateUserStatsCommand.cs b/Sunrise.Server/Commands/ChatCommands/System/RecalculateUserStatsCommand.cs index 22943dfe..1f589002 100644 --- a/Sunrise.Server/Commands/ChatCommands/System/RecalculateUserStatsCommand.cs +++ b/Sunrise.Server/Commands/ChatCommands/System/RecalculateUserStatsCommand.cs @@ -1,20 +1,24 @@ using Microsoft.EntityFrameworkCore; +using osu.Shared; using Sunrise.Server.Attributes; using Sunrise.Server.Repositories; using Sunrise.Shared.Application; using Sunrise.Shared.Database; +using Sunrise.Shared.Database.Models; using Sunrise.Shared.Database.Models.Users; using Sunrise.Shared.Database.Objects; using Sunrise.Shared.Enums.Leaderboards; using Sunrise.Shared.Enums.Users; -using Sunrise.Shared.Extensions.Users; +using Sunrise.Shared.Extensions.Beatmaps; using Sunrise.Shared.Objects; using Sunrise.Shared.Objects.Sessions; using Sunrise.Shared.Services; using GameMode = Sunrise.Shared.Enums.Beatmaps.GameMode; +using SubmissionStatus = Sunrise.Shared.Enums.Scores.SubmissionStatus; namespace Sunrise.Server.Commands.ChatCommands.System; +[Obsolete("While this is very optimised for recalcualtion, I will prefer to deprecate this in favour of a single all score recalcualtions for consistency and to reduce the amount of code that needs to be maintained. This will be removed in a future update.")] [ChatCommand("recalculateuserstats", requiredPrivileges: UserPrivilege.SuperUser)] public class RecalculateUserStatsCommand : IChatCommand { @@ -132,7 +136,7 @@ private async Task UpdateUserStats(UserStats stats, CalculatorService calculator foreach (var score in pageScores) { - tempUserStats.UpdateWithDbScore(score); + UpdateUserStatsWithScore(tempUserStats, score); } if (pageScores.Count < pageSize) break; @@ -174,4 +178,24 @@ private async Task UpdateUserStats(UserStats stats, CalculatorService calculator await database.Users.Stats.UpdateUserStats(stats, user); } + + private static void UpdateUserStatsWithScore(UserStats userStats, Score score) + { + var isFailed = !score.IsPassed && !score.Mods.HasFlag(Mods.NoFail); + + userStats.TotalScore += score.TotalScore; + userStats.TotalHits += score.Count300 + score.Count100 + score.Count50; + if ((GameMode)score.GameMode.ToVanillaGameMode() is GameMode.Taiko or GameMode.Mania) + userStats.TotalHits += score.CountGeki + score.CountKatu; + userStats.PlayTime += score.TimeElapsed; + userStats.PlayCount++; + + if (isFailed || !score.IsScoreable) + return; + + userStats.MaxCombo = Math.Max(userStats.MaxCombo, score.MaxCombo); + + if (score.SubmissionStatus == SubmissionStatus.Best && score.BeatmapStatus.IsRanked()) + userStats.RankedScore += score.TotalScore; + } } \ No newline at end of file diff --git a/Sunrise.Server/Commands/ChatCommands/System/RequeueFailedScoresCommand.cs b/Sunrise.Server/Commands/ChatCommands/System/RequeueFailedScoresCommand.cs new file mode 100644 index 00000000..ea26b538 --- /dev/null +++ b/Sunrise.Server/Commands/ChatCommands/System/RequeueFailedScoresCommand.cs @@ -0,0 +1,39 @@ +using Sunrise.Server.Attributes; +using Sunrise.Server.Repositories; +using Sunrise.Shared.Application; +using Sunrise.Shared.Database; +using Sunrise.Shared.Enums.Users; +using Sunrise.Shared.Objects; +using Sunrise.Shared.Objects.Sessions; + +namespace Sunrise.Server.Commands.ChatCommands.System; + +[ChatCommand("requeuefailedscores", requiredPrivileges: UserPrivilege.SuperUser)] +public class RequeueFailedScoresCommand : IChatCommand +{ + public async Task Handle(Session session, ChatChannel? channel, string[]? args) + { + int? taskId = null; + + if (args != null && args.Length >= 1) + { + if (!int.TryParse(args[0], out var parsedTaskId)) + { + ChatCommandRepository.SendMessage(session, + $"Usage: {Configuration.BotPrefix}requeuefailedscores [taskId] — omit filter to requeue all failed tasks."); + return; + } + + taskId = parsedTaskId; + } + + using var scope = ServicesProviderHolder.CreateScope(); + var database = scope.ServiceProvider.GetRequiredService(); + + var requeuedCount = taskId.HasValue + ? await database.ScoreProcessingTasks.TryRequeueFailedTask(taskId.Value) ? 1 : 0 + : await database.ScoreProcessingTasks.TryRequeueFailedTasks(); + + ChatCommandRepository.SendMessage(session, $"Requeued {requeuedCount} failed score-processing {(requeuedCount == 1 ? "task" : "tasks")}."); + } +} \ No newline at end of file diff --git a/Sunrise.Server/Commands/ChatCommands/System/RestoreScoreCommand.cs b/Sunrise.Server/Commands/ChatCommands/System/RestoreScoreCommand.cs new file mode 100644 index 00000000..d2d37168 --- /dev/null +++ b/Sunrise.Server/Commands/ChatCommands/System/RestoreScoreCommand.cs @@ -0,0 +1,69 @@ +using Hangfire; +using Sunrise.Server.Attributes; +using Sunrise.Server.Repositories; +using Sunrise.Shared.Application; +using Sunrise.Shared.Database; +using Sunrise.Shared.Database.Models.Scores; +using Sunrise.Shared.Enums.Scores; +using Sunrise.Shared.Enums.Users; +using Sunrise.Shared.Objects; +using Sunrise.Shared.Objects.Sessions; +using Sunrise.Shared.Services; + +namespace Sunrise.Server.Commands.ChatCommands.System; + +[ChatCommand("restorescore", requiredPrivileges: UserPrivilege.SuperUser)] +public class RestoreScoreCommand : IChatCommand +{ + public Task Handle(Session session, ChatChannel? channel, string[]? args) + { + if (args == null || args.Length < 1 || !int.TryParse(args[0], out var scoreId)) + { + ChatCommandRepository.SendMessage(session, + $"Usage: {Configuration.BotPrefix}restorescore "); + return Task.CompletedTask; + } + + BackgroundTaskService.TryStartNewBackgroundJob( + () => RestoreScore(session.UserId, scoreId, CancellationToken.None), + message => ChatCommandRepository.TrySendMessage(session.UserId, message)); + + return Task.CompletedTask; + } + + [AutomaticRetry(Attempts = 0)] + public async Task RestoreScore(int userId, int scoreId, CancellationToken ct) + { + await BackgroundTaskService.ExecuteBackgroundTask( + async () => + { + using var scope = ServicesProviderHolder.CreateScope(); + var database = scope.ServiceProvider.GetRequiredService(); + var score = await database.Scores.GetScore(scoreId, filterValidScores: false, ct: ct); + + if (score == null) + { + ChatCommandRepository.TrySendMessage(userId, $"Score {scoreId} was not found."); + return; + } + + var queued = await database.ScoreProcessingTasks.TryAddQueueEntry(new ScoreProcessingTask + { + TaskType = ScoreTaskType.Restore, + ScoreId = score.Id, + Priority = (int)ScoreProcessingPriority.Normal, + CreatedAt = DateTime.UtcNow + }, + ct); + + if (!queued) + { + ChatCommandRepository.TrySendMessage(userId, $"Score {scoreId} already has an active queued task."); + return; + } + + ChatCommandRepository.TrySendMessage(userId, $"Score {scoreId} was queued for restore."); + }, + message => ChatCommandRepository.TrySendMessage(userId, message)); + } +} \ No newline at end of file diff --git a/Sunrise.Server/Commands/ChatCommands/System/UpdateScoresBeatmapsStatusCommand.cs b/Sunrise.Server/Commands/ChatCommands/System/UpdateScoresBeatmapsStatusCommand.cs index c5485377..31c08e3a 100644 --- a/Sunrise.Server/Commands/ChatCommands/System/UpdateScoresBeatmapsStatusCommand.cs +++ b/Sunrise.Server/Commands/ChatCommands/System/UpdateScoresBeatmapsStatusCommand.cs @@ -1,92 +1,21 @@ using Sunrise.Server.Attributes; using Sunrise.Server.Repositories; using Sunrise.Shared.Application; -using Sunrise.Shared.Database; -using Sunrise.Shared.Database.Objects; -using Sunrise.Shared.Enums.Beatmaps; using Sunrise.Shared.Enums.Users; using Sunrise.Shared.Objects; using Sunrise.Shared.Objects.Sessions; -using Sunrise.Shared.Services; namespace Sunrise.Server.Commands.ChatCommands.System; +[Obsolete("This command is deprecated and will be removed in next versions.")] [ChatCommand("updatescoresbeatmapstatus", requiredPrivileges: UserPrivilege.SuperUser)] public class UpdateScoresBeatmapsStatusCommand : IChatCommand { public Task Handle(Session session, ChatChannel? channel, string[]? args) { - BackgroundTaskService.TryStartNewBackgroundJob( - () => - UpdateScoresBeatmapStatus(session.UserId, CancellationToken.None), - message => ChatCommandRepository.SendMessage(session, message)); - + ChatCommandRepository.SendMessage( + session, + $"This command is deprecated and will be removed in next versions. Use {Configuration.BotPrefix}recalculatescore for single-score repairs."); return Task.CompletedTask; } - - public async Task UpdateScoresBeatmapStatus(int userId, CancellationToken ct) - { - await BackgroundTaskService.ExecuteBackgroundTask( - async () => - { - using var scope = ServicesProviderHolder.CreateScope(); - var database = scope.ServiceProvider.GetRequiredService(); - - var (allScores, _) = await database.Scores.GetScores(options: new QueryOptions - { - IgnoreCountQueryIfExists = true - }, - ct: ct); - var groupedScores = allScores.GroupBy(x => x.BeatmapId); - - var scoresReviewedTotal = 0; - - foreach (var group in groupedScores) - { - var isNeedsUpdate = group.Any(s => s.BeatmapStatus == BeatmapStatus.Unknown); - - scoresReviewedTotal += group.Count(); - - if (!isNeedsUpdate) continue; - - var beatmapService = scope.ServiceProvider.GetRequiredService(); - - var session = BaseSession.GenerateServerSession(); - var beatmapSetResult = await beatmapService.GetBeatmapSet(session, beatmapId: group.Key, ct: ct); - - if (beatmapSetResult.IsFailure) - { - ChatCommandRepository.TrySendMessage(userId, $"Beatmap set {group.Key} returned error: {beatmapSetResult.Error}"); - return; - } - - var beatmapSet = beatmapSetResult.Value; - - var status = BeatmapStatus.NotSubmitted; - - if (beatmapSet == null) - { - ChatCommandRepository.TrySendMessage(userId, $"Beatmap set {group.Key} not found. Setting status to graveyard."); - } - else - { - status = beatmapSet.Status; - } - - foreach (var score in group) - { - score.BeatmapStatus = status; - ct.ThrowIfCancellationRequested(); - await database.Scores.UpdateScore(score); - } - - ChatCommandRepository.TrySendMessage(userId, $"Updated {group.Count()} scores for beatmap {group.Key} to status {status}"); - ChatCommandRepository.TrySendMessage(userId, $"Total scores reviewed: {scoresReviewedTotal}"); - - // Prevent rate limiting on beatmap mirrors - await Task.Delay(2000, ct); - } - }, - message => ChatCommandRepository.TrySendMessage(userId, message)); - } } \ No newline at end of file diff --git a/Sunrise.Server/Commands/ChatCommands/System/UpdateScoresSubmittedStatusCommand.cs b/Sunrise.Server/Commands/ChatCommands/System/UpdateScoresSubmittedStatusCommand.cs index bd14aba6..ac26b34c 100644 --- a/Sunrise.Server/Commands/ChatCommands/System/UpdateScoresSubmittedStatusCommand.cs +++ b/Sunrise.Server/Commands/ChatCommands/System/UpdateScoresSubmittedStatusCommand.cs @@ -1,107 +1,21 @@ -using Microsoft.EntityFrameworkCore; using Sunrise.Server.Attributes; using Sunrise.Server.Repositories; using Sunrise.Shared.Application; -using Sunrise.Shared.Database; -using Sunrise.Shared.Database.Extensions; -using Sunrise.Shared.Database.Models; -using Sunrise.Shared.Database.Objects; -using Sunrise.Shared.Enums.Scores; using Sunrise.Shared.Enums.Users; -using Sunrise.Shared.Extensions.Scores; using Sunrise.Shared.Objects; using Sunrise.Shared.Objects.Sessions; -using Sunrise.Shared.Services; namespace Sunrise.Server.Commands.ChatCommands.System; +[Obsolete("This command is deprecated and will be removed in next versions.")] [ChatCommand("updatescoressubmittedstatus", requiredPrivileges: UserPrivilege.SuperUser)] public class UpdateScoresSubmittedStatusCommand : IChatCommand { public Task Handle(Session session, ChatChannel? channel, string[]? args) { - BackgroundTaskService.TryStartNewBackgroundJob( - () => - UpdateScoresSubmittedStatus(session.UserId, CancellationToken.None), - message => ChatCommandRepository.SendMessage(session, message)); - + ChatCommandRepository.SendMessage( + session, + $"This command is deprecated and will be removed in next versions. Please use {Configuration.BotPrefix}deletescore or {Configuration.BotPrefix}restorescore instead."); return Task.CompletedTask; } - - public async Task UpdateScoresSubmittedStatus(int userId, CancellationToken ct) - { - await BackgroundTaskService.ExecuteBackgroundTask( - async () => - { - using var scope = ServicesProviderHolder.CreateScope(); - var database = scope.ServiceProvider.GetRequiredService(); - - var pageSize = 10; - var scoresReviewedTotal = 0; - - for (var x = 1;; x++) - { - var beatmapIds = await database.DbContext.Scores - .FilterValidScores() - .FilterPassedScoreableScores() - .Select(s => new - { - s.BeatmapId - }) - .Distinct() - .OrderBy(x => x.BeatmapId) - .UseQueryOptions(new QueryOptions(new Pagination(x, pageSize))) - .ToListAsync(cancellationToken: ct); - - foreach (var beatmap in beatmapIds) - { - var scores = await database.DbContext.Scores - .FilterValidScores() - .FilterPassedScoreableScores() - .Where(s => s.BeatmapId == beatmap.BeatmapId) - .ToListAsync(cancellationToken: ct); - - var scoresGrouped = scores.GroupBy(s => new - { - s.BeatmapId, - s.GameMode, - s.Mods, - s.UserId - }); - - foreach (var group in scoresGrouped) - { - var scoresGroup = group.ToList(); - - scoresReviewedTotal += group.Count(); - - await UpdateUserBeatmapScoresSubmittedStatus(userId, database, scoresGroup, ct); - } - } - - ChatCommandRepository.TrySendMessage(userId, $"Total scores reviewed: {scoresReviewedTotal}"); - if (beatmapIds.Count < pageSize) break; - } - }, - message => ChatCommandRepository.TrySendMessage(userId, message)); - } - - public async Task UpdateUserBeatmapScoresSubmittedStatus(int sendProgressMessageToUserId, DatabaseService database, List scores, CancellationToken ct) - { - var bestScore = scores.Select(x => x).ToList().SortScoresByTheirScoreValue().FirstOrDefault(); - - foreach (var score in scores) - { - var oldScoreStatus = score.SubmissionStatus; - - score.SubmissionStatus = score == bestScore ? SubmissionStatus.Best : score.IsPassed ? SubmissionStatus.Submitted : SubmissionStatus.Failed; - ct.ThrowIfCancellationRequested(); - - if (oldScoreStatus != score.SubmissionStatus) - { - await database.Scores.UpdateScore(score); - ChatCommandRepository.TrySendMessage(sendProgressMessageToUserId, $"Updated score {score.Id} submission status from {oldScoreStatus} to {score.SubmissionStatus}"); - } - } - } } \ No newline at end of file diff --git a/Sunrise.Server/Controllers/AssetsController.cs b/Sunrise.Server/Controllers/AssetsController.cs index bd8c557b..575aa5c3 100644 --- a/Sunrise.Server/Controllers/AssetsController.cs +++ b/Sunrise.Server/Controllers/AssetsController.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Mvc; +using Serilog; using Sunrise.Server.Attributes; using Sunrise.Server.Services; using Sunrise.Shared.Application; @@ -24,7 +25,7 @@ public async Task GetAvatar(int id, [FromQuery(Name = "default")] if (getAvatarResult.IsFailure) { if (fallToDefault is true) - SunriseMetrics.RequestReturnedErrorCounterInc(RequestType.GetAvatar, null, getAvatarResult.Error); + Log.Warning("Failed to get avatar {AvatarId}: {Error}", id, getAvatarResult.Error); return NotFound(); } @@ -40,7 +41,7 @@ public async Task GetBanner(int id, [FromQuery(Name = "default")] if (getBannerResult.IsFailure) { if (fallToDefault is true) - SunriseMetrics.RequestReturnedErrorCounterInc(RequestType.GetBanner, null, getBannerResult.Error); + Log.Warning("Failed to get banner {BannerId}: {Error}", id, getBannerResult.Error); return NotFound(); } @@ -56,7 +57,7 @@ public async Task GetScreenshot(int id, CancellationToken ct = de if (getScreenshotResult.IsFailure) { - SunriseMetrics.RequestReturnedErrorCounterInc(RequestType.OsuScreenshot, null, getScreenshotResult.Error); + Log.Warning("Failed to get screenshot {ScoreId}: {Error}", id, getScreenshotResult.Error); return NotFound(); } @@ -103,4 +104,4 @@ public async Task GetEventBanner(CancellationToken ct = default) return new FileContentResult(data, "image/png"); } -} \ No newline at end of file +} diff --git a/Sunrise.Server/Controllers/ScoreController.cs b/Sunrise.Server/Controllers/ScoreController.cs index 6b46f076..79424a0d 100644 --- a/Sunrise.Server/Controllers/ScoreController.cs +++ b/Sunrise.Server/Controllers/ScoreController.cs @@ -1,10 +1,8 @@ -using System.Collections.Concurrent; using Microsoft.AspNetCore.Mvc; using osu.Shared; using Serilog; using Sunrise.Server.Attributes; using Sunrise.Server.Services; -using Sunrise.Server.Services.Helpers.Scores; using Sunrise.Server.Utils; using Sunrise.Shared.Attributes; using Sunrise.Shared.Enums.Leaderboards; @@ -20,9 +18,6 @@ namespace Sunrise.Server.Controllers; [ApiExplorerSettings(IgnoreApi = true)] public class ScoreController(ScoreService scoreService, AssetBanchoService assetBanchoService, SessionRepository sessions) : ControllerBase { - private static readonly ConcurrentDictionary _processingScores = new(); - private static readonly TimeSpan ScoreProcessingTimeout = TimeSpan.FromMinutes(10); - [HttpPost(RequestType.OsuSubmitScore)] public async Task Submit( [FromForm(Name = "pass")] string passhash, @@ -44,53 +39,22 @@ public async Task Submit( if (!sessions.TryGetSession(username, passhash, out var session) || session == null) { - SubmitScoreHelper.ReportRejectionToMetrics(session, - scoreSerialized, - "SubmitScore: Invalid session or passhash mismatch"); + Log.Error("Failed to authenticate score submission for user {Username}. Invalid session.", username); return Ok("error: pass"); } - var scoreHash = scoreSerialized.Split(':')[2]; - - // TODO: Hopefully this will be replaced by writing scores which are being processed to the database (check BeatmapService TODOs), since right now this is not really scalable. - var isScoreAlreadyBeingProcessed = _processingScores.TryGetValue(scoreHash, out var existingTimestamp) && - DateTime.UtcNow - existingTimestamp < ScoreProcessingTimeout; - - if (isScoreAlreadyBeingProcessed) - { - SubmitScoreHelper.ReportRejectionToMetrics(session, - scoreSerialized, - "Duplicate score submission while previous is still processing"); - - return Ok("error: no"); - } - - var isScoreAddedToProcessing = _processingScores.TryAdd(scoreHash, DateTime.UtcNow); - - if (!isScoreAddedToProcessing) - { - Log.Warning("Failed to add score hash {ScoreHash} to processing dictionary for user {Username}. Another submission might be processing concurrently.", scoreHash, username); - } - - try - { - var result = await scoreService.SubmitScore(session, - scoreSerialized, - beatmapHash, - scoreTime, - scoreFailTime, - osuVersion, - clientHash, - replayFile, - storyboardHash); + var result = await scoreService.SubmitScore(session, + scoreSerialized, + beatmapHash, + scoreTime, + scoreFailTime, + osuVersion, + clientHash, + replayFile, + storyboardHash); - return Ok(result); - } - finally - { - _processingScores.TryRemove(scoreHash, out _); - } + return Ok(result); } [HttpGet(RequestType.OsuGetScores)] diff --git a/Sunrise.Server/Controllers/WebController.cs b/Sunrise.Server/Controllers/WebController.cs index 2f19f35d..0fb9760e 100644 --- a/Sunrise.Server/Controllers/WebController.cs +++ b/Sunrise.Server/Controllers/WebController.cs @@ -31,7 +31,7 @@ public async Task OsuScreenshot( if (saveScreenshotResult.IsFailure) { - SunriseMetrics.RequestReturnedErrorCounterInc(RequestType.OsuScreenshot, session, saveScreenshotResult.Error); + SunriseMetrics.RequestReturnedErrorCounterInc(RequestType.OsuScreenshot, session.UserId, saveScreenshotResult.Error); return BadRequest(saveScreenshotResult.Error); } @@ -227,4 +227,4 @@ public IActionResult RedirectToWebsite() { return Redirect($"https://{Configuration.Domain}/"); } -} \ No newline at end of file +} diff --git a/Sunrise.Server/Program.cs b/Sunrise.Server/Program.cs index 036ef188..4dd6ffb5 100644 --- a/Sunrise.Server/Program.cs +++ b/Sunrise.Server/Program.cs @@ -1,5 +1,6 @@ using DotNetEnv; using Hangfire; +using Sunrise.Processing; using Sunrise.Server; using Sunrise.Server.Middlewares; using Sunrise.Shared.Application; @@ -56,6 +57,7 @@ app.WarmUpSingletons(); RecurringJobs.Initialize(); +ProcessingJobs.Initialize(); if (Configuration.ClearCacheOnStartup) { diff --git a/Sunrise.Server/Services/BanchoService.cs b/Sunrise.Server/Services/BanchoService.cs index 3d11590d..5561efcc 100644 --- a/Sunrise.Server/Services/BanchoService.cs +++ b/Sunrise.Server/Services/BanchoService.cs @@ -27,7 +27,7 @@ public async Task ProcessPackets(Session session, MemoryStream buffer, ILogger l catch (Exception e) { var errorMessage = $"Failed to process Bancho packet: {e.Message}"; - SunriseMetrics.RequestReturnedErrorCounterInc(RequestType.BanchoProcess, session, errorMessage); + SunriseMetrics.RequestReturnedErrorCounterInc(RequestType.BanchoProcess, session.UserId, errorMessage); logger.LogError(e, errorMessage); } } @@ -42,4 +42,4 @@ public string GetCurrentEventJson() return json; } -} \ No newline at end of file +} diff --git a/Sunrise.Server/Services/ScoreService.cs b/Sunrise.Server/Services/ScoreService.cs index f2d55a63..3e4f0b82 100644 --- a/Sunrise.Server/Services/ScoreService.cs +++ b/Sunrise.Server/Services/ScoreService.cs @@ -1,38 +1,30 @@ -using System.Net; -using CSharpFunctionalExtensions; -using Microsoft.EntityFrameworkCore; using osu.Shared; using Serilog; -using Sunrise.API.Enums; -using Sunrise.API.Objects; -using Sunrise.API.Serializable.Response; -using Sunrise.Server.Services.Helpers.Scores; +using Sunrise.Processing.Scores.Handlers; +using Sunrise.Processing.Utils; using Sunrise.Shared.Application; using Sunrise.Shared.Attributes; using Sunrise.Shared.Database; using Sunrise.Shared.Database.Extensions; using Sunrise.Shared.Database.Models; -using Sunrise.Shared.Database.Models.Users; +using Sunrise.Shared.Database.Models.Scores; using Sunrise.Shared.Database.Objects; using Sunrise.Shared.Enums.Beatmaps; using Sunrise.Shared.Enums.Leaderboards; +using Sunrise.Shared.Enums.Scores; using Sunrise.Shared.Extensions.Beatmaps; using Sunrise.Shared.Extensions.Scores; -using Sunrise.Shared.Extensions.Users; using Sunrise.Shared.Objects; -using Sunrise.Shared.Objects.Keys; -using Sunrise.Shared.Objects.Serializable; using Sunrise.Shared.Objects.Sessions; -using Sunrise.Shared.Repositories; using Sunrise.Shared.Services; using GameMode = Sunrise.Shared.Enums.Beatmaps.GameMode; -using SubmissionStatus = Sunrise.Shared.Enums.Scores.SubmissionStatus; -using WebSocketManager = Sunrise.API.Managers.WebSocketManager; namespace Sunrise.Server.Services; -public class ScoreService(BeatmapService beatmapService, DatabaseService database, CalculatorService calculatorService, MedalService medalService, WebSocketManager webSocketManager, SessionRepository sessions, ChatChannelRepository channels, OsuVersionService osuVersionService) +public class ScoreService(BeatmapService beatmapService, DatabaseService database, ScoreSubmissionHandler submissionTaskHandler) { + private const int OsuReplayFileHeaderSize = 24; + [TraceExecution] public async Task SubmitScore(Session session, string scoreSerialized, string beatmapHash, int scoreTime, int scoreFailTime, string osuVersion, string clientHash, IFormFile? replay, @@ -40,272 +32,82 @@ public async Task SubmitScore(Session session, string scoreSerialized, s { var scoreSubmittedAt = DateTime.UtcNow; - var beatmapSetResult = await ExecuteWithRetry( - session, - scoreSerialized, - () => beatmapService.GetBeatmapSet(session, beatmapHash: beatmapHash, retryCount: 3, shouldSendRateLimitWarning: false), - () => beatmapService.GetBeatmapSet(session, beatmapHash: beatmapHash, retryCount: int.MaxValue, shouldSendRateLimitWarning: false), - null, - "BeatmapSet"); - - if (beatmapSetResult.IsFailure) - return "error: no"; - - var beatmapSet = beatmapSetResult.Value; - - var beatmap = beatmapSet?.Beatmaps.FirstOrDefault(x => x.Checksum == beatmapHash); - - if (beatmap == null || beatmapSet == null) - { - SubmitScoreHelper.ReportRejectionToMetrics(session, scoreSerialized, "Invalid request: BeatmapSet not found"); - return "error: no"; - } - - var (score, sessionUsername) = scoreSerialized.TryParseToSubmittedScore(session, beatmap, scoreSubmittedAt); - - if (Configuration.EnforceLatestClientVersion) - await CheckScoreClientVersion(score.OsuVersion, osuVersion); + var parsedScoreResult = scoreSerialized.TryParseBaseScore(scoreSubmittedAt); - var dbScore = await database.Scores.GetScore(score.ScoreHash); // TODO: Score hash is not indexed, this is heavy performance downside. Consider refactoring to score uploading queue and checking if unique by inserting. (Insert score -> Process -> If in the valid request timeframe (API ratelimits can make score wait in queue), return score submission response) - - if (dbScore != null) + if (parsedScoreResult.IsFailure) { - SubmitScoreHelper.ReportRejectionToMetrics(session, scoreSerialized, "Score with same hash already exists"); + Log.Error("Failed to parse submitted score for user {UserId}: {Error}", session.UserId, parsedScoreResult.Error); return "error: no"; } - var scorePerformanceResult = await ExecuteWithRetry( - session, - scoreSerialized, - () => calculatorService.CalculateScorePerformance(session, score, shouldSendRateLimitWarning: false), - () => calculatorService.CalculateScorePerformance(session, score, int.MaxValue, false), - "While we could find the beatmapset you played on, we couldn't find the beatmap file for it. If you think this is a mistake, please report it to the developer with the beatmap hash: " + beatmapHash, - "Beatmap"); - - if (scorePerformanceResult.IsFailure) - return "error: no"; - - if (scorePerformanceResult.Value == null) - { - SubmitScoreHelper.ReportRejectionToMetrics(session, scoreSerialized, "Score performance calculation returned null"); - return "error: no"; - } + var submittedScore = parsedScoreResult.Value; - score.PerformancePoints = scorePerformanceResult.Value.PerformancePoints; + int? replayFileId = null; - if (replay is { Length: >= 24 }) + if (replay is { Length: >= OsuReplayFileHeaderSize }) { var replayFileResult = await database.Scores.Files.AddReplayFile(session.UserId, replay); if (replayFileResult.IsFailure) { - await SaveRejectedScore(score); - SubmitScoreHelper.ReportRejectionToMetrics(session, scoreSerialized, $"Couldn't add replay file for score, reason: {replayFileResult.Error}"); - return "error: no"; + Log.Error("Failed to save replay file for user {UserId}: {Error}", session.UserId, replayFileResult.Error); + throw new Exception($"Failed to save replay for user {session.UserId}: {replayFileResult.Error}"); // Throw to make osu client retry the score submission } - score.ReplayFileId = replayFileResult.Value.Id; - } - - var isCurrentScoreFailed = SubmitScoreHelper.IsScoreFailed(score); - - if (!isCurrentScoreFailed && score.ReplayFileId == null) - { - await SaveRejectedScore(score); - SubmitScoreHelper.ReportRejectionToMetrics(session, scoreSerialized, "Replay file not found for passed score"); - return "error: no"; - } - - if (SubmitScoreHelper.IsHasInvalidMods(score.Mods)) - { - await SaveRejectedScore(score); - SubmitScoreHelper.ReportRejectionToMetrics(session, scoreSerialized, "Invalid mods"); - return "error: no"; - } - - var notStandardMods = score.Mods.TryGetSelectedNotStandardMods(); - - var hasNonStandardMods = notStandardMods is not Mods.None; - var isHasMoreThanOneNotStandardMod = !notStandardMods.IsSingleMod() && hasNonStandardMods; - var isNonSupportedNonStandardMod = (int)score.GameMode < 4 && hasNonStandardMods; - - // Disallow submitting scores with double not standard mods (e.g. ScoreV2 + Relax) or with which we are not supporting (e.g. shouldn't exist) - if (isHasMoreThanOneNotStandardMod || isNonSupportedNonStandardMod) - { - await SaveRejectedScore(score); - SubmitScoreHelper.ReportRejectionToMetrics(session, scoreSerialized, "Includes non-standard mod(s), which is not supported for this game mode"); - return "error: no"; - } - - // Auto-restrict players who submit scores with too many performance points on standard game modes - if (score.PerformancePoints >= Configuration.BannablePpThreshold && !hasNonStandardMods && score.LocalProperties.IsRanked) - { - await SaveRejectedScore(score); - SubmitScoreHelper.ReportRejectionToMetrics(session, scoreSerialized, "Too many performance points. Cheating?"); - await database.Users.Moderation.RestrictPlayer(session.UserId, null, "Auto-restricted for submitting impossible score"); - return "error: no"; - } - - var isScoreValid = SubmitScoreHelper.IsScoreValid(session, - score, - clientHash, - beatmapHash, - beatmap.Checksum, - storyboardHash, - sessionUsername); - - if (!isScoreValid) - { - await SaveRejectedScore(score); - SubmitScoreHelper.ReportRejectionToMetrics(session, scoreSerialized, "Invalid checksums"); - await database.Users.Moderation.RestrictPlayer(session.UserId, null, "Invalid checksums on score submission"); - return "error: no"; - } - - var isScoreScoreable = !isCurrentScoreFailed && score.IsScoreable; - - var (databaseScores, _) = isScoreScoreable - ? await database.Scores.GetBeatmapScores(score.BeatmapHash, - score.GameMode, - options: new QueryOptions - { - IgnoreCountQueryIfExists = true - }) - : ([], 0); - - var globalScores = databaseScores.EnrichWithLeaderboardPositions(); - - var (databaseScoresWithSameMods, _) = isScoreScoreable - ? await database.Scores.GetBeatmapScores(score.BeatmapHash, - score.GameMode, - LeaderboardType.GlobalWithMods, - score.Mods, - options: new QueryOptions - { - IgnoreCountQueryIfExists = true - }) - : ([], 0); - - var scoresWithSameMods = databaseScoresWithSameMods.EnrichWithLeaderboardPositions(); - - var user = await database.Users.GetUser(session.UserId, - options: new QueryOptions - { - QueryModifier = q => q.Cast().Include(u => u.UserStats) - }); - - if (user == null) - { - await SaveRejectedScore(score); - SubmitScoreHelper.ReportRejectionToMetrics(session, scoreSerialized, "Couldn't find user while submitting score"); - return "error: no"; - } - - var userStats = user.UserStats.FirstOrDefault(u => u.GameMode == score.GameMode) - ?? await database.Users.Stats.GetUserStats(user.Id, score.GameMode); - - if (userStats == null) - { - await SaveRejectedScore(score); - SubmitScoreHelper.ReportRejectionToMetrics(session, scoreSerialized, "User stats not found"); - return "error: no"; + replayFileId = replayFileResult.Value.Id; } - var prevUserStats = userStats.Clone(); - - var prevUserBeatmapScores = isScoreScoreable - ? await database.Scores.GetUserScores(score.UserId, - score.GameMode, - ScoreTableType.Recent, - new QueryOptions - { - QueryModifier = x => x.Cast().Where(x => x.BeatmapHash == score.BeatmapHash).FilterPassedScoreableScores(), - IgnoreCountQueryIfExists = true - }) - : ([], 0); + var timeElapsed = ScoreSubmissionUtil.GetTimeElapsed(submittedScore, scoreTime, scoreFailTime); - var prevUserPersonalBestScores = prevUserBeatmapScores.Scores.GetUserPersonalBestScores(score.UserId); - - var (prevUserGlobalRank, _) = isScoreScoreable ? await database.Users.Stats.Ranks.GetUserRanks(user, userStats.GameMode) : (0, 0); - prevUserStats.LocalProperties.Rank = prevUserGlobalRank; - - var timeElapsed = SubmitScoreHelper.GetTimeElapsed(score, scoreTime, scoreFailTime); - - var userGrades = await database.Users.Grades.GetUserGrades(user.Id, userStats.GameMode); - - if (userGrades == null) + var candidate = new ScoreSubmissionRequest { - await SaveRejectedScore(score); - SubmitScoreHelper.ReportRejectionToMetrics(session, scoreSerialized, "Couldn't find user grades while submitting score"); - return "error: no"; - } + UserId = session.UserId, + ScoreHash = submittedScore.ScoreHash, + ScoreSerialized = scoreSerialized, + BeatmapHash = beatmapHash, + TimeElapsed = timeElapsed, + OsuVersion = osuVersion, + ClientHash = clientHash, + ReplayFileId = replayFileId, + StoryboardHash = storyboardHash, + UserHash = session.Attributes.UserHash, + WhenPlayed = scoreSubmittedAt + }; - var transactionResult = await database.CommitAsTransactionAsync(async () => + try { - var prevUserPersonalBestScoresWithSameMods = prevUserBeatmapScores.Scores.Where(x => x.Mods == score.Mods).ToList().GetUserPersonalBestScores(score.UserId); - - score.UpdateSubmissionStatus(prevUserPersonalBestScoresWithSameMods?.BestScoreBasedByTotalScore); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(Configuration.ScoreProcessingTimeoutSeconds)); + var processSubmissionResult = await submissionTaskHandler.ExecuteInlineSubmission(session, candidate, cts.Token); - await userStats.UpdateWithScore(score, prevUserPersonalBestScores, timeElapsed); - userGrades.UpdateWithScore(score, prevUserPersonalBestScores?.BestScoreBasedByTotalScore); + if (processSubmissionResult.IsSuccess) + return processSubmissionResult.Value ?? "error: no"; - if (prevUserPersonalBestScoresWithSameMods?.BestScoreBasedByTotalScore != null && score.SubmissionStatus == SubmissionStatus.Best) - { - // Best score shouldn't be failed, but adding this check just in case - prevUserPersonalBestScoresWithSameMods.BestScoreBasedByTotalScore.SubmissionStatus = prevUserPersonalBestScoresWithSameMods.BestScoreBasedByTotalScore.IsPassed ? SubmissionStatus.Submitted : SubmissionStatus.Failed; - await database.Scores.UpdateScore(prevUserPersonalBestScoresWithSameMods.BestScoreBasedByTotalScore); - } + var processingError = processSubmissionResult.Error; - await database.Scores.AddScore(score); - await database.Users.Stats.UpdateUserStats(userStats, user); - await database.Users.Grades.UpdateUserGrades(userGrades); - }); + if (processingError.Code == ScoreProcessingErrorCode.DuplicateScore) + return "error: no"; - if (transactionResult.IsFailure) - { - SubmitScoreHelper.ReportRejectionToMetrics(session, scoreSerialized, "Failed to execute transaction for score submission, reason: " + transactionResult.Error); - return "error: no"; + await EnqueueForBackgroundRetry(candidate, session, processingError); } - - SunriseMetrics.ScoreSubmittedCounterInc(session, beatmap.Id, score.GameMode, score.Mods, score.PerformancePoints, score.Id); - - if (!isScoreScoreable) + catch (OperationCanceledException) { - return "error: no"; // No need to create chart/unlock medals for failed or for scores that are not scoreable + await EnqueueForBackgroundRetry(candidate, session); } - - webSocketManager.BroadcastJsonAsync(new WebSocketMessage(WebSocketEventType.NewScoreSubmitted, new ScoreResponse(sessions, score))); - - // Mods can change difficulty rating, important to recalculate it for right medal unlocking - if ((int)score.GameMode != beatmap.ModeInt || (int)score.Mods > 0) + catch (Exception ex) { - var recalculateBeatmapResult = await calculatorService.CalculateBeatmapPerformance(session, score.BeatmapId, score.GameMode, score.Mods); + Log.Error(ex, "Unexpected exception during sync score submission for user {UserId}", session.UserId); - if (recalculateBeatmapResult.IsFailure) + try { - SunriseMetrics.RequestReturnedErrorCounterInc(RequestType.OsuSubmitScore, session, recalculateBeatmapResult.Error.Message); + await EnqueueForBackgroundRetry(candidate, session); } - else + catch (Exception enqueueEx) { - beatmap.UpdateBeatmapWithPerformance(score.Mods, recalculateBeatmapResult.Value); + Log.Error(enqueueEx, "Failed to enqueue score for user {UserId} after sync exception", session.UserId); } } - var updatedScores = globalScores.UpsertUserScoreToSortedScores(score); - score = updatedScores.First(s => s.ScoreHash == score.ScoreHash); - - var (newUserRank, _) = await database.Users.Stats.Ranks.GetUserRanks(user, userStats.GameMode); - userStats.LocalProperties.Rank = newUserRank; - - if (score.LocalProperties.LeaderboardPosition == 1 && globalScores.Count > 0 && globalScores[0].UserId != score.UserId) - { - channels.GetChannel(session, "#announce") - ?.SendToChannel(SubmitScoreHelper.GetNewFirstPlaceString(session, score, beatmapSet, beatmap)); - } - - var newAchievements = await medalService.UnlockAndGetNewMedals(score, beatmap, userStats); - - return SubmitScoreHelper.GetScoreSubmitResponse(beatmap, userStats, prevUserStats, score, prevUserPersonalBestScores, newAchievements); + return "error: no"; } [TraceExecution] @@ -341,7 +143,7 @@ public async Task GetBeatmapScores(Session session, int setId, GameMode var beatmapSet = beatmapSetResult.Value; - var beatmap = beatmapSet?.Beatmaps.FirstOrDefault(x => x.Checksum == beatmapHash); + var beatmap = beatmapSet?.Beatmaps?.FirstOrDefault(x => x.Checksum == beatmapHash); if (beatmapSet == null || beatmap == null) return $"{(int)BeatmapStatus.NotSubmitted}|false"; // TODO: Check with our db to find out if need's update @@ -360,7 +162,7 @@ public async Task GetBeatmapScores(Session session, int setId, GameMode var userPersonalBestScores = scores.GetUserPersonalBestScores(session.UserId); - var personalBest = userPersonalBestScores?.BestScoreBasedByTotalScore; + var personalBest = userPersonalBestScores?.BestScoreByScoreValue; responses.Add(personalBest != null ? personalBest.GetString() : ""); var leaderboardScores = scores.GetScoresGroupedByUsersBest().Take(50); @@ -373,75 +175,40 @@ public async Task GetBeatmapScores(Session session, int setId, GameMode return string.Join("\n", responses); } - private async Task> ExecuteWithRetry( - Session session, - string scoreSerialized, - Func>> initialCall, - Func>> retryCall, - string? notFoundNotification, - string resultInstanceName) + private async Task EnqueueForBackgroundRetry(ScoreSubmissionRequest candidate, Session userSession, ScoreProcessingError? error = null) { - var result = await initialCall(); - - if (!result.IsFailure) - return result; - - var isNotFound = result.Error.Status == HttpStatusCode.NotFound; + var shouldParkAsFailed = error is { Disposition: ScoreProcessingDisposition.Permanent } + || error.HasValue && Configuration.ScoreProcessingMaxRetries <= 0; - if (isNotFound) + var enqueueResult = await database.CommitAsTransactionAsync(async () => { - if (notFoundNotification != null) - session.SendNotification(notFoundNotification); - - SubmitScoreHelper.ReportRejectionToMetrics(session, scoreSerialized, $"Invalid request: {resultInstanceName} not found"); - return result; - } - - session.SendNotification("One of your recent score seems to have troubles retrieving the beatmap data from. This score can be missing in your profile or leaderboards for now, but it will be fixed automatically once we can retrieve the beatmap data."); - - SubmitScoreHelper.ReportRejectionToMetrics(session, scoreSerialized, $"{resultInstanceName} retrieval failed, retrying..."); - - result = await retryCall(); - - if (!result.IsFailure) - return result; + await database.ScoreSubmissionRequests.AddQueueEntry(candidate); - isNotFound = result.Error.Status == HttpStatusCode.NotFound; - - SubmitScoreHelper.ReportRejectionToMetrics(session, - scoreSerialized, - isNotFound - ? $"Invalid request: {resultInstanceName} not found" - : $"{resultInstanceName} couldn't be retrieved due to ratelimit timeout, please report this to the developer."); - - return result; - } - - private async Task SaveRejectedScore(Score score) - { - score.SubmissionStatus = SubmissionStatus.Deleted; - await database.Scores.AddScore(score); - } - - private async Task CheckScoreClientVersion(string scoreOsuVersion, string formOsuVersion) - { - var versionString = !string.IsNullOrWhiteSpace(scoreOsuVersion) ? scoreOsuVersion : formOsuVersion; - var clientVersion = OsuVersion.TryParse(versionString); + var task = new ScoreProcessingTask + { + TaskType = ScoreTaskType.Submission, + ScoreSubmissionRequest = candidate, + Priority = (int)ScoreProcessingPriority.High, + CreatedAt = DateTime.UtcNow + }; - if (clientVersion == null) - return; + if (shouldParkAsFailed && error.HasValue) + { + var processingError = error.Value; + task.Status = ScoreProcessingStatus.Failed; + task.ErrorCode = processingError.Code; + task.ErrorMessage = processingError.Message; + } - var latestVersion = await osuVersionService.GetLatestVersion(clientVersion.Stream); + await database.ScoreProcessingTasks.AddQueueEntry(task); + }); - if (latestVersion == null) - return; + if (enqueueResult.IsFailure) + throw new Exception($"Failed to enqueue score for background retry: {enqueueResult.Error}"); - if (clientVersion < latestVersion) + if (!shouldParkAsFailed) { - Log.Warning("Score submitted with outdated osu! client version {ClientVersion} (stream: {Stream}, latest: {LatestVersion})", - clientVersion, - clientVersion.Stream, - latestVersion); + userSession.SendNotification("One of your recent scores seems to have trouble retrieving its beatmap data. This score may be missing from your profile or the leaderboards for now, but it will be fixed automatically once we can retrieve the beatmap data."); } } } \ No newline at end of file diff --git a/Sunrise.Server/Sunrise.Server.csproj b/Sunrise.Server/Sunrise.Server.csproj index fa95d81c..6c7ab677 100644 --- a/Sunrise.Server/Sunrise.Server.csproj +++ b/Sunrise.Server/Sunrise.Server.csproj @@ -16,15 +16,16 @@ - + + diff --git a/Sunrise.Shared.Tests/Application/RecurringJobsTests.cs b/Sunrise.Shared.Tests/Application/RecurringJobsTests.cs index d8afa227..947956e7 100644 --- a/Sunrise.Shared.Tests/Application/RecurringJobsTests.cs +++ b/Sunrise.Shared.Tests/Application/RecurringJobsTests.cs @@ -171,6 +171,7 @@ await Database.DbContext.UserStats.ExecuteUpdateAsync(setters => setters } } + // TODO: This test seems to hit 900-1500ms limits. I bump it from 1500 to 2000, but we need to optimise the snapshot saving process as a proper fix. [Fact] public async Task TestSaveUsersStatsSnapshotsShouldNotTakeLongerThanExpectedForMultipleUsers() { @@ -203,6 +204,6 @@ await Database.DbContext.UserStats.ExecuteUpdateAsync(setters => setters Assert.Empty(emptySnapshots); } - Assert.True(timer.ElapsedMilliseconds < 1500, $"Login took too long, possible performance issue with multiple active sessions. Took: {timer.ElapsedMilliseconds}ms"); + Assert.True(timer.ElapsedMilliseconds < 2000, $"Login took too long, possible performance issue with multiple active sessions. Took: {timer.ElapsedMilliseconds}ms"); } } \ No newline at end of file diff --git a/Sunrise.Shared.Tests/Utils/ModsValidationUtilTests.cs b/Sunrise.Shared.Tests/Utils/ModsValidationUtilTests.cs new file mode 100644 index 00000000..627c36b1 --- /dev/null +++ b/Sunrise.Shared.Tests/Utils/ModsValidationUtilTests.cs @@ -0,0 +1,95 @@ +using osu.Shared; +using Sunrise.Shared.Utils; +using Sunrise.Tests.Abstracts; +using Mods = osu.Shared.Mods; + +namespace Sunrise.Shared.Tests.Utils; + +public class ModsValidationUtilTests : BaseTest +{ + [Theory] + [InlineData(Mods.Target)] + [InlineData(Mods.Random)] + [InlineData(Mods.KeyCoop)] + [InlineData(Mods.Cinema)] + [InlineData(Mods.Autoplay)] + public void TestValidateModsWithForbiddenModsReturnsFailure(Mods mods) + { + // Arrange & Act + var result = ModsValidationUtil.ValidateMods(mods, GameMode.Standard); + + // Assert + Assert.True(result.IsFailure); + } + + [Fact] + public void TestValidateModsWithAllowedModsReturnsSuccess() + { + // Arrange & Act + var result = ModsValidationUtil.ValidateMods(Mods.Hidden | Mods.HardRock, GameMode.Standard); + + // Assert + Assert.True(result.IsSuccess); + } + + [Theory] + [InlineData(GameMode.Standard, Mods.SpunOut)] + [InlineData(GameMode.Standard, Mods.DoubleTime | Mods.Nightcore)] + [InlineData(GameMode.Standard, Mods.SuddenDeath | Mods.Perfect)] + [InlineData(GameMode.Mania, Mods.Key4)] + [InlineData(GameMode.Mania, Mods.FadeIn)] + [InlineData(GameMode.Mania, Mods.Mirror)] + [InlineData(GameMode.Taiko, Mods.Hidden | Mods.HardRock)] + [InlineData(GameMode.CatchTheBeat, Mods.DoubleTime | Mods.Flashlight)] + public void TestValidateModsWithGameModeAllowedModsReturnsSuccess(GameMode gameMode, Mods mods) + { + // Arrange & Act + var result = ModsValidationUtil.ValidateMods(mods, gameMode); + + // Assert + Assert.True(result.IsSuccess); + } + + [Theory] + [InlineData(GameMode.Standard, Mods.Key4)] + [InlineData(GameMode.Standard, Mods.FadeIn)] + [InlineData(GameMode.Taiko, Mods.SpunOut)] + [InlineData(GameMode.CatchTheBeat, Mods.Mirror)] + public void TestValidateModsWithGameModeForbiddenModsReturnsFailure(GameMode gameMode, Mods mods) + { + // Arrange & Act + var result = ModsValidationUtil.ValidateMods(mods, gameMode); + + // Assert + Assert.True(result.IsFailure); + } + + [Theory] + [InlineData(GameMode.Standard, Mods.None)] + [InlineData(GameMode.Standard, Mods.TouchDevice)] + [InlineData(GameMode.Standard, Mods.ScoreV2)] + [InlineData(GameMode.Standard, Mods.TouchDevice | Mods.ScoreV2)] + public void TestValidateModsWithIgnoredModsReturnsSuccess(GameMode gameMode, Mods mods) + { + // Arrange & Act + var result = ModsValidationUtil.ValidateMods(mods, gameMode); + + // Assert + Assert.True(result.IsSuccess); + } + + [Theory] + [InlineData(GameMode.Standard, Mods.DoubleTime | Mods.HalfTime)] + [InlineData(GameMode.Standard, Mods.NoFail | Mods.SuddenDeath)] + [InlineData(GameMode.Standard, Mods.Easy | Mods.HardRock)] + [InlineData(GameMode.Mania, Mods.Key1 | Mods.Key2)] + [InlineData(GameMode.Standard, Mods.Relax | Mods.ScoreV2)] + public void TestValidateModsWithSingleInstanceConflictsReturnsFailure(GameMode gameMode, Mods mods) + { + // Arrange & Act + var result = ModsValidationUtil.ValidateMods(mods, gameMode); + + // Assert + Assert.True(result.IsFailure); + } +} \ No newline at end of file diff --git a/Sunrise.Shared/Application/Configuration.cs b/Sunrise.Shared/Application/Configuration.cs index d5b7cd30..2211bb24 100644 --- a/Sunrise.Shared/Application/Configuration.cs +++ b/Sunrise.Shared/Application/Configuration.cs @@ -191,6 +191,39 @@ public static string WebTokenSecret public static string? BotUsername { get; set; } = ""; public static string BotPrefix => Config.GetSection("Bot").GetValue("Prefix") ?? ""; + // Score processing queue section + public static int ScoreProcessingTimeoutSeconds => + Config.GetSection("ScoreProcessing").GetValue("TimeoutSeconds") ?? 10; + + public static int ScoreProcessingMaxConcurrency => + Config.GetSection("ScoreProcessing").GetValue("MaxConcurrency") ?? 3; + + public static int ScoreProcessingPollerInterBatchDelaySeconds => + Config.GetSection("ScoreProcessing").GetValue("PollerInterBatchDelaySeconds") + ?? Config.GetSection("ScoreProcessing").GetValue("PollerIntervalSeconds") + ?? 1; + + public static int ScoreProcessingMaxRetries => + Config.GetSection("ScoreProcessing").GetValue("MaxRetries") ?? 10; + + public static int ScoreProcessingBatchLeaseSeconds => + Config.GetSection("ScoreProcessing").GetValue("BatchLeaseSeconds") ?? 120; + + public static TimeSpan ScoreProcessingBatchLease => + TimeSpan.FromSeconds(ScoreProcessingBatchLeaseSeconds); + + public static TimeSpan[] ScoreProcessingBackoffSchedule => + Config.GetSection("ScoreProcessing").GetSection("BackoffScheduleSeconds").Get() + ?.Select(seconds => TimeSpan.FromSeconds(seconds)).ToArray() + ?? + [ + TimeSpan.FromSeconds(15), + TimeSpan.FromSeconds(30), + TimeSpan.FromMinutes(1), + TimeSpan.FromMinutes(15), + TimeSpan.FromHours(1) + ]; + // Redis section public static string RedisConnection => GetValuesFromEnvOrFallbackToDeprecatedConfigIfCantAccessEnv("REDIS_HOST", () => string.Format("{0}:{1}", diff --git a/Sunrise.Shared/Application/RecurringJobs.cs b/Sunrise.Shared/Application/RecurringJobs.cs index 514e3881..86b64d1b 100644 --- a/Sunrise.Shared/Application/RecurringJobs.cs +++ b/Sunrise.Shared/Application/RecurringJobs.cs @@ -25,7 +25,7 @@ public static void Initialize() RecurringJob.AddOrUpdate("Save users stats snapshots", () => SaveUsersStatsSnapshots(CancellationToken.None), "59 23 * * *"); // At 23:59 UTC - // TODO: Disabling inactive users is not really was thinking through. I would keep it as deprecated for now and maybe revisit it in the future. + // TODO: Disabling inactive users wasn't fully thought through. I would keep it as deprecated for now and maybe revisit it in the future. // RecurringJob.AddOrUpdate("Disable inactive users", () => DisableInactiveUsers(CancellationToken.None), "0 1 * * *"); // At 01:00 UTC RecurringJob.AddOrUpdate("Refresh users hypes", () => RefreshUsersHypes(CancellationToken.None), "0 0 * * 1"); // At 00:00 UTC on Monday @@ -54,7 +54,7 @@ public static async Task SaveUsersStatsSnapshots(CancellationToken ct) foreach (var i in Enum.GetValues()) { - for (var x = 1;; x++) + for (var x = 1; ; x++) { var usersStats = await database.Users.Stats.GetUsersStats(i, LeaderboardSortType.Pp, @@ -110,7 +110,7 @@ public static async Task DisableInactiveUsers(CancellationToken ct) var pageSize = 50; - for (var i = 1;; i++) + for (var i = 1; ; i++) { var users = await database.Users.GetValidUsers(options: new QueryOptions(new Pagination(i, pageSize)), ct: ct); @@ -132,12 +132,12 @@ public static async Task RefreshUsersHypes(CancellationToken ct) var pageSize = 50; - for (var i = 1;; i++) + for (var i = 1; ; i++) { var users = await database.Users.GetUsers(options: new QueryOptions(new Pagination(i, pageSize)) - { - QueryModifier = q => q.Cast().Include(x => x.Inventory.Where(y => y.ItemType == ItemType.Hype)) - }, + { + QueryModifier = q => q.Cast().Include(x => x.Inventory.Where(y => y.ItemType == ItemType.Hype)) + }, ct: ct); foreach (var user in users.Where(user => diff --git a/Sunrise.Shared/Application/SunriseMetrics.cs b/Sunrise.Shared/Application/SunriseMetrics.cs index 0baf7561..1a0f8a07 100644 --- a/Sunrise.Shared/Application/SunriseMetrics.cs +++ b/Sunrise.Shared/Application/SunriseMetrics.cs @@ -6,6 +6,7 @@ using Serilog; using Sunrise.Shared.Database; using Sunrise.Shared.Enums; +using Sunrise.Shared.Enums.Scores; using Sunrise.Shared.Objects.Sessions; using Sunrise.Shared.Repositories; using Sunrise.Shared.Repositories.Multiplayer; @@ -33,6 +34,38 @@ public class SunriseMetrics "scores_submitted_total", description: "Counts the total number of successfully submitted scores"); + private static readonly Counter ScoreProcessingPollerRunsCounter = SunriseMeter.CreateCounter( + "score_processing_poller_runs_total", + description: "Counts each Hangfire ProcessQueue tick, tagged by outcome (empty, drained, cancelled, error)"); + + private static readonly Counter ScoreProcessingEntriesCounter = SunriseMeter.CreateCounter( + "score_processing_entries_total", + description: "Counts individual queue-entry outcomes, tagged by outcome (success, permanent_failure, retryable_failure, unexpected)"); + + private static readonly ObservableGauge ScoreProcessingQueueDepthPendingGauge = SunriseMeter.CreateObservableGauge( + "score_processing_queue_depth_pending", + () => _cachedQueueDepthByStatus?.GetValueOrDefault(ScoreProcessingStatus.Pending, 0) ?? 0, + "entries", + "Current number of queue rows in Pending status"); + + private static readonly ObservableGauge ScoreProcessingQueueDepthProcessingGauge = SunriseMeter.CreateObservableGauge( + "score_processing_queue_depth_processing", + () => _cachedQueueDepthByStatus?.GetValueOrDefault(ScoreProcessingStatus.Processing, 0) ?? 0, + "entries", + "Current number of queue rows in Processing status"); + + private static readonly ObservableGauge ScoreProcessingQueueDepthFailedGauge = SunriseMeter.CreateObservableGauge( + "score_processing_queue_depth_failed", + () => _cachedQueueDepthByStatus?.GetValueOrDefault(ScoreProcessingStatus.Failed, 0) ?? 0, + "entries", + "Current number of queue rows in Failed status (exhausted retries)"); + + private static readonly ObservableGauge ScoreProcessingLastRunSecondsGauge = SunriseMeter.CreateObservableGauge( + "score_processing_last_run_age_seconds", + GetSecondsSinceLastPollerRun, + "seconds", + "Seconds since the Hangfire ProcessQueue job last completed; alert if this grows unbounded"); + private static readonly ObservableGauge CurrentMatchesGauge = SunriseMeter.CreateObservableGauge( "current_matches_count", GetCurrentMatchesCount, @@ -145,6 +178,8 @@ public class SunriseMetrics private static int _cachedTotalUsers; private static int _cachedTotalRestrictedUsers; private static Dictionary _cachedScoresByGameMode = new(); + private static Dictionary _cachedQueueDepthByStatus = new(); + private static DateTime? _lastPollerRunCompletedAt; public SunriseMetrics() { @@ -162,10 +197,36 @@ public static async Task RefreshDatabaseMetricsAsync() var totalUsers = await database.Users.CountUsers(); var restrictedUsers = await database.Users.CountRestrictedUsers(); var scoresByMode = await database.Scores.CountScoresByGameMode(); + var queueDepth = await database.ScoreProcessingTasks.CountByStatus(); _cachedTotalUsers = totalUsers; _cachedTotalRestrictedUsers = restrictedUsers; _cachedScoresByGameMode = scoresByMode; + _cachedQueueDepthByStatus = queueDepth; + } + + public static void ScoreProcessingPollerRunCounterInc(string outcome, int batchCount) + { + _lastPollerRunCompletedAt = DateTime.UtcNow; + ScoreProcessingPollerRunsCounter.Add(1, + new KeyValuePair("outcome", outcome), + new KeyValuePair("batch_count", batchCount)); + } + + public static void ScoreProcessingEntryCounterInc(string outcome, ScoreTaskType taskType, ScoreProcessingErrorCode? code = null) + { + ScoreProcessingEntriesCounter.Add(1, + new KeyValuePair("outcome", outcome), + new KeyValuePair("task_type", taskType.ToString()), + new KeyValuePair("error_code", code?.ToString() ?? "none")); + } + + private static long GetSecondsSinceLastPollerRun() + { + if (_lastPollerRunCompletedAt == null) + return -1; + + return (long)(DateTime.UtcNow - _lastPollerRunCompletedAt.Value).TotalSeconds; } public static void PacketHandlingCounterInc(BanchoPacket packet, Session session) @@ -183,22 +244,23 @@ public static void ExternalApiRequestsCounterInc(ApiType type, ApiServer server, new KeyValuePair("user_id", session.UserId.ToString())); } - public static void RequestReturnedErrorCounterInc(string requestType, Session? session, string? errorMessage) + [Obsolete("Please use logger instead")] + public static void RequestReturnedErrorCounterInc(string requestType, int userId, string? errorMessage) { RequestReturnedErrorCounter.Add(1, new KeyValuePair("request_type", requestType), - new KeyValuePair("user_id", session?.UserId.ToString() ?? "-1")); + new KeyValuePair("user_id", userId.ToString())); Log.Error("Request {RequestType} by (user id: {UserId}) returned error: {ErrorMessage}", requestType, - session?.UserId.ToString() ?? "-1", + userId.ToString(), errorMessage ?? "Not specified"); } - public static void ScoreSubmittedCounterInc(Session session, int beatmapId, GameMode gameMode, Mods mods, double pp, int scoreId) + public static void ScoreSubmittedCounterInc(int userId, int beatmapId, GameMode gameMode, Mods mods, double pp, int scoreId) { ScoresSubmittedCounter.Add(1, - new KeyValuePair("user_id", session.UserId.ToString()), + new KeyValuePair("user_id", userId.ToString()), new KeyValuePair("beatmap_id", beatmapId.ToString()), new KeyValuePair("game_mode", gameMode.ToString()), new KeyValuePair("mods", mods.ToString()), diff --git a/Sunrise.Shared/Database/DatabaseService.cs b/Sunrise.Shared/Database/DatabaseService.cs index 7d114f93..1294179c 100644 --- a/Sunrise.Shared/Database/DatabaseService.cs +++ b/Sunrise.Shared/Database/DatabaseService.cs @@ -21,6 +21,8 @@ public sealed class DatabaseService( EventRepository eventRepository, ScoreRepository scoreRepository, MedalRepository medalRepository, + ScoreSubmissionRequestRepository scoreSubmissionRequestRepository, + ScoreProcessingTaskRepository scoreProcessingTaskRepository, IEFCacheServiceProvider? cacheProvider = null) { @@ -30,6 +32,8 @@ public sealed class DatabaseService( public readonly MedalRepository Medals = medalRepository; public readonly RedisRepository Redis = redis; public readonly ScoreRepository Scores = scoreRepository; + public readonly ScoreSubmissionRequestRepository ScoreSubmissionRequests = scoreSubmissionRequestRepository; + public readonly ScoreProcessingTaskRepository ScoreProcessingTasks = scoreProcessingTaskRepository; public readonly UserRepository Users = userRepository; public async Task FlushAndUpdateRedisCache(bool isSoftFlush = true) @@ -146,4 +150,4 @@ void OnSavingChanges(object? sender, SavingChangesEventArgs e) } } } -} \ No newline at end of file +} diff --git a/Sunrise.Shared/Database/Migrations/20260419233843_AddScoreSubmissionRequestAndProcessingTask.Designer.cs b/Sunrise.Shared/Database/Migrations/20260419233843_AddScoreSubmissionRequestAndProcessingTask.Designer.cs new file mode 100644 index 00000000..4939baa2 --- /dev/null +++ b/Sunrise.Shared/Database/Migrations/20260419233843_AddScoreSubmissionRequestAndProcessingTask.Designer.cs @@ -0,0 +1,1091 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Sunrise.Shared.Database; + +#nullable disable + +namespace Sunrise.Shared.Database.Migrations +{ + [DbContext(typeof(SunriseDbContext))] + [Migration("20260419233843_AddScoreSubmissionRequestAndProcessingTask")] + partial class AddScoreSubmissionRequestAndProcessingTask + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.22") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Beatmap.BeatmapHype", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("BeatmapSetId") + .HasColumnType("int"); + + b.Property("Hypes") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("BeatmapSetId", "Hypes"); + + b.HasIndex("BeatmapSetId", "UserId") + .IsUnique(); + + b.ToTable("beatmap_hype"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Beatmap.CustomBeatmapStatus", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("BeatmapHash") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.Property("BeatmapSetId") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedByUserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("BeatmapHash"); + + b.HasIndex("BeatmapSetId"); + + b.HasIndex("UpdatedByUserId"); + + b.ToTable("custom_beatmap_status"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Events.EventBeatmap", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("BeatmapSetId") + .HasColumnType("int"); + + b.Property("EventType") + .HasColumnType("int"); + + b.Property("ExecutorId") + .HasColumnType("int"); + + b.Property("JsonData") + .HasColumnType("longtext"); + + b.Property("Time") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("BeatmapSetId"); + + b.HasIndex("ExecutorId"); + + b.ToTable("event_beatmap"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Events.EventUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("EventType") + .HasColumnType("int"); + + b.Property("Ip") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.Property("JsonData") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Time") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("EventType", "Ip"); + + b.HasIndex("EventType", "UserId"); + + b.ToTable("event_user"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Medal", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("Category") + .HasColumnType("int"); + + b.Property("Condition") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Description") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("FileId") + .HasColumnType("int"); + + b.Property("FileUrl") + .HasColumnType("longtext"); + + b.Property("GameMode") + .HasColumnType("tinyint unsigned"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("Category"); + + b.HasIndex("FileId"); + + b.HasIndex("GameMode"); + + b.ToTable("medal"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.MedalFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Path") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("medal_file"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Restriction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("Date") + .HasColumnType("datetime(6)"); + + b.Property("ExecutorId") + .HasColumnType("int"); + + b.Property("ExpiryDate") + .HasColumnType("datetime(6)"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ExecutorId"); + + b.HasIndex("UserId"); + + b.ToTable("restriction"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Score", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("Accuracy") + .HasColumnType("double"); + + b.Property("BeatmapHash") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.Property("BeatmapId") + .HasColumnType("int"); + + b.Property("BeatmapStatus") + .HasColumnType("int"); + + b.Property("ClientTime") + .HasColumnType("datetime(6)"); + + b.Property("Count100") + .HasColumnType("int"); + + b.Property("Count300") + .HasColumnType("int"); + + b.Property("Count50") + .HasColumnType("int"); + + b.Property("CountGeki") + .HasColumnType("int"); + + b.Property("CountKatu") + .HasColumnType("int"); + + b.Property("CountMiss") + .HasColumnType("int"); + + b.Property("GameMode") + .HasColumnType("tinyint unsigned"); + + b.Property("Grade") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("IsPassed") + .HasColumnType("tinyint(1)"); + + b.Property("IsScoreable") + .HasColumnType("tinyint(1)"); + + b.Property("MaxCombo") + .HasColumnType("int"); + + b.Property("Mods") + .HasColumnType("int"); + + b.Property("OsuVersion") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Perfect") + .HasColumnType("tinyint(1)"); + + b.Property("PerformancePoints") + .HasColumnType("double"); + + b.Property("ReplayFileId") + .HasColumnType("int"); + + b.Property("ScoreHash") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.Property("SubmissionStatus") + .HasColumnType("int"); + + b.Property("TotalScore") + .HasColumnType("BIGINT"); + + b.Property("UserId") + .HasColumnType("int"); + + b.Property("WhenPlayed") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("BeatmapHash"); + + b.HasIndex("ReplayFileId"); + + b.HasIndex("ScoreHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "BeatmapId"); + + b.HasIndex("UserId", "SubmissionStatus", "BeatmapStatus"); + + b.HasIndex("BeatmapId", "IsScoreable", "IsPassed", "SubmissionStatus"); + + b.HasIndex("GameMode", "SubmissionStatus", "BeatmapStatus", "WhenPlayed"); + + b.ToTable("score"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Scores.ScoreSubmissionRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("BeatmapHash") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("ClientHash") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("OsuVersion") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("ReplayFileId") + .HasColumnType("int"); + + b.Property("ScoreHash") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.Property("ScoreSerialized") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("StoryboardHash") + .HasColumnType("longtext"); + + b.Property("TimeElapsed") + .HasColumnType("int"); + + b.Property("UserHash") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("UserId") + .HasColumnType("int"); + + b.Property("WhenPlayed") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("ReplayFileId"); + + b.HasIndex("ScoreHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("score_submission_request"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Scores.ScoreProcessingTask", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("ActiveScoreId") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("int") + .HasComputedColumnSql("CASE WHEN Status IN (0, 1) THEN ScoreId ELSE NULL END", true); + + b.Property("ActiveScoreSubmissionRequestId") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("int") + .HasComputedColumnSql("CASE WHEN Status IN (0, 1) THEN ScoreSubmissionRequestId ELSE NULL END", true); + + b.Property("ClaimToken") + .HasColumnType("longtext"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("ErrorCode") + .HasColumnType("int"); + + b.Property("ErrorMessage") + .HasColumnType("longtext"); + + b.Property("LeaseExpiresAt") + .HasColumnType("datetime(6)"); + + b.Property("NextRetryAt") + .HasColumnType("datetime(6)"); + + b.Property("Priority") + .HasColumnType("int"); + + b.Property("RetryCount") + .HasColumnType("int"); + + b.Property("ScoreId") + .HasColumnType("int"); + + b.Property("ScoreSubmissionRequestId") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TaskType") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ActiveScoreId") + .IsUnique() + .HasDatabaseName("UX_score_processing_task_active_score"); + + b.HasIndex("ActiveScoreSubmissionRequestId") + .IsUnique() + .HasDatabaseName("UX_score_processing_task_active_submission_request"); + + b.HasIndex("ScoreId"); + + b.HasIndex("ScoreSubmissionRequestId"); + + b.HasIndex("Status", "LeaseExpiresAt"); + + b.HasIndex("TaskType", "ScoreId"); + + b.HasIndex("Status", "Priority", "NextRetryAt"); + + b.ToTable("score_processing_task", t => + { + t.HasCheckConstraint("CK_score_processing_task_target", "((TaskType = 0 AND ScoreSubmissionRequestId IS NOT NULL AND ScoreId IS NULL) OR (TaskType <> 0 AND ScoreSubmissionRequestId IS NULL AND ScoreId IS NOT NULL))"); + }); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("AccountStatus") + .HasColumnType("int"); + + b.Property("Country") + .HasColumnType("smallint"); + + b.Property("DefaultGameMode") + .HasColumnType("tinyint unsigned"); + + b.Property("Description") + .HasColumnType("longtext"); + + b.Property("Email") + .IsRequired() + .HasColumnType("varchar(255)") + .UseCollation("utf8mb4_unicode_ci"); + + b.Property("LastOnlineTime") + .HasColumnType("datetime(6)"); + + b.Property("Passhash") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Privilege") + .HasColumnType("int"); + + b.Property("RegisterDate") + .HasColumnType("datetime(6)"); + + b.Property("SilencedUntil") + .HasColumnType("datetime(6)"); + + b.Property("Username") + .IsRequired() + .HasColumnType("varchar(255)") + .UseCollation("utf8mb4_unicode_ci"); + + b.HasKey("Id"); + + b.HasIndex("AccountStatus"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("user"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserFavouriteBeatmap", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("BeatmapSetId") + .HasColumnType("int"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "BeatmapSetId"); + + b.ToTable("user_favourite_beatmap"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("OwnerId") + .HasColumnType("int"); + + b.Property("Path") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("OwnerId"); + + b.HasIndex("OwnerId", "Type"); + + b.ToTable("user_file"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserGrades", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("CountA") + .HasColumnType("int"); + + b.Property("CountB") + .HasColumnType("int"); + + b.Property("CountC") + .HasColumnType("int"); + + b.Property("CountD") + .HasColumnType("int"); + + b.Property("CountS") + .HasColumnType("int"); + + b.Property("CountSH") + .HasColumnType("int"); + + b.Property("CountX") + .HasColumnType("int"); + + b.Property("CountXH") + .HasColumnType("int"); + + b.Property("GameMode") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "GameMode") + .IsUnique(); + + b.ToTable("user_grades"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserInventoryItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("ItemType") + .HasColumnType("int"); + + b.Property("Quantity") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemType") + .IsUnique(); + + b.ToTable("user_inventory_item"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserMedals", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("MedalId") + .HasColumnType("int"); + + b.Property("UnlockedAt") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("user_medals"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("Discord") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("varchar(32)"); + + b.Property("Interest") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("varchar(32)"); + + b.Property("Location") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("varchar(32)"); + + b.Property("Occupation") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("varchar(32)"); + + b.Property("Playstyle") + .HasColumnType("int"); + + b.Property("Telegram") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("varchar(32)"); + + b.Property("Twitch") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("varchar(32)"); + + b.Property("Twitter") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("varchar(32)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.Property("Website") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("user_metadata"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserRelationship", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("Relation") + .HasColumnType("int"); + + b.Property("TargetId") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("TargetId"); + + b.HasIndex("UserId", "TargetId"); + + b.ToTable("user_relationship"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserStats", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("Accuracy") + .HasColumnType("double"); + + b.Property("BestCountryRank") + .HasColumnType("BIGINT"); + + b.Property("BestCountryRankDate") + .HasColumnType("datetime(6)"); + + b.Property("BestGlobalRank") + .HasColumnType("BIGINT"); + + b.Property("BestGlobalRankDate") + .HasColumnType("datetime(6)"); + + b.Property("GameMode") + .HasColumnType("tinyint unsigned"); + + b.Property("MaxCombo") + .HasColumnType("int"); + + b.Property("PerformancePoints") + .HasColumnType("double"); + + b.Property("PlayCount") + .HasColumnType("int"); + + b.Property("PlayTime") + .HasColumnType("int"); + + b.Property("RankedScore") + .HasColumnType("BIGINT"); + + b.Property("TotalHits") + .HasColumnType("int"); + + b.Property("TotalScore") + .HasColumnType("BIGINT"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "GameMode") + .IsUnique(); + + b.ToTable("user_stats"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserStatsSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("GameMode") + .HasColumnType("tinyint unsigned"); + + b.Property("SnapshotsJson") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "GameMode"); + + b.ToTable("user_stats_snapshot"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Beatmap.BeatmapHype", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Beatmap.CustomBeatmapStatus", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "UpdatedByUser") + .WithMany() + .HasForeignKey("UpdatedByUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UpdatedByUser"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Events.EventBeatmap", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "Executor") + .WithMany() + .HasForeignKey("ExecutorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Executor"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Events.EventUser", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Medal", b => + { + b.HasOne("Sunrise.Shared.Database.Models.MedalFile", "File") + .WithMany() + .HasForeignKey("FileId"); + + b.Navigation("File"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Restriction", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "Executor") + .WithMany() + .HasForeignKey("ExecutorId"); + + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Executor"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Score", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.UserFile", "ReplayFile") + .WithMany() + .HasForeignKey("ReplayFileId"); + + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ReplayFile"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Scores.ScoreSubmissionRequest", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.UserFile", "ReplayFile") + .WithMany() + .HasForeignKey("ReplayFileId"); + + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ReplayFile"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Scores.ScoreProcessingTask", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Score", "Score") + .WithMany() + .HasForeignKey("ScoreId"); + + b.HasOne("Sunrise.Shared.Database.Models.Scores.ScoreSubmissionRequest", "ScoreSubmissionRequest") + .WithMany() + .HasForeignKey("ScoreSubmissionRequestId"); + + b.Navigation("Score"); + + b.Navigation("ScoreSubmissionRequest"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserFavouriteBeatmap", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserFile", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "User") + .WithMany("UserFiles") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserGrades", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserInventoryItem", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "User") + .WithMany("Inventory") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserMedals", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserMetadata", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserRelationship", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "Target") + .WithMany("UserReceivedRelationships") + .HasForeignKey("TargetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "User") + .WithMany("UserInitiatedRelationships") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Target"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserStats", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "User") + .WithMany("UserStats") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserStatsSnapshot", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "User") + .WithMany("UserStatsSnapshots") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.User", b => + { + b.Navigation("Inventory"); + + b.Navigation("UserFiles"); + + b.Navigation("UserInitiatedRelationships"); + + b.Navigation("UserReceivedRelationships"); + + b.Navigation("UserStats"); + + b.Navigation("UserStatsSnapshots"); + }); +#pragma warning restore 612, 618 + } + } +} + + diff --git a/Sunrise.Shared/Database/Migrations/20260419233843_AddScoreSubmissionRequestAndProcessingTask.cs b/Sunrise.Shared/Database/Migrations/20260419233843_AddScoreSubmissionRequestAndProcessingTask.cs new file mode 100644 index 00000000..b4c049d4 --- /dev/null +++ b/Sunrise.Shared/Database/Migrations/20260419233843_AddScoreSubmissionRequestAndProcessingTask.cs @@ -0,0 +1,204 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using MySql.EntityFrameworkCore.Metadata; + +#nullable disable + +namespace Sunrise.Shared.Database.Migrations +{ + /// + public partial class AddScoreSubmissionRequestAndProcessingTask : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "ScoreHash", + table: "score", + type: "varchar(255)", + nullable: false, + oldClrType: typeof(string), + oldType: "longtext"); + + migrationBuilder.CreateTable( + name: "score_submission_request", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("MySQL:ValueGenerationStrategy", MySQLValueGenerationStrategy.IdentityColumn), + UserId = table.Column(type: "int", nullable: false), + ScoreHash = table.Column(type: "varchar(255)", nullable: false), + ScoreSerialized = table.Column(type: "longtext", nullable: false), + BeatmapHash = table.Column(type: "longtext", nullable: false), + TimeElapsed = table.Column(type: "int", nullable: false), + OsuVersion = table.Column(type: "longtext", nullable: false), + ClientHash = table.Column(type: "longtext", nullable: false), + ReplayFileId = table.Column(type: "int", nullable: true), + StoryboardHash = table.Column(type: "longtext", nullable: true), + UserHash = table.Column(type: "longtext", nullable: false), + WhenPlayed = table.Column(type: "datetime(6)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_score_submission_request", x => x.Id); + table.ForeignKey( + name: "FK_score_submission_request_user_UserId", + column: x => x.UserId, + principalTable: "user", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_score_submission_request_user_file_ReplayFileId", + column: x => x.ReplayFileId, + principalTable: "user_file", + principalColumn: "Id"); + }) + .Annotation("MySQL:Charset", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "score_processing_task", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("MySQL:ValueGenerationStrategy", MySQLValueGenerationStrategy.IdentityColumn), + TaskType = table.Column(type: "int", nullable: false), + ScoreSubmissionRequestId = table.Column(type: "int", nullable: true), + ScoreId = table.Column(type: "int", nullable: true), + Priority = table.Column(type: "int", nullable: false), + Status = table.Column(type: "int", nullable: false), + NextRetryAt = table.Column(type: "datetime(6)", nullable: true), + RetryCount = table.Column(type: "int", nullable: false), + ErrorCode = table.Column(type: "int", nullable: true), + ErrorMessage = table.Column(type: "longtext", nullable: true), + ClaimToken = table.Column(type: "longtext", nullable: true), + LeaseExpiresAt = table.Column(type: "datetime(6)", nullable: true), + CreatedAt = table.Column(type: "datetime(6)", nullable: false), + ActiveScoreId = table.Column(type: "int", nullable: true, computedColumnSql: "CASE WHEN Status IN (0, 1) THEN ScoreId ELSE NULL END", stored: true), + ActiveScoreSubmissionRequestId = table.Column(type: "int", nullable: true, computedColumnSql: "CASE WHEN Status IN (0, 1) THEN ScoreSubmissionRequestId ELSE NULL END", stored: true) + }, + constraints: table => + { + table.PrimaryKey("PK_score_processing_task", x => x.Id); + table.CheckConstraint("CK_score_processing_task_target", "((TaskType = 0 AND ScoreSubmissionRequestId IS NOT NULL AND ScoreId IS NULL) OR (TaskType <> 0 AND ScoreSubmissionRequestId IS NULL AND ScoreId IS NOT NULL))"); + table.ForeignKey( + name: "FK_score_processing_task_score_ScoreId", + column: x => x.ScoreId, + principalTable: "score", + principalColumn: "Id"); + table.ForeignKey( + name: "FK_score_processing_task_score_submission_request_ScoreSubmissi~", + column: x => x.ScoreSubmissionRequestId, + principalTable: "score_submission_request", + principalColumn: "Id"); + }) + .Annotation("MySQL:Charset", "utf8mb4"); + + migrationBuilder.CreateIndex( + name: "IX_score_ScoreHash_Status_Id", + table: "score", + columns: new[] { "ScoreHash", "SubmissionStatus", "Id" }); + + migrationBuilder.Sql(@" + DELETE FROM score + WHERE Id IN ( + SELECT Id FROM ( + SELECT Id, + ROW_NUMBER() OVER ( + PARTITION BY ScoreHash + ORDER BY SubmissionStatus DESC, Id DESC + ) AS rn + FROM score + ) t + WHERE t.rn > 1 + ); + "); + + migrationBuilder.DropIndex( + name: "IX_score_ScoreHash_Status_Id", + table: "score"); + + migrationBuilder.CreateIndex( + name: "IX_score_ScoreHash", + table: "score", + column: "ScoreHash", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_score_submission_request_ReplayFileId", + table: "score_submission_request", + column: "ReplayFileId"); + + migrationBuilder.CreateIndex( + name: "IX_score_submission_request_ScoreHash", + table: "score_submission_request", + column: "ScoreHash", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_score_submission_request_UserId", + table: "score_submission_request", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_score_processing_task_ScoreId", + table: "score_processing_task", + column: "ScoreId"); + + migrationBuilder.CreateIndex( + name: "IX_score_processing_task_ScoreSubmissionRequestId", + table: "score_processing_task", + column: "ScoreSubmissionRequestId"); + + migrationBuilder.CreateIndex( + name: "IX_score_processing_task_Status_LeaseExpiresAt", + table: "score_processing_task", + columns: new[] { "Status", "LeaseExpiresAt" }); + + migrationBuilder.CreateIndex( + name: "IX_score_processing_task_Status_Priority_NextRetryAt", + table: "score_processing_task", + columns: new[] { "Status", "Priority", "NextRetryAt" }); + + migrationBuilder.CreateIndex( + name: "IX_score_processing_task_TaskType_ScoreId", + table: "score_processing_task", + columns: new[] { "TaskType", "ScoreId" }); + + migrationBuilder.CreateIndex( + name: "UX_score_processing_task_active_submission_request", + table: "score_processing_task", + column: "ActiveScoreSubmissionRequestId", + unique: true); + + migrationBuilder.CreateIndex( + name: "UX_score_processing_task_active_score", + table: "score_processing_task", + column: "ActiveScoreId", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "score_processing_task"); + + migrationBuilder.DropTable( + name: "score_submission_request"); + + migrationBuilder.DropIndex( + name: "IX_score_ScoreHash", + table: "score"); + + migrationBuilder.AlterColumn( + name: "ScoreHash", + table: "score", + type: "longtext", + nullable: false, + oldClrType: typeof(string), + oldType: "varchar(255)"); + } + } +} + + diff --git a/Sunrise.Shared/Database/Migrations/20260510131203_AddTimeElapsedEntityToScore.Designer.cs b/Sunrise.Shared/Database/Migrations/20260510131203_AddTimeElapsedEntityToScore.Designer.cs new file mode 100644 index 00000000..331f7e3d --- /dev/null +++ b/Sunrise.Shared/Database/Migrations/20260510131203_AddTimeElapsedEntityToScore.Designer.cs @@ -0,0 +1,1095 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Sunrise.Shared.Database; + +#nullable disable + +namespace Sunrise.Shared.Database.Migrations +{ + [DbContext(typeof(SunriseDbContext))] + [Migration("20260510131203_AddTimeElapsedEntityToScore")] + partial class AddTimeElapsedEntityToScore + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.22") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Beatmap.BeatmapHype", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("BeatmapSetId") + .HasColumnType("int"); + + b.Property("Hypes") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("BeatmapSetId", "Hypes"); + + b.HasIndex("BeatmapSetId", "UserId") + .IsUnique(); + + b.ToTable("beatmap_hype"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Beatmap.CustomBeatmapStatus", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("BeatmapHash") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.Property("BeatmapSetId") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedByUserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("BeatmapHash"); + + b.HasIndex("BeatmapSetId"); + + b.HasIndex("UpdatedByUserId"); + + b.ToTable("custom_beatmap_status"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Events.EventBeatmap", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("BeatmapSetId") + .HasColumnType("int"); + + b.Property("EventType") + .HasColumnType("int"); + + b.Property("ExecutorId") + .HasColumnType("int"); + + b.Property("JsonData") + .HasColumnType("longtext"); + + b.Property("Time") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("BeatmapSetId"); + + b.HasIndex("ExecutorId"); + + b.ToTable("event_beatmap"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Events.EventUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("EventType") + .HasColumnType("int"); + + b.Property("Ip") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.Property("JsonData") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Time") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("EventType", "Ip"); + + b.HasIndex("EventType", "UserId"); + + b.ToTable("event_user"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Medal", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("Category") + .HasColumnType("int"); + + b.Property("Condition") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Description") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("FileId") + .HasColumnType("int"); + + b.Property("FileUrl") + .HasColumnType("longtext"); + + b.Property("GameMode") + .HasColumnType("tinyint unsigned"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("Category"); + + b.HasIndex("FileId"); + + b.HasIndex("GameMode"); + + b.ToTable("medal"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.MedalFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Path") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("medal_file"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Restriction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("Date") + .HasColumnType("datetime(6)"); + + b.Property("ExecutorId") + .HasColumnType("int"); + + b.Property("ExpiryDate") + .HasColumnType("datetime(6)"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ExecutorId"); + + b.HasIndex("UserId"); + + b.ToTable("restriction"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Score", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("Accuracy") + .HasColumnType("double"); + + b.Property("BeatmapHash") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.Property("BeatmapId") + .HasColumnType("int"); + + b.Property("BeatmapStatus") + .HasColumnType("int"); + + b.Property("ClientTime") + .HasColumnType("datetime(6)"); + + b.Property("Count100") + .HasColumnType("int"); + + b.Property("Count300") + .HasColumnType("int"); + + b.Property("Count50") + .HasColumnType("int"); + + b.Property("CountGeki") + .HasColumnType("int"); + + b.Property("CountKatu") + .HasColumnType("int"); + + b.Property("CountMiss") + .HasColumnType("int"); + + b.Property("GameMode") + .HasColumnType("tinyint unsigned"); + + b.Property("Grade") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("IsPassed") + .HasColumnType("tinyint(1)"); + + b.Property("IsScoreable") + .HasColumnType("tinyint(1)"); + + b.Property("MaxCombo") + .HasColumnType("int"); + + b.Property("Mods") + .HasColumnType("int"); + + b.Property("OsuVersion") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Perfect") + .HasColumnType("tinyint(1)"); + + b.Property("PerformancePoints") + .HasColumnType("double"); + + b.Property("ReplayFileId") + .HasColumnType("int"); + + b.Property("ScoreHash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("varchar(32)"); + + b.Property("SubmissionStatus") + .HasColumnType("int"); + + b.Property("TimeElapsed") + .HasColumnType("int"); + + b.Property("TotalScore") + .HasColumnType("BIGINT"); + + b.Property("UserId") + .HasColumnType("int"); + + b.Property("WhenPlayed") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("BeatmapHash"); + + b.HasIndex("ReplayFileId"); + + b.HasIndex("ScoreHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "BeatmapId"); + + b.HasIndex("UserId", "SubmissionStatus", "BeatmapStatus"); + + b.HasIndex("BeatmapId", "IsScoreable", "IsPassed", "SubmissionStatus"); + + b.HasIndex("GameMode", "SubmissionStatus", "BeatmapStatus", "WhenPlayed"); + + b.ToTable("score"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Scores.ScoreSubmissionRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("BeatmapHash") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("ClientHash") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("OsuVersion") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("ReplayFileId") + .HasColumnType("int"); + + b.Property("ScoreHash") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.Property("ScoreSerialized") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("StoryboardHash") + .HasColumnType("longtext"); + + b.Property("TimeElapsed") + .HasColumnType("int"); + + b.Property("UserHash") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("UserId") + .HasColumnType("int"); + + b.Property("WhenPlayed") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("ReplayFileId"); + + b.HasIndex("ScoreHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("score_submission_request"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Scores.ScoreProcessingTask", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("ActiveScoreId") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("int") + .HasComputedColumnSql("CASE WHEN Status IN (0, 1) THEN ScoreId ELSE NULL END", true); + + b.Property("ActiveScoreSubmissionRequestId") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("int") + .HasComputedColumnSql("CASE WHEN Status IN (0, 1) THEN ScoreSubmissionRequestId ELSE NULL END", true); + + b.Property("ClaimToken") + .HasColumnType("longtext"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("ErrorCode") + .HasColumnType("int"); + + b.Property("ErrorMessage") + .HasColumnType("longtext"); + + b.Property("LeaseExpiresAt") + .HasColumnType("datetime(6)"); + + b.Property("NextRetryAt") + .HasColumnType("datetime(6)"); + + b.Property("Priority") + .HasColumnType("int"); + + b.Property("RetryCount") + .HasColumnType("int"); + + b.Property("ScoreId") + .HasColumnType("int"); + + b.Property("ScoreSubmissionRequestId") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TaskType") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ActiveScoreId") + .IsUnique() + .HasDatabaseName("UX_score_processing_task_active_score"); + + b.HasIndex("ActiveScoreSubmissionRequestId") + .IsUnique() + .HasDatabaseName("UX_score_processing_task_active_submission_request"); + + b.HasIndex("ScoreId"); + + b.HasIndex("ScoreSubmissionRequestId"); + + b.HasIndex("Status", "LeaseExpiresAt"); + + b.HasIndex("TaskType", "ScoreId"); + + b.HasIndex("Status", "Priority", "NextRetryAt"); + + b.ToTable("score_processing_task", t => + { + t.HasCheckConstraint("CK_score_processing_task_target", "((TaskType = 0 AND ScoreSubmissionRequestId IS NOT NULL AND ScoreId IS NULL) OR (TaskType <> 0 AND ScoreSubmissionRequestId IS NULL AND ScoreId IS NOT NULL))"); + }); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("AccountStatus") + .HasColumnType("int"); + + b.Property("Country") + .HasColumnType("smallint"); + + b.Property("DefaultGameMode") + .HasColumnType("tinyint unsigned"); + + b.Property("Description") + .HasColumnType("longtext"); + + b.Property("Email") + .IsRequired() + .HasColumnType("varchar(255)") + .UseCollation("utf8mb4_unicode_ci"); + + b.Property("LastOnlineTime") + .HasColumnType("datetime(6)"); + + b.Property("Passhash") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Privilege") + .HasColumnType("int"); + + b.Property("RegisterDate") + .HasColumnType("datetime(6)"); + + b.Property("SilencedUntil") + .HasColumnType("datetime(6)"); + + b.Property("Username") + .IsRequired() + .HasColumnType("varchar(255)") + .UseCollation("utf8mb4_unicode_ci"); + + b.HasKey("Id"); + + b.HasIndex("AccountStatus"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("user"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserFavouriteBeatmap", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("BeatmapSetId") + .HasColumnType("int"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "BeatmapSetId"); + + b.ToTable("user_favourite_beatmap"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("OwnerId") + .HasColumnType("int"); + + b.Property("Path") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("OwnerId"); + + b.HasIndex("OwnerId", "Type"); + + b.ToTable("user_file"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserGrades", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("CountA") + .HasColumnType("int"); + + b.Property("CountB") + .HasColumnType("int"); + + b.Property("CountC") + .HasColumnType("int"); + + b.Property("CountD") + .HasColumnType("int"); + + b.Property("CountS") + .HasColumnType("int"); + + b.Property("CountSH") + .HasColumnType("int"); + + b.Property("CountX") + .HasColumnType("int"); + + b.Property("CountXH") + .HasColumnType("int"); + + b.Property("GameMode") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "GameMode") + .IsUnique(); + + b.ToTable("user_grades"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserInventoryItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("ItemType") + .HasColumnType("int"); + + b.Property("Quantity") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemType") + .IsUnique(); + + b.ToTable("user_inventory_item"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserMedals", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("MedalId") + .HasColumnType("int"); + + b.Property("UnlockedAt") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("user_medals"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("Discord") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("varchar(32)"); + + b.Property("Interest") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("varchar(32)"); + + b.Property("Location") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("varchar(32)"); + + b.Property("Occupation") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("varchar(32)"); + + b.Property("Playstyle") + .HasColumnType("int"); + + b.Property("Telegram") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("varchar(32)"); + + b.Property("Twitch") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("varchar(32)"); + + b.Property("Twitter") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("varchar(32)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.Property("Website") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("user_metadata"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserRelationship", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("Relation") + .HasColumnType("int"); + + b.Property("TargetId") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("TargetId"); + + b.HasIndex("UserId", "TargetId"); + + b.ToTable("user_relationship"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserStats", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("Accuracy") + .HasColumnType("double"); + + b.Property("BestCountryRank") + .HasColumnType("BIGINT"); + + b.Property("BestCountryRankDate") + .HasColumnType("datetime(6)"); + + b.Property("BestGlobalRank") + .HasColumnType("BIGINT"); + + b.Property("BestGlobalRankDate") + .HasColumnType("datetime(6)"); + + b.Property("GameMode") + .HasColumnType("tinyint unsigned"); + + b.Property("MaxCombo") + .HasColumnType("int"); + + b.Property("PerformancePoints") + .HasColumnType("double"); + + b.Property("PlayCount") + .HasColumnType("int"); + + b.Property("PlayTime") + .HasColumnType("int"); + + b.Property("RankedScore") + .HasColumnType("BIGINT"); + + b.Property("TotalHits") + .HasColumnType("int"); + + b.Property("TotalScore") + .HasColumnType("BIGINT"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "GameMode") + .IsUnique(); + + b.ToTable("user_stats"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserStatsSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("GameMode") + .HasColumnType("tinyint unsigned"); + + b.Property("SnapshotsJson") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "GameMode"); + + b.ToTable("user_stats_snapshot"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Beatmap.BeatmapHype", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Beatmap.CustomBeatmapStatus", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "UpdatedByUser") + .WithMany() + .HasForeignKey("UpdatedByUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UpdatedByUser"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Events.EventBeatmap", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "Executor") + .WithMany() + .HasForeignKey("ExecutorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Executor"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Events.EventUser", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Medal", b => + { + b.HasOne("Sunrise.Shared.Database.Models.MedalFile", "File") + .WithMany() + .HasForeignKey("FileId"); + + b.Navigation("File"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Restriction", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "Executor") + .WithMany() + .HasForeignKey("ExecutorId"); + + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Executor"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Score", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.UserFile", "ReplayFile") + .WithMany() + .HasForeignKey("ReplayFileId"); + + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ReplayFile"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Scores.ScoreSubmissionRequest", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.UserFile", "ReplayFile") + .WithMany() + .HasForeignKey("ReplayFileId"); + + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ReplayFile"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Scores.ScoreProcessingTask", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Score", "Score") + .WithMany() + .HasForeignKey("ScoreId"); + + b.HasOne("Sunrise.Shared.Database.Models.Scores.ScoreSubmissionRequest", "ScoreSubmissionRequest") + .WithMany() + .HasForeignKey("ScoreSubmissionRequestId"); + + b.Navigation("Score"); + + b.Navigation("ScoreSubmissionRequest"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserFavouriteBeatmap", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserFile", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "User") + .WithMany("UserFiles") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserGrades", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserInventoryItem", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "User") + .WithMany("Inventory") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserMedals", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserMetadata", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserRelationship", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "Target") + .WithMany("UserReceivedRelationships") + .HasForeignKey("TargetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "User") + .WithMany("UserInitiatedRelationships") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Target"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserStats", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "User") + .WithMany("UserStats") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserStatsSnapshot", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "User") + .WithMany("UserStatsSnapshots") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.User", b => + { + b.Navigation("Inventory"); + + b.Navigation("UserFiles"); + + b.Navigation("UserInitiatedRelationships"); + + b.Navigation("UserReceivedRelationships"); + + b.Navigation("UserStats"); + + b.Navigation("UserStatsSnapshots"); + }); +#pragma warning restore 612, 618 + } + } +} + + diff --git a/Sunrise.Shared/Database/Migrations/20260510131203_AddTimeElapsedEntityToScore.cs b/Sunrise.Shared/Database/Migrations/20260510131203_AddTimeElapsedEntityToScore.cs new file mode 100644 index 00000000..5b302a32 --- /dev/null +++ b/Sunrise.Shared/Database/Migrations/20260510131203_AddTimeElapsedEntityToScore.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Sunrise.Shared.Database.Migrations +{ + /// + public partial class AddTimeElapsedEntityToScore : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "TimeElapsed", + table: "score", + type: "int", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "TimeElapsed", + table: "score"); + } + } +} diff --git a/Sunrise.Shared/Database/Migrations/20260510161526_LimitScoreHashTo32Characters.Designer.cs b/Sunrise.Shared/Database/Migrations/20260510161526_LimitScoreHashTo32Characters.Designer.cs new file mode 100644 index 00000000..f166a185 --- /dev/null +++ b/Sunrise.Shared/Database/Migrations/20260510161526_LimitScoreHashTo32Characters.Designer.cs @@ -0,0 +1,1095 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Sunrise.Shared.Database; + +#nullable disable + +namespace Sunrise.Shared.Database.Migrations +{ + [DbContext(typeof(SunriseDbContext))] + [Migration("20260510161526_LimitScoreHashTo32Characters")] + partial class LimitScoreHashTo32Characters + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.22") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Beatmap.BeatmapHype", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("BeatmapSetId") + .HasColumnType("int"); + + b.Property("Hypes") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("BeatmapSetId", "Hypes"); + + b.HasIndex("BeatmapSetId", "UserId") + .IsUnique(); + + b.ToTable("beatmap_hype"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Beatmap.CustomBeatmapStatus", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("BeatmapHash") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.Property("BeatmapSetId") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedByUserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("BeatmapHash"); + + b.HasIndex("BeatmapSetId"); + + b.HasIndex("UpdatedByUserId"); + + b.ToTable("custom_beatmap_status"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Events.EventBeatmap", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("BeatmapSetId") + .HasColumnType("int"); + + b.Property("EventType") + .HasColumnType("int"); + + b.Property("ExecutorId") + .HasColumnType("int"); + + b.Property("JsonData") + .HasColumnType("longtext"); + + b.Property("Time") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("BeatmapSetId"); + + b.HasIndex("ExecutorId"); + + b.ToTable("event_beatmap"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Events.EventUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("EventType") + .HasColumnType("int"); + + b.Property("Ip") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.Property("JsonData") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Time") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("EventType", "Ip"); + + b.HasIndex("EventType", "UserId"); + + b.ToTable("event_user"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Medal", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("Category") + .HasColumnType("int"); + + b.Property("Condition") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Description") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("FileId") + .HasColumnType("int"); + + b.Property("FileUrl") + .HasColumnType("longtext"); + + b.Property("GameMode") + .HasColumnType("tinyint unsigned"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("Category"); + + b.HasIndex("FileId"); + + b.HasIndex("GameMode"); + + b.ToTable("medal"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.MedalFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Path") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("medal_file"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Restriction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("Date") + .HasColumnType("datetime(6)"); + + b.Property("ExecutorId") + .HasColumnType("int"); + + b.Property("ExpiryDate") + .HasColumnType("datetime(6)"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ExecutorId"); + + b.HasIndex("UserId"); + + b.ToTable("restriction"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Score", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("Accuracy") + .HasColumnType("double"); + + b.Property("BeatmapHash") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.Property("BeatmapId") + .HasColumnType("int"); + + b.Property("BeatmapStatus") + .HasColumnType("int"); + + b.Property("ClientTime") + .HasColumnType("datetime(6)"); + + b.Property("Count100") + .HasColumnType("int"); + + b.Property("Count300") + .HasColumnType("int"); + + b.Property("Count50") + .HasColumnType("int"); + + b.Property("CountGeki") + .HasColumnType("int"); + + b.Property("CountKatu") + .HasColumnType("int"); + + b.Property("CountMiss") + .HasColumnType("int"); + + b.Property("GameMode") + .HasColumnType("tinyint unsigned"); + + b.Property("Grade") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("IsPassed") + .HasColumnType("tinyint(1)"); + + b.Property("IsScoreable") + .HasColumnType("tinyint(1)"); + + b.Property("MaxCombo") + .HasColumnType("int"); + + b.Property("Mods") + .HasColumnType("int"); + + b.Property("OsuVersion") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Perfect") + .HasColumnType("tinyint(1)"); + + b.Property("PerformancePoints") + .HasColumnType("double"); + + b.Property("ReplayFileId") + .HasColumnType("int"); + + b.Property("ScoreHash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("varchar(32)"); + + b.Property("SubmissionStatus") + .HasColumnType("int"); + + b.Property("TimeElapsed") + .HasColumnType("int"); + + b.Property("TotalScore") + .HasColumnType("BIGINT"); + + b.Property("UserId") + .HasColumnType("int"); + + b.Property("WhenPlayed") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("BeatmapHash"); + + b.HasIndex("ReplayFileId"); + + b.HasIndex("ScoreHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "BeatmapId"); + + b.HasIndex("UserId", "SubmissionStatus", "BeatmapStatus"); + + b.HasIndex("BeatmapId", "IsScoreable", "IsPassed", "SubmissionStatus"); + + b.HasIndex("GameMode", "SubmissionStatus", "BeatmapStatus", "WhenPlayed"); + + b.ToTable("score"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Scores.ScoreSubmissionRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("BeatmapHash") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("ClientHash") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("OsuVersion") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("ReplayFileId") + .HasColumnType("int"); + + b.Property("ScoreHash") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.Property("ScoreSerialized") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("StoryboardHash") + .HasColumnType("longtext"); + + b.Property("TimeElapsed") + .HasColumnType("int"); + + b.Property("UserHash") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("UserId") + .HasColumnType("int"); + + b.Property("WhenPlayed") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("ReplayFileId"); + + b.HasIndex("ScoreHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("score_submission_request"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Scores.ScoreProcessingTask", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("ActiveScoreId") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("int") + .HasComputedColumnSql("CASE WHEN Status IN (0, 1) THEN ScoreId ELSE NULL END", true); + + b.Property("ActiveScoreSubmissionRequestId") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("int") + .HasComputedColumnSql("CASE WHEN Status IN (0, 1) THEN ScoreSubmissionRequestId ELSE NULL END", true); + + b.Property("ClaimToken") + .HasColumnType("longtext"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("ErrorCode") + .HasColumnType("int"); + + b.Property("ErrorMessage") + .HasColumnType("longtext"); + + b.Property("LeaseExpiresAt") + .HasColumnType("datetime(6)"); + + b.Property("NextRetryAt") + .HasColumnType("datetime(6)"); + + b.Property("Priority") + .HasColumnType("int"); + + b.Property("RetryCount") + .HasColumnType("int"); + + b.Property("ScoreId") + .HasColumnType("int"); + + b.Property("ScoreSubmissionRequestId") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TaskType") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ActiveScoreId") + .IsUnique() + .HasDatabaseName("UX_score_processing_task_active_score"); + + b.HasIndex("ActiveScoreSubmissionRequestId") + .IsUnique() + .HasDatabaseName("UX_score_processing_task_active_submission_request"); + + b.HasIndex("ScoreId"); + + b.HasIndex("ScoreSubmissionRequestId"); + + b.HasIndex("Status", "LeaseExpiresAt"); + + b.HasIndex("TaskType", "ScoreId"); + + b.HasIndex("Status", "Priority", "NextRetryAt"); + + b.ToTable("score_processing_task", t => + { + t.HasCheckConstraint("CK_score_processing_task_target", "((TaskType = 0 AND ScoreSubmissionRequestId IS NOT NULL AND ScoreId IS NULL) OR (TaskType <> 0 AND ScoreSubmissionRequestId IS NULL AND ScoreId IS NOT NULL))"); + }); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("AccountStatus") + .HasColumnType("int"); + + b.Property("Country") + .HasColumnType("smallint"); + + b.Property("DefaultGameMode") + .HasColumnType("tinyint unsigned"); + + b.Property("Description") + .HasColumnType("longtext"); + + b.Property("Email") + .IsRequired() + .HasColumnType("varchar(255)") + .UseCollation("utf8mb4_unicode_ci"); + + b.Property("LastOnlineTime") + .HasColumnType("datetime(6)"); + + b.Property("Passhash") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Privilege") + .HasColumnType("int"); + + b.Property("RegisterDate") + .HasColumnType("datetime(6)"); + + b.Property("SilencedUntil") + .HasColumnType("datetime(6)"); + + b.Property("Username") + .IsRequired() + .HasColumnType("varchar(255)") + .UseCollation("utf8mb4_unicode_ci"); + + b.HasKey("Id"); + + b.HasIndex("AccountStatus"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("user"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserFavouriteBeatmap", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("BeatmapSetId") + .HasColumnType("int"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "BeatmapSetId"); + + b.ToTable("user_favourite_beatmap"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("OwnerId") + .HasColumnType("int"); + + b.Property("Path") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("OwnerId"); + + b.HasIndex("OwnerId", "Type"); + + b.ToTable("user_file"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserGrades", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("CountA") + .HasColumnType("int"); + + b.Property("CountB") + .HasColumnType("int"); + + b.Property("CountC") + .HasColumnType("int"); + + b.Property("CountD") + .HasColumnType("int"); + + b.Property("CountS") + .HasColumnType("int"); + + b.Property("CountSH") + .HasColumnType("int"); + + b.Property("CountX") + .HasColumnType("int"); + + b.Property("CountXH") + .HasColumnType("int"); + + b.Property("GameMode") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "GameMode") + .IsUnique(); + + b.ToTable("user_grades"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserInventoryItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("ItemType") + .HasColumnType("int"); + + b.Property("Quantity") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemType") + .IsUnique(); + + b.ToTable("user_inventory_item"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserMedals", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("MedalId") + .HasColumnType("int"); + + b.Property("UnlockedAt") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("user_medals"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("Discord") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("varchar(32)"); + + b.Property("Interest") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("varchar(32)"); + + b.Property("Location") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("varchar(32)"); + + b.Property("Occupation") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("varchar(32)"); + + b.Property("Playstyle") + .HasColumnType("int"); + + b.Property("Telegram") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("varchar(32)"); + + b.Property("Twitch") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("varchar(32)"); + + b.Property("Twitter") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("varchar(32)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.Property("Website") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("user_metadata"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserRelationship", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("Relation") + .HasColumnType("int"); + + b.Property("TargetId") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("TargetId"); + + b.HasIndex("UserId", "TargetId"); + + b.ToTable("user_relationship"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserStats", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("Accuracy") + .HasColumnType("double"); + + b.Property("BestCountryRank") + .HasColumnType("BIGINT"); + + b.Property("BestCountryRankDate") + .HasColumnType("datetime(6)"); + + b.Property("BestGlobalRank") + .HasColumnType("BIGINT"); + + b.Property("BestGlobalRankDate") + .HasColumnType("datetime(6)"); + + b.Property("GameMode") + .HasColumnType("tinyint unsigned"); + + b.Property("MaxCombo") + .HasColumnType("int"); + + b.Property("PerformancePoints") + .HasColumnType("double"); + + b.Property("PlayCount") + .HasColumnType("int"); + + b.Property("PlayTime") + .HasColumnType("int"); + + b.Property("RankedScore") + .HasColumnType("BIGINT"); + + b.Property("TotalHits") + .HasColumnType("int"); + + b.Property("TotalScore") + .HasColumnType("BIGINT"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "GameMode") + .IsUnique(); + + b.ToTable("user_stats"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserStatsSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("GameMode") + .HasColumnType("tinyint unsigned"); + + b.Property("SnapshotsJson") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "GameMode"); + + b.ToTable("user_stats_snapshot"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Beatmap.BeatmapHype", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Beatmap.CustomBeatmapStatus", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "UpdatedByUser") + .WithMany() + .HasForeignKey("UpdatedByUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UpdatedByUser"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Events.EventBeatmap", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "Executor") + .WithMany() + .HasForeignKey("ExecutorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Executor"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Events.EventUser", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Medal", b => + { + b.HasOne("Sunrise.Shared.Database.Models.MedalFile", "File") + .WithMany() + .HasForeignKey("FileId"); + + b.Navigation("File"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Restriction", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "Executor") + .WithMany() + .HasForeignKey("ExecutorId"); + + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Executor"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Score", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.UserFile", "ReplayFile") + .WithMany() + .HasForeignKey("ReplayFileId"); + + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ReplayFile"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Scores.ScoreSubmissionRequest", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.UserFile", "ReplayFile") + .WithMany() + .HasForeignKey("ReplayFileId"); + + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ReplayFile"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Scores.ScoreProcessingTask", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Score", "Score") + .WithMany() + .HasForeignKey("ScoreId"); + + b.HasOne("Sunrise.Shared.Database.Models.Scores.ScoreSubmissionRequest", "ScoreSubmissionRequest") + .WithMany() + .HasForeignKey("ScoreSubmissionRequestId"); + + b.Navigation("Score"); + + b.Navigation("ScoreSubmissionRequest"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserFavouriteBeatmap", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserFile", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "User") + .WithMany("UserFiles") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserGrades", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserInventoryItem", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "User") + .WithMany("Inventory") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserMedals", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserMetadata", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserRelationship", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "Target") + .WithMany("UserReceivedRelationships") + .HasForeignKey("TargetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "User") + .WithMany("UserInitiatedRelationships") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Target"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserStats", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "User") + .WithMany("UserStats") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserStatsSnapshot", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "User") + .WithMany("UserStatsSnapshots") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.User", b => + { + b.Navigation("Inventory"); + + b.Navigation("UserFiles"); + + b.Navigation("UserInitiatedRelationships"); + + b.Navigation("UserReceivedRelationships"); + + b.Navigation("UserStats"); + + b.Navigation("UserStatsSnapshots"); + }); +#pragma warning restore 612, 618 + } + } +} + + diff --git a/Sunrise.Shared/Database/Migrations/20260510161526_LimitScoreHashTo32Characters.cs b/Sunrise.Shared/Database/Migrations/20260510161526_LimitScoreHashTo32Characters.cs new file mode 100644 index 00000000..7662abb1 --- /dev/null +++ b/Sunrise.Shared/Database/Migrations/20260510161526_LimitScoreHashTo32Characters.cs @@ -0,0 +1,36 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Sunrise.Shared.Database.Migrations +{ + /// + public partial class LimitScoreHashTo32Characters : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "ScoreHash", + table: "score", + type: "varchar(32)", + maxLength: 32, + nullable: false, + oldClrType: typeof(string), + oldType: "longtext"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "ScoreHash", + table: "score", + type: "longtext", + nullable: false, + oldClrType: typeof(string), + oldType: "varchar(32)", + oldMaxLength: 32); + } + } +} diff --git a/Sunrise.Shared/Database/Migrations/20260510173606_LimitScoreHashTo32CharactersForScoreSubmissionRequest.Designer.cs b/Sunrise.Shared/Database/Migrations/20260510173606_LimitScoreHashTo32CharactersForScoreSubmissionRequest.Designer.cs new file mode 100644 index 00000000..c17cc5ae --- /dev/null +++ b/Sunrise.Shared/Database/Migrations/20260510173606_LimitScoreHashTo32CharactersForScoreSubmissionRequest.Designer.cs @@ -0,0 +1,1096 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Sunrise.Shared.Database; + +#nullable disable + +namespace Sunrise.Shared.Database.Migrations +{ + [DbContext(typeof(SunriseDbContext))] + [Migration("20260510173606_LimitScoreHashTo32CharactersForScoreSubmissionRequest")] + partial class LimitScoreHashTo32CharactersForScoreSubmissionRequest + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.22") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Beatmap.BeatmapHype", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("BeatmapSetId") + .HasColumnType("int"); + + b.Property("Hypes") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("BeatmapSetId", "Hypes"); + + b.HasIndex("BeatmapSetId", "UserId") + .IsUnique(); + + b.ToTable("beatmap_hype"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Beatmap.CustomBeatmapStatus", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("BeatmapHash") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.Property("BeatmapSetId") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedByUserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("BeatmapHash"); + + b.HasIndex("BeatmapSetId"); + + b.HasIndex("UpdatedByUserId"); + + b.ToTable("custom_beatmap_status"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Events.EventBeatmap", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("BeatmapSetId") + .HasColumnType("int"); + + b.Property("EventType") + .HasColumnType("int"); + + b.Property("ExecutorId") + .HasColumnType("int"); + + b.Property("JsonData") + .HasColumnType("longtext"); + + b.Property("Time") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("BeatmapSetId"); + + b.HasIndex("ExecutorId"); + + b.ToTable("event_beatmap"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Events.EventUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("EventType") + .HasColumnType("int"); + + b.Property("Ip") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.Property("JsonData") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Time") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("EventType", "Ip"); + + b.HasIndex("EventType", "UserId"); + + b.ToTable("event_user"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Medal", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("Category") + .HasColumnType("int"); + + b.Property("Condition") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Description") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("FileId") + .HasColumnType("int"); + + b.Property("FileUrl") + .HasColumnType("longtext"); + + b.Property("GameMode") + .HasColumnType("tinyint unsigned"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("Category"); + + b.HasIndex("FileId"); + + b.HasIndex("GameMode"); + + b.ToTable("medal"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.MedalFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Path") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("medal_file"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Restriction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("Date") + .HasColumnType("datetime(6)"); + + b.Property("ExecutorId") + .HasColumnType("int"); + + b.Property("ExpiryDate") + .HasColumnType("datetime(6)"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ExecutorId"); + + b.HasIndex("UserId"); + + b.ToTable("restriction"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Score", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("Accuracy") + .HasColumnType("double"); + + b.Property("BeatmapHash") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.Property("BeatmapId") + .HasColumnType("int"); + + b.Property("BeatmapStatus") + .HasColumnType("int"); + + b.Property("ClientTime") + .HasColumnType("datetime(6)"); + + b.Property("Count100") + .HasColumnType("int"); + + b.Property("Count300") + .HasColumnType("int"); + + b.Property("Count50") + .HasColumnType("int"); + + b.Property("CountGeki") + .HasColumnType("int"); + + b.Property("CountKatu") + .HasColumnType("int"); + + b.Property("CountMiss") + .HasColumnType("int"); + + b.Property("GameMode") + .HasColumnType("tinyint unsigned"); + + b.Property("Grade") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("IsPassed") + .HasColumnType("tinyint(1)"); + + b.Property("IsScoreable") + .HasColumnType("tinyint(1)"); + + b.Property("MaxCombo") + .HasColumnType("int"); + + b.Property("Mods") + .HasColumnType("int"); + + b.Property("OsuVersion") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Perfect") + .HasColumnType("tinyint(1)"); + + b.Property("PerformancePoints") + .HasColumnType("double"); + + b.Property("ReplayFileId") + .HasColumnType("int"); + + b.Property("ScoreHash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("varchar(32)"); + + b.Property("SubmissionStatus") + .HasColumnType("int"); + + b.Property("TimeElapsed") + .HasColumnType("int"); + + b.Property("TotalScore") + .HasColumnType("BIGINT"); + + b.Property("UserId") + .HasColumnType("int"); + + b.Property("WhenPlayed") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("BeatmapHash"); + + b.HasIndex("ReplayFileId"); + + b.HasIndex("ScoreHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "BeatmapId"); + + b.HasIndex("UserId", "SubmissionStatus", "BeatmapStatus"); + + b.HasIndex("BeatmapId", "IsScoreable", "IsPassed", "SubmissionStatus"); + + b.HasIndex("GameMode", "SubmissionStatus", "BeatmapStatus", "WhenPlayed"); + + b.ToTable("score"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Scores.ScoreSubmissionRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("BeatmapHash") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("ClientHash") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("OsuVersion") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("ReplayFileId") + .HasColumnType("int"); + + b.Property("ScoreHash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("varchar(32)"); + + b.Property("ScoreSerialized") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("StoryboardHash") + .HasColumnType("longtext"); + + b.Property("TimeElapsed") + .HasColumnType("int"); + + b.Property("UserHash") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("UserId") + .HasColumnType("int"); + + b.Property("WhenPlayed") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("ReplayFileId"); + + b.HasIndex("ScoreHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("score_submission_request"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Scores.ScoreProcessingTask", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("ActiveScoreId") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("int") + .HasComputedColumnSql("CASE WHEN Status IN (0, 1) THEN ScoreId ELSE NULL END", true); + + b.Property("ActiveScoreSubmissionRequestId") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("int") + .HasComputedColumnSql("CASE WHEN Status IN (0, 1) THEN ScoreSubmissionRequestId ELSE NULL END", true); + + b.Property("ClaimToken") + .HasColumnType("longtext"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("ErrorCode") + .HasColumnType("int"); + + b.Property("ErrorMessage") + .HasColumnType("longtext"); + + b.Property("LeaseExpiresAt") + .HasColumnType("datetime(6)"); + + b.Property("NextRetryAt") + .HasColumnType("datetime(6)"); + + b.Property("Priority") + .HasColumnType("int"); + + b.Property("RetryCount") + .HasColumnType("int"); + + b.Property("ScoreId") + .HasColumnType("int"); + + b.Property("ScoreSubmissionRequestId") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TaskType") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ActiveScoreId") + .IsUnique() + .HasDatabaseName("UX_score_processing_task_active_score"); + + b.HasIndex("ActiveScoreSubmissionRequestId") + .IsUnique() + .HasDatabaseName("UX_score_processing_task_active_submission_request"); + + b.HasIndex("ScoreId"); + + b.HasIndex("ScoreSubmissionRequestId"); + + b.HasIndex("Status", "LeaseExpiresAt"); + + b.HasIndex("TaskType", "ScoreId"); + + b.HasIndex("Status", "Priority", "NextRetryAt"); + + b.ToTable("score_processing_task", t => + { + t.HasCheckConstraint("CK_score_processing_task_target", "((TaskType = 0 AND ScoreSubmissionRequestId IS NOT NULL AND ScoreId IS NULL) OR (TaskType <> 0 AND ScoreSubmissionRequestId IS NULL AND ScoreId IS NOT NULL))"); + }); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("AccountStatus") + .HasColumnType("int"); + + b.Property("Country") + .HasColumnType("smallint"); + + b.Property("DefaultGameMode") + .HasColumnType("tinyint unsigned"); + + b.Property("Description") + .HasColumnType("longtext"); + + b.Property("Email") + .IsRequired() + .HasColumnType("varchar(255)") + .UseCollation("utf8mb4_unicode_ci"); + + b.Property("LastOnlineTime") + .HasColumnType("datetime(6)"); + + b.Property("Passhash") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Privilege") + .HasColumnType("int"); + + b.Property("RegisterDate") + .HasColumnType("datetime(6)"); + + b.Property("SilencedUntil") + .HasColumnType("datetime(6)"); + + b.Property("Username") + .IsRequired() + .HasColumnType("varchar(255)") + .UseCollation("utf8mb4_unicode_ci"); + + b.HasKey("Id"); + + b.HasIndex("AccountStatus"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("user"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserFavouriteBeatmap", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("BeatmapSetId") + .HasColumnType("int"); + + b.Property("DateAdded") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "BeatmapSetId"); + + b.ToTable("user_favourite_beatmap"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("OwnerId") + .HasColumnType("int"); + + b.Property("Path") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("OwnerId"); + + b.HasIndex("OwnerId", "Type"); + + b.ToTable("user_file"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserGrades", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("CountA") + .HasColumnType("int"); + + b.Property("CountB") + .HasColumnType("int"); + + b.Property("CountC") + .HasColumnType("int"); + + b.Property("CountD") + .HasColumnType("int"); + + b.Property("CountS") + .HasColumnType("int"); + + b.Property("CountSH") + .HasColumnType("int"); + + b.Property("CountX") + .HasColumnType("int"); + + b.Property("CountXH") + .HasColumnType("int"); + + b.Property("GameMode") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "GameMode") + .IsUnique(); + + b.ToTable("user_grades"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserInventoryItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("ItemType") + .HasColumnType("int"); + + b.Property("Quantity") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemType") + .IsUnique(); + + b.ToTable("user_inventory_item"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserMedals", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("MedalId") + .HasColumnType("int"); + + b.Property("UnlockedAt") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("user_medals"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("Discord") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("varchar(32)"); + + b.Property("Interest") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("varchar(32)"); + + b.Property("Location") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("varchar(32)"); + + b.Property("Occupation") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("varchar(32)"); + + b.Property("Playstyle") + .HasColumnType("int"); + + b.Property("Telegram") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("varchar(32)"); + + b.Property("Twitch") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("varchar(32)"); + + b.Property("Twitter") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("varchar(32)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.Property("Website") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("user_metadata"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserRelationship", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("Relation") + .HasColumnType("int"); + + b.Property("TargetId") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("TargetId"); + + b.HasIndex("UserId", "TargetId"); + + b.ToTable("user_relationship"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserStats", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("Accuracy") + .HasColumnType("double"); + + b.Property("BestCountryRank") + .HasColumnType("BIGINT"); + + b.Property("BestCountryRankDate") + .HasColumnType("datetime(6)"); + + b.Property("BestGlobalRank") + .HasColumnType("BIGINT"); + + b.Property("BestGlobalRankDate") + .HasColumnType("datetime(6)"); + + b.Property("GameMode") + .HasColumnType("tinyint unsigned"); + + b.Property("MaxCombo") + .HasColumnType("int"); + + b.Property("PerformancePoints") + .HasColumnType("double"); + + b.Property("PlayCount") + .HasColumnType("int"); + + b.Property("PlayTime") + .HasColumnType("int"); + + b.Property("RankedScore") + .HasColumnType("BIGINT"); + + b.Property("TotalHits") + .HasColumnType("int"); + + b.Property("TotalScore") + .HasColumnType("BIGINT"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "GameMode") + .IsUnique(); + + b.ToTable("user_stats"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserStatsSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("GameMode") + .HasColumnType("tinyint unsigned"); + + b.Property("SnapshotsJson") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "GameMode"); + + b.ToTable("user_stats_snapshot"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Beatmap.BeatmapHype", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Beatmap.CustomBeatmapStatus", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "UpdatedByUser") + .WithMany() + .HasForeignKey("UpdatedByUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UpdatedByUser"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Events.EventBeatmap", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "Executor") + .WithMany() + .HasForeignKey("ExecutorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Executor"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Events.EventUser", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Medal", b => + { + b.HasOne("Sunrise.Shared.Database.Models.MedalFile", "File") + .WithMany() + .HasForeignKey("FileId"); + + b.Navigation("File"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Restriction", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "Executor") + .WithMany() + .HasForeignKey("ExecutorId"); + + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Executor"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Score", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.UserFile", "ReplayFile") + .WithMany() + .HasForeignKey("ReplayFileId"); + + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ReplayFile"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Scores.ScoreSubmissionRequest", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.UserFile", "ReplayFile") + .WithMany() + .HasForeignKey("ReplayFileId"); + + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ReplayFile"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Scores.ScoreProcessingTask", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Score", "Score") + .WithMany() + .HasForeignKey("ScoreId"); + + b.HasOne("Sunrise.Shared.Database.Models.Scores.ScoreSubmissionRequest", "ScoreSubmissionRequest") + .WithMany() + .HasForeignKey("ScoreSubmissionRequestId"); + + b.Navigation("Score"); + + b.Navigation("ScoreSubmissionRequest"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserFavouriteBeatmap", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserFile", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "User") + .WithMany("UserFiles") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserGrades", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserInventoryItem", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "User") + .WithMany("Inventory") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserMedals", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserMetadata", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserRelationship", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "Target") + .WithMany("UserReceivedRelationships") + .HasForeignKey("TargetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "User") + .WithMany("UserInitiatedRelationships") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Target"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserStats", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "User") + .WithMany("UserStats") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserStatsSnapshot", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "User") + .WithMany("UserStatsSnapshots") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.User", b => + { + b.Navigation("Inventory"); + + b.Navigation("UserFiles"); + + b.Navigation("UserInitiatedRelationships"); + + b.Navigation("UserReceivedRelationships"); + + b.Navigation("UserStats"); + + b.Navigation("UserStatsSnapshots"); + }); +#pragma warning restore 612, 618 + } + } +} + + diff --git a/Sunrise.Shared/Database/Migrations/20260510173606_LimitScoreHashTo32CharactersForScoreSubmissionRequest.cs b/Sunrise.Shared/Database/Migrations/20260510173606_LimitScoreHashTo32CharactersForScoreSubmissionRequest.cs new file mode 100644 index 00000000..9872a0ba --- /dev/null +++ b/Sunrise.Shared/Database/Migrations/20260510173606_LimitScoreHashTo32CharactersForScoreSubmissionRequest.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Sunrise.Shared.Database.Migrations +{ + /// + public partial class LimitScoreHashTo32CharactersForScoreSubmissionRequest : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "ScoreHash", + table: "score_submission_request", + type: "varchar(32)", + maxLength: 32, + nullable: false, + oldClrType: typeof(string), + oldType: "varchar(255)"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "ScoreHash", + table: "score_submission_request", + type: "varchar(255)", + nullable: false, + oldClrType: typeof(string), + oldType: "varchar(32)", + oldMaxLength: 32); + } + } +} + + diff --git a/Sunrise.Shared/Database/Migrations/SunriseDbContextModelSnapshot.cs b/Sunrise.Shared/Database/Migrations/SunriseDbContextModelSnapshot.cs index ab3feed5..7b31cad0 100644 --- a/Sunrise.Shared/Database/Migrations/SunriseDbContextModelSnapshot.cs +++ b/Sunrise.Shared/Database/Migrations/SunriseDbContextModelSnapshot.cs @@ -1,4 +1,4 @@ -// +// using System; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; @@ -304,11 +304,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("ScoreHash") .IsRequired() - .HasColumnType("longtext"); + .HasMaxLength(32) + .HasColumnType("varchar(32)"); b.Property("SubmissionStatus") .HasColumnType("int"); + b.Property("TimeElapsed") + .HasColumnType("int"); + b.Property("TotalScore") .HasColumnType("BIGINT"); @@ -324,6 +328,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("ReplayFileId"); + b.HasIndex("ScoreHash") + .IsUnique(); + b.HasIndex("UserId"); b.HasIndex("UserId", "BeatmapId"); @@ -337,6 +344,142 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("score"); }); + modelBuilder.Entity("Sunrise.Shared.Database.Models.Scores.ScoreSubmissionRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("BeatmapHash") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("ClientHash") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("OsuVersion") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("ReplayFileId") + .HasColumnType("int"); + + b.Property("ScoreHash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("varchar(32)"); + + b.Property("ScoreSerialized") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("StoryboardHash") + .HasColumnType("longtext"); + + b.Property("TimeElapsed") + .HasColumnType("int"); + + b.Property("UserHash") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("UserId") + .HasColumnType("int"); + + b.Property("WhenPlayed") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("ReplayFileId"); + + b.HasIndex("ScoreHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("score_submission_request"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Scores.ScoreProcessingTask", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("ActiveScoreId") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("int") + .HasComputedColumnSql("CASE WHEN Status IN (0, 1) THEN ScoreId ELSE NULL END", true); + + b.Property("ActiveScoreSubmissionRequestId") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("int") + .HasComputedColumnSql("CASE WHEN Status IN (0, 1) THEN ScoreSubmissionRequestId ELSE NULL END", true); + + b.Property("ClaimToken") + .HasColumnType("longtext"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("ErrorCode") + .HasColumnType("int"); + + b.Property("ErrorMessage") + .HasColumnType("longtext"); + + b.Property("LeaseExpiresAt") + .HasColumnType("datetime(6)"); + + b.Property("NextRetryAt") + .HasColumnType("datetime(6)"); + + b.Property("Priority") + .HasColumnType("int"); + + b.Property("RetryCount") + .HasColumnType("int"); + + b.Property("ScoreId") + .HasColumnType("int"); + + b.Property("ScoreSubmissionRequestId") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TaskType") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ActiveScoreId") + .IsUnique() + .HasDatabaseName("UX_score_processing_task_active_score"); + + b.HasIndex("ActiveScoreSubmissionRequestId") + .IsUnique() + .HasDatabaseName("UX_score_processing_task_active_submission_request"); + + b.HasIndex("ScoreId"); + + b.HasIndex("ScoreSubmissionRequestId"); + + b.HasIndex("Status", "LeaseExpiresAt"); + + b.HasIndex("TaskType", "ScoreId"); + + b.HasIndex("Status", "Priority", "NextRetryAt"); + + b.ToTable("score_processing_task", t => + { + t.HasCheckConstraint("CK_score_processing_task_target", "((TaskType = 0 AND ScoreSubmissionRequestId IS NOT NULL AND ScoreId IS NULL) OR (TaskType <> 0 AND ScoreSubmissionRequestId IS NULL AND ScoreId IS NOT NULL))"); + }); + }); + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.User", b => { b.Property("Id") @@ -789,6 +932,38 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("User"); }); + modelBuilder.Entity("Sunrise.Shared.Database.Models.Scores.ScoreSubmissionRequest", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Users.UserFile", "ReplayFile") + .WithMany() + .HasForeignKey("ReplayFileId"); + + b.HasOne("Sunrise.Shared.Database.Models.Users.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ReplayFile"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Sunrise.Shared.Database.Models.Scores.ScoreProcessingTask", b => + { + b.HasOne("Sunrise.Shared.Database.Models.Score", "Score") + .WithMany() + .HasForeignKey("ScoreId"); + + b.HasOne("Sunrise.Shared.Database.Models.Scores.ScoreSubmissionRequest", "ScoreSubmissionRequest") + .WithMany() + .HasForeignKey("ScoreSubmissionRequestId"); + + b.Navigation("Score"); + + b.Navigation("ScoreSubmissionRequest"); + }); + modelBuilder.Entity("Sunrise.Shared.Database.Models.Users.UserFavouriteBeatmap", b => { b.HasOne("Sunrise.Shared.Database.Models.Users.User", "User") @@ -914,3 +1089,5 @@ protected override void BuildModel(ModelBuilder modelBuilder) } } } + + diff --git a/Sunrise.Shared/Database/Models/Score.cs b/Sunrise.Shared/Database/Models/Score.cs index 47681404..df1062ec 100644 --- a/Sunrise.Shared/Database/Models/Score.cs +++ b/Sunrise.Shared/Database/Models/Score.cs @@ -1,3 +1,4 @@ +using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using Microsoft.EntityFrameworkCore; using osu.Shared; @@ -16,21 +17,26 @@ namespace Sunrise.Shared.Database.Models; [Index(nameof(BeatmapId), nameof(IsScoreable), nameof(IsPassed), nameof(SubmissionStatus))] [Index(nameof(GameMode), nameof(SubmissionStatus), nameof(BeatmapStatus), nameof(WhenPlayed))] [Index(nameof(BeatmapHash))] +[Index(nameof(ScoreHash), IsUnique = true)] public class Score { public Score() { + // TODO: This doesn't work without explicit call. Please let's deprecate it in favour of dynamic values LocalProperties = new LocalProperties().FromScore(this); } public int Id { get; set; } [ForeignKey(nameof(UserId))] - public User User { get; set; } + public User? User { get; set; } public int UserId { get; set; } public int BeatmapId { get; set; } + + [MaxLength(32)] public string ScoreHash { get; set; } + public string BeatmapHash { get; set; } [ForeignKey("ReplayFileId")] @@ -51,7 +57,10 @@ public Score() public bool Perfect { get; set; } public Mods Mods { get; set; } public string Grade { get; set; } + public bool IsPassed { get; set; } + + // TODO: Drop persisted IsScoreable once all score reads derive it from BeatmapStatus. public bool IsScoreable { get; set; } public SubmissionStatus SubmissionStatus { get; set; } = SubmissionStatus.Unknown; public GameMode GameMode { get; set; } @@ -61,6 +70,7 @@ public Score() public DateTime ClientTime { get; set; } public double Accuracy { get; set; } public double PerformancePoints { get; set; } + public int TimeElapsed { get; set; } [NotMapped] public LocalProperties LocalProperties { get; set; } @@ -78,8 +88,8 @@ public class LocalProperties */ public Mods SerializedMods { get; set; } - public bool IsRanked { get; set; } - public int? LeaderboardPosition { get; set; } + public bool IsRanked { get; set; } // TODO: Questionable to removal + public int? LeaderboardPosition { get; set; } // TODO: Badly called from the graph creation for score submissiion result. Ideally remove public LocalProperties FromScore(Score score) { diff --git a/Sunrise.Shared/Database/Models/Scores/ScoreProcessingTask.cs b/Sunrise.Shared/Database/Models/Scores/ScoreProcessingTask.cs new file mode 100644 index 00000000..b84fd185 --- /dev/null +++ b/Sunrise.Shared/Database/Models/Scores/ScoreProcessingTask.cs @@ -0,0 +1,36 @@ +using System.ComponentModel.DataAnnotations.Schema; +using Microsoft.EntityFrameworkCore; +using Sunrise.Shared.Enums.Scores; + +namespace Sunrise.Shared.Database.Models.Scores; + +[Table("score_processing_task")] +[Index(nameof(Status), nameof(Priority), nameof(NextRetryAt))] +[Index(nameof(Status), nameof(LeaseExpiresAt))] +[Index(nameof(TaskType), nameof(ScoreId))] +[Index(nameof(ScoreSubmissionRequestId))] +public class ScoreProcessingTask +{ + public int Id { get; set; } + + public ScoreTaskType TaskType { get; set; } + + [ForeignKey(nameof(ScoreSubmissionRequestId))] + public ScoreSubmissionRequest? ScoreSubmissionRequest { get; set; } + + public int? ScoreSubmissionRequestId { get; set; } + + [ForeignKey(nameof(ScoreId))] + public Score? Score { get; set; } + + public int? ScoreId { get; set; } + public int Priority { get; set; } = (int)ScoreProcessingPriority.High; + public ScoreProcessingStatus Status { get; set; } = ScoreProcessingStatus.Pending; + public DateTime? NextRetryAt { get; set; } + public int RetryCount { get; set; } + public ScoreProcessingErrorCode? ErrorCode { get; set; } + public string? ErrorMessage { get; set; } + public string? ClaimToken { get; set; } + public DateTime? LeaseExpiresAt { get; set; } + public DateTime CreatedAt { get; set; } +} diff --git a/Sunrise.Shared/Database/Models/Scores/ScoreSubmissionRequest.cs b/Sunrise.Shared/Database/Models/Scores/ScoreSubmissionRequest.cs new file mode 100644 index 00000000..3d56c76a --- /dev/null +++ b/Sunrise.Shared/Database/Models/Scores/ScoreSubmissionRequest.cs @@ -0,0 +1,35 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Microsoft.EntityFrameworkCore; +using Sunrise.Shared.Database.Models.Users; + +namespace Sunrise.Shared.Database.Models.Scores; + +[Table("score_submission_request")] +[Index(nameof(ScoreHash), IsUnique = true)] +public class ScoreSubmissionRequest +{ + public int Id { get; set; } + + [ForeignKey(nameof(UserId))] + public User? User { get; set; } + + public int UserId { get; set; } + + [MaxLength(32)] + public string ScoreHash { get; set; } = null!; + + public string ScoreSerialized { get; set; } = null!; + public string BeatmapHash { get; set; } = null!; + public int TimeElapsed { get; set; } + public string OsuVersion { get; set; } = null!; + public string ClientHash { get; set; } = null!; + + [ForeignKey(nameof(ReplayFileId))] + public UserFile? ReplayFile { get; set; } + + public int? ReplayFileId { get; set; } + public string? StoryboardHash { get; set; } + public string UserHash { get; set; } = null!; + public DateTime WhenPlayed { get; set; } +} \ No newline at end of file diff --git a/Sunrise.Shared/Database/Models/Users/UserGrades.cs b/Sunrise.Shared/Database/Models/Users/UserGrades.cs index c802f99b..fdde2dad 100644 --- a/Sunrise.Shared/Database/Models/Users/UserGrades.cs +++ b/Sunrise.Shared/Database/Models/Users/UserGrades.cs @@ -24,4 +24,20 @@ public class UserGrades public int CountB { get; set; } = 0; public int CountC { get; set; } = 0; public int CountD { get; set; } = 0; + + public int GetGradeCount(string grade) + { + return grade switch + { + "XH" => CountXH, + "X" => CountX, + "SH" => CountSH, + "S" => CountS, + "A" => CountA, + "B" => CountB, + "C" => CountC, + "D" => CountD, + _ => throw new ArgumentOutOfRangeException($"Unknown grade: {grade} while getting") + }; + } } \ No newline at end of file diff --git a/Sunrise.Shared/Database/Models/Users/UserStats.cs b/Sunrise.Shared/Database/Models/Users/UserStats.cs index 91a8ef14..77584225 100644 --- a/Sunrise.Shared/Database/Models/Users/UserStats.cs +++ b/Sunrise.Shared/Database/Models/Users/UserStats.cs @@ -17,7 +17,7 @@ public UserStats() public int Id { get; set; } [ForeignKey("UserId")] - public User User { get; set; } + public User? User { get; set; } public int UserId { get; set; } public GameMode GameMode { get; set; } diff --git a/Sunrise.Shared/Database/Repositories/ScoreProcessingTaskRepository.cs b/Sunrise.Shared/Database/Repositories/ScoreProcessingTaskRepository.cs new file mode 100644 index 00000000..c5722c6e --- /dev/null +++ b/Sunrise.Shared/Database/Repositories/ScoreProcessingTaskRepository.cs @@ -0,0 +1,212 @@ +using CSharpFunctionalExtensions; +using Microsoft.EntityFrameworkCore; +using Sunrise.Shared.Application; +using Sunrise.Shared.Database.Models.Scores; +using Sunrise.Shared.Enums.Scores; +using Sunrise.Shared.Objects; + +namespace Sunrise.Shared.Database.Repositories; + +public class ScoreProcessingTaskRepository(SunriseDbContext dbContext) +{ + public async Task AddQueueEntry(ScoreProcessingTask task, CancellationToken ct = default) + { + dbContext.ScoreProcessingTasks.Add(task); + await dbContext.SaveChangesAsync(ct); + } + + public async Task TryAddQueueEntry(ScoreProcessingTask task, CancellationToken ct = default) + { + try + { + dbContext.ScoreProcessingTasks.Add(task); + await dbContext.SaveChangesAsync(ct); + return true; + } + catch (DbUpdateException ex) when (IsActiveTaskConflict(ex)) + { + if (dbContext.Entry(task).State == EntityState.Added) + dbContext.Entry(task).State = EntityState.Detached; + + return false; + } + } + + public async Task> ClaimPendingBatch(int limit, TimeSpan lease, CancellationToken ct = default) + { + var claimToken = Guid.NewGuid().ToString("N"); + var leaseUntil = DateTime.UtcNow.Add(lease); + + await dbContext.Database.ExecuteSqlInterpolatedAsync($@" + UPDATE score_processing_task AS target + JOIN ( + SELECT Id + FROM score_processing_task + WHERE ( + Status = {(int)ScoreProcessingStatus.Pending} + OR (Status = {(int)ScoreProcessingStatus.Processing} AND LeaseExpiresAt < UTC_TIMESTAMP()) + ) + AND (NextRetryAt IS NULL OR NextRetryAt <= UTC_TIMESTAMP()) + ORDER BY Priority DESC, CreatedAt, Id + LIMIT {limit} + ) AS picked ON picked.Id = target.Id + SET target.Status = {(int)ScoreProcessingStatus.Processing}, + target.ClaimToken = {claimToken}, + target.LeaseExpiresAt = {leaseUntil}", + ct); + + return await dbContext.ScoreProcessingTasks + .AsNoTracking() + .Where(task => task.ClaimToken == claimToken) + .OrderByDescending(task => task.Priority) + .ThenBy(task => task.CreatedAt) + .ToListAsync(ct); + } + + public async Task MarkForDeletion(int taskId, CancellationToken ct = default) + { + await dbContext.ScoreProcessingTasks + .Where(task => task.Id == taskId) + .ExecuteDeleteAsync(ct); + } + + public async Task MarkAsFailed(int taskId, ScoreProcessingError error, TimeSpan nextRetryDelay, CancellationToken ct = default) + { + var task = await dbContext.ScoreProcessingTasks.FindAsync([taskId], ct); + if (task == null) + return; + + task.RetryCount++; + task.ErrorCode = error.Code; + task.ErrorMessage = error.Message; + task.ClaimToken = null; + task.LeaseExpiresAt = null; + + if (error.Disposition == ScoreProcessingDisposition.Permanent || task.RetryCount >= Configuration.ScoreProcessingMaxRetries) + { + task.Status = ScoreProcessingStatus.Failed; + task.NextRetryAt = null; + } + else + { + task.Status = ScoreProcessingStatus.Pending; + task.NextRetryAt = DateTime.UtcNow + nextRetryDelay; + } + + await dbContext.SaveChangesAsync(ct); + } + + public async Task> CancelTask(int taskId, CancellationToken ct = default) + { + var task = await dbContext.ScoreProcessingTasks.FindAsync([taskId], ct); + if (task == null) + return UnitResult.Failure($"Score task {taskId} was not found."); + + if (task.Status == ScoreProcessingStatus.Processing) + return UnitResult.Failure( + $"Score task {taskId} is currently being processed and cannot be cancelled."); + + if (task.Status == ScoreProcessingStatus.Failed) + return UnitResult.Failure($"Score task {taskId} has already failed; nothing to cancel."); + + task.Status = ScoreProcessingStatus.Failed; + task.NextRetryAt = null; + task.ClaimToken = null; + task.LeaseExpiresAt = null; + task.ErrorCode = ScoreProcessingErrorCode.CancelledByOperator; + task.ErrorMessage = "Cancelled by operator"; + + await dbContext.SaveChangesAsync(ct); + return UnitResult.Success(); + } + + public async Task TryRequeueFailedTask(int taskId, CancellationToken ct = default) + { + var task = await dbContext.ScoreProcessingTasks.FindAsync([taskId], ct); + if (task is not { Status: ScoreProcessingStatus.Failed }) + return false; + + task.Status = ScoreProcessingStatus.Pending; + task.RetryCount = 0; + task.NextRetryAt = null; + task.ClaimToken = null; + task.LeaseExpiresAt = null; + task.ErrorCode = null; + task.ErrorMessage = null; + + try + { + await dbContext.SaveChangesAsync(ct); + return true; + } + catch (DbUpdateException ex) when (IsActiveTaskConflict(ex)) + { + await dbContext.Entry(task).ReloadAsync(ct); + return false; + } + } + + public async Task TryRequeueFailedTasks(IEnumerable? taskIds = null, CancellationToken ct = default) + { + List ids; + + if (taskIds == null) + { + ids = await dbContext.ScoreProcessingTasks + .Where(task => task.Status == ScoreProcessingStatus.Failed) + .Select(task => task.Id) + .ToListAsync(ct); + } + else + { + ids = taskIds + .Distinct() + .ToList(); + } + + if (ids.Count == 0) + return 0; + + var requeuedCount = 0; + + foreach (var id in ids) + { + if (await TryRequeueFailedTask(id, ct)) + requeuedCount++; + } + + return requeuedCount; + } + + public async Task> CountByStatus(CancellationToken ct = default) + { + var grouped = await dbContext.ScoreProcessingTasks + .AsNoTracking() + .GroupBy(task => task.Status) + .Select(group => new + { + Status = group.Key, + Count = group.LongCount() + }) + .ToListAsync(ct); + + return grouped.ToDictionary(group => group.Status, group => group.Count); + } + + public async Task RefreshClaimLease(int taskId, string claimToken, DateTime leaseUntil, CancellationToken ct = default) + { + return await dbContext.ScoreProcessingTasks + .Where(task => task.Id == taskId && task.ClaimToken == claimToken) + .ExecuteUpdateAsync(setters => setters + .SetProperty(task => task.LeaseExpiresAt, leaseUntil), + ct); + } + + private static bool IsActiveTaskConflict(DbUpdateException ex) + { + var message = ex.InnerException?.Message ?? ex.Message; + + return message.Contains("UX_score_processing_task_active_score", StringComparison.OrdinalIgnoreCase) + || message.Contains("UX_score_processing_task_active_submission_request", StringComparison.OrdinalIgnoreCase); + } +} \ No newline at end of file diff --git a/Sunrise.Shared/Database/Repositories/ScoreRepository.cs b/Sunrise.Shared/Database/Repositories/ScoreRepository.cs index 8528c9a0..4dedeb32 100644 --- a/Sunrise.Shared/Database/Repositories/ScoreRepository.cs +++ b/Sunrise.Shared/Database/Repositories/ScoreRepository.cs @@ -11,6 +11,8 @@ using Sunrise.Shared.Database.Services.Users; using Sunrise.Shared.Enums.Leaderboards; using Sunrise.Shared.Extensions.Beatmaps; +using Sunrise.Shared.Extensions.Scores; +using Sunrise.Shared.Objects; using Sunrise.Shared.Utils; using SubmissionStatus = Sunrise.Shared.Enums.Scores.SubmissionStatus; using GameMode = Sunrise.Shared.Enums.Beatmaps.GameMode; @@ -40,15 +42,6 @@ public async Task UpdateScore(Score score) }); } - public async Task MarkScoreAsDeleted(Score score) - { - return await ResultUtil.TryExecuteAsync(async () => - { - score.SubmissionStatus = SubmissionStatus.Deleted; - await UpdateScore(score); - }); - } - public async Task<(List, int)> GetBestScoresByGameMode(GameMode mode, QueryOptions? options = null, CancellationToken ct = default) { var groupedBestScores = dbContext.Scores @@ -69,10 +62,16 @@ public async Task MarkScoreAsDeleted(Score score) return (scores, totalCount); } - public async Task GetScore(int id, QueryOptions? options = null, CancellationToken ct = default) + public async Task GetScore(int id, QueryOptions? options = null, bool? filterValidScores = true, CancellationToken ct = default) { - return await dbContext.Scores - .FilterValidScores() + var baseScores = dbContext.Scores.AsQueryable(); + + if (filterValidScores.HasValue && filterValidScores.Value) + { + baseScores = baseScores.FilterValidScores(); + } + + return await baseScores .Where(s => s.Id == id) .UseQueryOptions(options) .FirstOrDefaultAsync(cancellationToken: ct); @@ -87,7 +86,6 @@ public async Task MarkScoreAsDeleted(Score score) .FirstOrDefaultAsync(cancellationToken: ct); } - public async Task<(List>, int)> GetUserMostPlayedBeatmapIds(int userId, GameMode mode, QueryOptions? options = null, CancellationToken ct = default) { var groupedBeatmapsQuery = dbContext.Scores @@ -123,10 +121,9 @@ public async Task MarkScoreAsDeleted(Score score) var scoresGrouped = dbContext.Scores .FilterValidScores() .FilterPassedScoreableScores() - .Where( - s => - s.BeatmapHash == EF.Constant(beatmapHash) && - s.GameMode == EF.Constant(gameMode)); + .Where(s => + s.BeatmapHash == EF.Constant(beatmapHash) && + s.GameMode == EF.Constant(gameMode)); if (type is LeaderboardType.GlobalWithMods && mods != null) { @@ -270,7 +267,7 @@ public async Task> EnrichScoresWithLeaderboardPosition(List s var command = connection.CreateCommand(); command.CommandText = $""" - + SELECT Id, RANK() OVER (PARTITION BY BeatmapId ORDER BY {orderByValue} DESC) AS LeaderboardPosition FROM score @@ -317,4 +314,80 @@ public async Task> CountScoresByGameMode(Cancellation }) .ToDictionaryAsync(k => k.GameMode, v => v.Count, ct); } + + public async Task GetUserBeatmapPeersForUpdate( + int userId, + string beatmapHash, + GameMode gameMode, + Mods mods, + int? excludeScoreId = null, + CancellationToken ct = default) + { + var excludeId = excludeScoreId ?? 0; + + // TODO: Use EntityFrameworkCore.Locking.MySql instead after move to Pomelo MySQL provider + // TODO: Use FilterPassedScoreableScores to filter scoreable scores + var scores = await dbContext.Scores + .FromSqlInterpolated($""" + SELECT * FROM score + WHERE UserId = {userId} + AND BeatmapHash = {beatmapHash} + AND GameMode = {(int)gameMode} + AND IsScoreable = TRUE + AND IsPassed = TRUE + AND SubmissionStatus != {(int)SubmissionStatus.Failed} + AND SubmissionStatus != {(int)SubmissionStatus.Deleted} + AND SubmissionStatus != {(int)SubmissionStatus.Unknown} + AND Id != {excludeId} + FOR UPDATE + """) + .AsNoTracking() + .ToListAsync(ct); + + foreach (var score in scores) + { + score.LocalProperties = score.LocalProperties.FromScore(score); + } + + var overallPeer = scores.GetUserPersonalBestScores(userId); + var sameModsPeer = scores + .Where(x => x.Mods == mods) + .ToList() + .GetUserPersonalBestScores(userId); + + return new UserBeatmapPeers(sameModsPeer, overallPeer); + } + + public async Task GetUserMaxComboExcluding( + int userId, + GameMode gameMode, + int? excludeScoreId = null, + CancellationToken ct = default) + { + var query = dbContext.Scores + .AsNoTracking() + .FilterValidScores() + .FilterPassedScoreableScores() + .Where(s => s.UserId == userId && s.GameMode == gameMode); + + if (excludeScoreId.HasValue) + { + var excludeId = excludeScoreId.Value; + query = query.Where(s => s.Id != excludeId); + } + + var hasAny = await query.AnyAsync(ct); + if (!hasAny) + return null; + + return await query.MaxAsync(s => (int?)s.MaxCombo, ct); + } + + public async Task GetUserIdByScoreId(int scoreId, CancellationToken ct = default) + { + return await dbContext.Scores + .Where(p => p.Id == scoreId) + .Select(p => (int?)p.UserId) + .FirstOrDefaultAsync(ct); + } } \ No newline at end of file diff --git a/Sunrise.Shared/Database/Repositories/ScoreSubmissionRequestRepository.cs b/Sunrise.Shared/Database/Repositories/ScoreSubmissionRequestRepository.cs new file mode 100644 index 00000000..8f9957c5 --- /dev/null +++ b/Sunrise.Shared/Database/Repositories/ScoreSubmissionRequestRepository.cs @@ -0,0 +1,33 @@ +using Microsoft.EntityFrameworkCore; +using Sunrise.Shared.Database.Models.Scores; + +namespace Sunrise.Shared.Database.Repositories; + +public class ScoreSubmissionRequestRepository(SunriseDbContext dbContext) +{ + public async Task AddQueueEntry(ScoreSubmissionRequest payload, CancellationToken ct = default) + { + dbContext.ScoreSubmissionRequests.Add(payload); + await dbContext.SaveChangesAsync(ct); + } + + public async Task GetById(int payloadId, CancellationToken ct = default) + { + return await dbContext.ScoreSubmissionRequests.FindAsync([payloadId], ct); + } + + public async Task DeleteById(int payloadId, CancellationToken ct = default) + { + await dbContext.ScoreSubmissionRequests + .Where(e => e.Id == payloadId) + .ExecuteDeleteAsync(ct); + } + + public async Task GetUserIdByPayloadId(int payloadId, CancellationToken ct = default) + { + return await dbContext.ScoreSubmissionRequests + .Where(p => p.Id == payloadId) + .Select(p => (int?)p.UserId) + .FirstOrDefaultAsync(ct); + } +} \ No newline at end of file diff --git a/Sunrise.Shared/Database/SunriseDbContext.cs b/Sunrise.Shared/Database/SunriseDbContext.cs index a65750e1..f2edc6f9 100644 --- a/Sunrise.Shared/Database/SunriseDbContext.cs +++ b/Sunrise.Shared/Database/SunriseDbContext.cs @@ -3,7 +3,9 @@ using Sunrise.Shared.Database.Models; using Sunrise.Shared.Database.Models.Beatmap; using Sunrise.Shared.Database.Models.Events; +using Sunrise.Shared.Database.Models.Scores; using Sunrise.Shared.Database.Models.Users; +using Sunrise.Shared.Enums.Scores; namespace Sunrise.Shared.Database; @@ -37,6 +39,8 @@ public SunriseDbContext(DbContextOptions options) : base(optio public DbSet Restrictions { get; set; } public DbSet Scores { get; set; } + public DbSet ScoreSubmissionRequests { get; set; } + public DbSet ScoreProcessingTasks { get; set; } public DbSet BeatmapHypes { get; set; } public DbSet CustomBeatmapStatuses { get; set; } @@ -45,6 +49,11 @@ public SunriseDbContext(DbContextOptions options) : base(optio protected override void OnModelCreating(ModelBuilder modelBuilder) { + const string scoreTaskTypeColumn = nameof(ScoreProcessingTask.TaskType); + const string scoreTaskScoreIdColumn = nameof(ScoreProcessingTask.ScoreId); + const string scoreTaskPayloadIdColumn = nameof(ScoreProcessingTask.ScoreSubmissionRequestId); + const string scoreTaskStatusColumn = nameof(ScoreProcessingTask.Status); + modelBuilder.Entity() .Property(u => u.Username) .UseCollation("utf8mb4_unicode_ci"); @@ -64,6 +73,34 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .WithMany(u => u.UserReceivedRelationships) .HasForeignKey(ur => ur.TargetId) .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .ToTable(t => t.HasCheckConstraint( + "CK_score_processing_task_target", + $"(({scoreTaskTypeColumn} = {(int)ScoreTaskType.Submission} AND {scoreTaskPayloadIdColumn} IS NOT NULL AND {scoreTaskScoreIdColumn} IS NULL) " + + $"OR ({scoreTaskTypeColumn} <> {(int)ScoreTaskType.Submission} AND {scoreTaskPayloadIdColumn} IS NULL AND {scoreTaskScoreIdColumn} IS NOT NULL))")); + + modelBuilder.Entity() + .Property("ActiveScoreId") + .HasComputedColumnSql( + $"CASE WHEN {scoreTaskStatusColumn} IN ({(int)ScoreProcessingStatus.Pending}, {(int)ScoreProcessingStatus.Processing}) THEN {scoreTaskScoreIdColumn} ELSE NULL END", + true); + + modelBuilder.Entity() + .Property("ActiveScoreSubmissionRequestId") + .HasComputedColumnSql( + $"CASE WHEN {scoreTaskStatusColumn} IN ({(int)ScoreProcessingStatus.Pending}, {(int)ScoreProcessingStatus.Processing}) THEN {scoreTaskPayloadIdColumn} ELSE NULL END", + true); + + modelBuilder.Entity() + .HasIndex("ActiveScoreId") + .IsUnique() + .HasDatabaseName("UX_score_processing_task_active_score"); + + modelBuilder.Entity() + .HasIndex("ActiveScoreSubmissionRequestId") + .IsUnique() + .HasDatabaseName("UX_score_processing_task_active_submission_request"); } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) diff --git a/Sunrise.Shared/Enums/Scores/ScoreProcessingDisposition.cs b/Sunrise.Shared/Enums/Scores/ScoreProcessingDisposition.cs new file mode 100644 index 00000000..025519fb --- /dev/null +++ b/Sunrise.Shared/Enums/Scores/ScoreProcessingDisposition.cs @@ -0,0 +1,7 @@ +namespace Sunrise.Shared.Enums.Scores; + +public enum ScoreProcessingDisposition +{ + Retryable = 0, + Permanent = 1, +} diff --git a/Sunrise.Shared/Enums/Scores/ScoreProcessingErrorCode.cs b/Sunrise.Shared/Enums/Scores/ScoreProcessingErrorCode.cs new file mode 100644 index 00000000..5d9e09bd --- /dev/null +++ b/Sunrise.Shared/Enums/Scores/ScoreProcessingErrorCode.cs @@ -0,0 +1,20 @@ +namespace Sunrise.Shared.Enums.Scores; + +public enum ScoreProcessingErrorCode +{ + Unexpected = 0, + BeatmapNotFound = 1, + DuplicateScore = 2, + PpCalculationFailed = 3, + ReplayMissing = 4, + InvalidMods = 5, + BannablePpThreshold = 6, + InvalidChecksums = 7, + UserNotFound = 8, + UserStatsNotFound = 9, + UserGradesNotFound = 10, + TransactionFailed = 11, + ParsedScoreInvalid = 12, + CancelledByOperator = 13, + InvalidScoreState = 14 +} \ No newline at end of file diff --git a/Sunrise.Shared/Enums/Scores/ScoreProcessingPriority.cs b/Sunrise.Shared/Enums/Scores/ScoreProcessingPriority.cs new file mode 100644 index 00000000..8440e547 --- /dev/null +++ b/Sunrise.Shared/Enums/Scores/ScoreProcessingPriority.cs @@ -0,0 +1,8 @@ +namespace Sunrise.Shared.Enums.Scores; + +public enum ScoreProcessingPriority +{ + Low = 0, + Normal = 50, + High = 100 +} diff --git a/Sunrise.Shared/Enums/Scores/ScoreProcessingStatus.cs b/Sunrise.Shared/Enums/Scores/ScoreProcessingStatus.cs new file mode 100644 index 00000000..fe3bc7c3 --- /dev/null +++ b/Sunrise.Shared/Enums/Scores/ScoreProcessingStatus.cs @@ -0,0 +1,8 @@ +namespace Sunrise.Shared.Enums.Scores; + +public enum ScoreProcessingStatus +{ + Pending = 0, + Processing = 1, + Failed = 2 +} diff --git a/Sunrise.Shared/Enums/Scores/ScoreTaskType.cs b/Sunrise.Shared/Enums/Scores/ScoreTaskType.cs new file mode 100644 index 00000000..ebbf77ce --- /dev/null +++ b/Sunrise.Shared/Enums/Scores/ScoreTaskType.cs @@ -0,0 +1,9 @@ +namespace Sunrise.Shared.Enums.Scores; + +public enum ScoreTaskType +{ + Submission = 0, + Recalculation = 1, + Restore = 2, + Delete = 3 +} diff --git a/Sunrise.Shared/Extensions/Beatmaps/BeatmapStatusExtensions.cs b/Sunrise.Shared/Extensions/Beatmaps/BeatmapStatusExtensions.cs index 88fe6aa0..e6088bb1 100644 --- a/Sunrise.Shared/Extensions/Beatmaps/BeatmapStatusExtensions.cs +++ b/Sunrise.Shared/Extensions/Beatmaps/BeatmapStatusExtensions.cs @@ -53,6 +53,11 @@ public static BeatmapStatus StringToBeatmapStatus(this string statusString) return _statusMap.GetValueOrDefault(statusString, BeatmapStatus.Pending); } + public static string BeatmapStatusToString(this BeatmapStatus statusString) + { + return _statusMapString.GetValueOrDefault(statusString, "pending"); + } + public static BeatmapStatusWeb StringToBeatmapStatusSearch(this string statusString) { return _statusSearchMap.GetValueOrDefault(statusString, BeatmapStatusWeb.Pending); diff --git a/Sunrise.Shared/Extensions/Performances/PerformanceAttributesExtensions.cs b/Sunrise.Shared/Extensions/Performances/PerformanceAttributesExtensions.cs index b09ee097..9151c0bf 100644 --- a/Sunrise.Shared/Extensions/Performances/PerformanceAttributesExtensions.cs +++ b/Sunrise.Shared/Extensions/Performances/PerformanceAttributesExtensions.cs @@ -1,4 +1,5 @@ using osu.Shared; +using Serilog; using Sunrise.Shared.Database.Models; using Sunrise.Shared.Objects.Serializable.Performances; using GameMode = Sunrise.Shared.Enums.Beatmaps.GameMode; @@ -78,6 +79,18 @@ private static double RecalculateToRelaxStdPerformance(PerformanceAttributes per 1.0 / 1.1 ) * multi; + if (double.IsNaN(relaxPp)) + { + Log.Error("RelaxStd pp recalculation resulted in NaN. Aim: {Aim}, Speed: {Speed}, Accuracy: {Accuracy}, Flashlight: {Flashlight}, Multi: {Multi}, AccDepression: {AccDepression}, StreamsNerf: {StreamsNerf}", + performance.PerformancePointsAim, + performance.PerformancePointsSpeed, + performance.PerformancePointsAccuracy, + performance.PerformancePointsFlashlight, + multi, + accDepression, + streamsNerf); + } + return double.IsNaN(relaxPp) ? 0.0 : relaxPp; } diff --git a/Sunrise.Shared/Extensions/Scores/ScoreExtensions.cs b/Sunrise.Shared/Extensions/Scores/ScoreExtensions.cs index 160169f8..a3b7e4e3 100644 --- a/Sunrise.Shared/Extensions/Scores/ScoreExtensions.cs +++ b/Sunrise.Shared/Extensions/Scores/ScoreExtensions.cs @@ -1,3 +1,4 @@ +using CSharpFunctionalExtensions; using Microsoft.Extensions.DependencyInjection; using osu.Shared; using Sunrise.Shared.Application; @@ -18,33 +19,33 @@ public static class ScoreExtensions { public static UserPersonalBestScores? GetUserPersonalBestScores(this List scores, int userId) { - var personalBestByTotalScore = scores.GetScoresGroupedByUsersBest().Find(x => x.UserId == userId); - if (personalBestByTotalScore == null) + var personalBestByScoreValue = scores.GetScoresGroupedByUsersBest().Find(x => x.UserId == userId); + if (personalBestByScoreValue == null) return null; var personalBestByPerformancePoints = Configuration.UseNewPerformanceCalculationAlgorithm ? scores.GetScoresGroupedByUsersBest(basedByPerformance: true).Find(x => x.UserId == userId) : null; - return new UserPersonalBestScores(personalBestByTotalScore, personalBestByPerformancePoints); + return new UserPersonalBestScores(personalBestByScoreValue, personalBestByPerformancePoints); } public static List GetScoresGroupedByUsersBest(this List scores, bool? basedByPerformance = null) where T : Score { - return GroupScoresByUserId(scores) + return scores.GroupScoresByUserId() .Select(x => x.ToList() .GroupScoresByBeatmapId() - .Select(y => y.OrderByDescending(z => basedByPerformance == true || z.GameMode.IsGameModeWithoutScoreMultiplier() ? z.PerformancePoints : z.TotalScore) - .First())) + .Select(y => + { + var groupedScores = y.ToList(); + + return basedByPerformance == true + ? groupedScores.SortScoresByPerformancePoints().First() + : groupedScores.SortScoresByTheirScoreValue().First(); + })) .SelectMany(x => x) .ToList(); } - public static List GetScoresGroupedByBeatmapBest(this List scores) where T : Score - { - return GroupScoresByBeatmapId(scores) - .Select(x => x.OrderByDescending(y => y.GameMode.IsGameModeWithoutScoreMultiplier() ? y.PerformancePoints : y.TotalScore).First()).ToList(); - } - public static IEnumerable> GroupScoresByBeatmapId(this List scores) where T : Score { return scores.GroupBy(x => x.BeatmapId); @@ -91,60 +92,86 @@ public static List SortScoresByTheirScoreValue(this List scores) where : scores.SortScoresByTotalScore(); } - public static List UpsertUserScoreToSortedScores(this List scores, T score) where T : Score + public static Score ToScore(this SubmittedScore baseScore, int userId, Beatmap beatmap, int timeElapsed) { - var leaderboard = GetScoresGroupedByUsersBest(scores); - - var oldScores = leaderboard.FindAll(x => x.UserId == score.UserId && x.BeatmapHash == score.BeatmapHash && x.GameMode == score.GameMode); - - foreach (var oldScore in oldScores) - { - leaderboard.Remove(oldScore); - } - - leaderboard.Add(score); - leaderboard = GetScoresGroupedByUsersBest(leaderboard); - leaderboard = leaderboard.SortScoresByTheirScoreValue(); - leaderboard = leaderboard.EnrichWithLeaderboardPositions(); - - return leaderboard.ToList(); - } - - public static (Score, string) TryParseToSubmittedScore(this string scoreString, Session session, Beatmap beatmap, DateTime scoreSubmittedAt) - { - var split = scoreString.Split(':'); - var score = new Score { - BeatmapHash = split[0], - UserId = session.UserId, + BeatmapHash = baseScore.BeatmapHash, + UserId = userId, BeatmapId = beatmap.Id, - ScoreHash = split[2], - Count300 = int.Parse(split[3]), - Count100 = int.Parse(split[4]), - Count50 = int.Parse(split[5]), - CountGeki = int.Parse(split[6]), - CountKatu = int.Parse(split[7]), - CountMiss = int.Parse(split[8]), - TotalScore = long.Parse(split[9]), - MaxCombo = int.Parse(split[10]), - Perfect = bool.Parse(split[11]), - Grade = split[12], - Mods = (Mods)int.Parse(split[13]), - IsPassed = bool.Parse(split[14]), + ScoreHash = baseScore.ScoreHash, + Count300 = baseScore.Count300, + Count100 = baseScore.Count100, + Count50 = baseScore.Count50, + CountGeki = baseScore.CountGeki, + CountKatu = baseScore.CountKatu, + CountMiss = baseScore.CountMiss, + TotalScore = baseScore.TotalScore, + MaxCombo = baseScore.MaxCombo, + Perfect = baseScore.Perfect, + Grade = baseScore.Grade, + Mods = baseScore.Mods, + IsPassed = baseScore.IsPassed, IsScoreable = beatmap.IsScoreable, - GameMode = (GameMode)int.Parse(split[15]), - WhenPlayed = scoreSubmittedAt, - OsuVersion = split[17].Trim(), + GameMode = baseScore.GameMode, + WhenPlayed = baseScore.WhenPlayed, + OsuVersion = baseScore.OsuVersion, BeatmapStatus = beatmap.Status, - ClientTime = DateTime.ParseExact(split[16], "yyMMddHHmmss", null) + ClientTime = baseScore.ClientTime, + Accuracy = baseScore.Accuracy, + TimeElapsed = timeElapsed }; score.LocalProperties = score.LocalProperties.FromScore(score); - score.GameMode = score.GameMode.EnrichWithMods(score.Mods); - score.Accuracy = PerformanceCalculator.CalculateAccuracy(score); - return (score, split[1]); + return score; + } + + public static Result TryParseBaseScore(this string scoreString, DateTime scoreSubmittedAt) + { + if (string.IsNullOrWhiteSpace(scoreString) || !scoreString.Contains(':')) + return Result.Failure("Invalid score string format"); + + var split = scoreString.Split(':'); + + if (split.Length < 18) + return Result.Failure("Invalid score string format"); + + try + { + var score = new SubmittedScore + { + BeatmapHash = string.IsNullOrWhiteSpace(split[0]) ? throw new Exception("Beatmap hash is empty") : split[0], + PlayerUsername = string.IsNullOrWhiteSpace(split[1]) ? throw new Exception("Player username is empty") : split[1], + ScoreHash = string.IsNullOrWhiteSpace(split[2]) ? throw new Exception("Score hash is empty") : split[2], + Count300 = int.Parse(split[3]), + Count100 = int.Parse(split[4]), + Count50 = int.Parse(split[5]), + CountGeki = int.Parse(split[6]), + CountKatu = int.Parse(split[7]), + CountMiss = int.Parse(split[8]), + TotalScore = long.Parse(split[9]), + MaxCombo = int.Parse(split[10]), + Perfect = bool.Parse(split[11]), + Grade = string.IsNullOrWhiteSpace(split[12]) ? throw new Exception("Grade is empty") : split[12], // TODO: This probably should be validated more strictly. + Mods = (Mods)int.Parse(split[13]), + IsPassed = bool.Parse(split[14]), + GameMode = (GameMode)int.Parse(split[15]), + WhenPlayed = scoreSubmittedAt, + OsuVersion = string.IsNullOrWhiteSpace(split[17]) ? throw new Exception("Osu version is empty") : split[17].Trim(), + ClientTime = DateTime.ParseExact(split[16], "yyMMddHHmmss", null), + Accuracy = 0 + }; + + score.GameMode = score.GameMode.EnrichWithMods(score.Mods); + score.Accuracy = PerformanceCalculator.CalculateAccuracy(score); + + return score; + } + catch (Exception ex) + { + return Result.Failure($"Error parsing score string: {ex.Message}"); + } } public static string ToScoreString(this Score score, string userUsername) @@ -223,7 +250,7 @@ public static string ComputeOnlineHash(this Score score, string username, string storyboardHash).ToHash(); } - public static async Task GetBeatmapInGameChatString(this Score score, BeatmapSet beatmapSet, Session session) + public static async Task GetBeatmapInGameChatString(this Score score, BeatmapSet beatmapSet, BaseSession session) { var beatmap = beatmapSet.Beatmaps.FirstOrDefault(b => b.Id == score.BeatmapId); if (beatmap == null) @@ -238,7 +265,7 @@ public static async Task GetBeatmapInGameChatString(this Score score, Be if (recalculateBeatmapResult.IsFailure) { - SunriseMetrics.RequestReturnedErrorCounterInc(RequestType.OsuSubmitScore, session, recalculateBeatmapResult.Error.Message); + SunriseMetrics.RequestReturnedErrorCounterInc(RequestType.OsuSubmitScore, score.UserId, recalculateBeatmapResult.Error.Message); } else { @@ -246,6 +273,11 @@ public static async Task GetBeatmapInGameChatString(this Score score, Be } } + return score.GetBeatmapInGameChatString(beatmapSet, beatmap); + } + + public static string GetBeatmapInGameChatString(this Score score, BeatmapSet beatmapSet, Beatmap beatmap) + { return $"{beatmap.GetBeatmapInGameChatString(beatmapSet)} {score.Mods.GetModsString()}| GameMode: {score.GameMode.ToVanillaGameMode()} | Acc: {score.Accuracy:0.00}% | {score.PerformancePoints:0.00}pp | {TimeConverter.SecondsToString(beatmap.TotalLength)} | {beatmap.DifficultyRating:0.00} ★"; } } \ No newline at end of file diff --git a/Sunrise.Shared/Extensions/Users/UserGradesExtensions.cs b/Sunrise.Shared/Extensions/Users/UserGradesExtensions.cs index 205dc620..803a72b5 100644 --- a/Sunrise.Shared/Extensions/Users/UserGradesExtensions.cs +++ b/Sunrise.Shared/Extensions/Users/UserGradesExtensions.cs @@ -15,30 +15,25 @@ public static void UpdateWithScore(this UserGrades userGrades, Score score, Scor return; if (prevScore != null) - userGrades.UpdateUserGradesCount(prevScore, -1); + UpdateUserGradesCount(userGrades, prevScore, -1); - userGrades.UpdateUserGradesCount(score, 1); + UpdateUserGradesCount(userGrades, score, 1); } - private static void UpdateUserGradesCount(this UserGrades userGrades, Score score, int delta) + private static void UpdateUserGradesCount(UserGrades userGrades, Score score, int delta) { switch (score.Grade) { - case "XH": userGrades.CountXH = UpdateGradeCount(userGrades.CountXH, delta); break; - case "X": userGrades.CountX = UpdateGradeCount(userGrades.CountX, delta); break; - case "SH": userGrades.CountSH = UpdateGradeCount(userGrades.CountSH, delta); break; - case "S": userGrades.CountS = UpdateGradeCount(userGrades.CountS, delta); break; - case "A": userGrades.CountA = UpdateGradeCount(userGrades.CountA, delta); break; - case "B": userGrades.CountB = UpdateGradeCount(userGrades.CountB, delta); break; - case "C": userGrades.CountC = UpdateGradeCount(userGrades.CountC, delta); break; - case "D": userGrades.CountD = UpdateGradeCount(userGrades.CountD, delta); break; + case "XH": userGrades.CountXH = Math.Max(0, userGrades.CountXH + delta); break; + case "X": userGrades.CountX = Math.Max(0, userGrades.CountX + delta); break; + case "SH": userGrades.CountSH = Math.Max(0, userGrades.CountSH + delta); break; + case "S": userGrades.CountS = Math.Max(0, userGrades.CountS + delta); break; + case "A": userGrades.CountA = Math.Max(0, userGrades.CountA + delta); break; + case "B": userGrades.CountB = Math.Max(0, userGrades.CountB + delta); break; + case "C": userGrades.CountC = Math.Max(0, userGrades.CountC + delta); break; + case "D": userGrades.CountD = Math.Max(0, userGrades.CountD + delta); break; case "F": break; default: throw new ArgumentOutOfRangeException($"Unknown grade: {score.Grade} while updating user grades with score."); } } - - private static int UpdateGradeCount(this int countGrade, int delta) - { - return Math.Max(0, countGrade + delta); - } } \ No newline at end of file diff --git a/Sunrise.Shared/Extensions/Users/UserStatsExtensions.cs b/Sunrise.Shared/Extensions/Users/UserStatsExtensions.cs deleted file mode 100644 index 4e1cc740..00000000 --- a/Sunrise.Shared/Extensions/Users/UserStatsExtensions.cs +++ /dev/null @@ -1,107 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using osu.Shared; -using Sunrise.Shared.Application; -using Sunrise.Shared.Database; -using Sunrise.Shared.Database.Models; -using Sunrise.Shared.Database.Models.Users; -using Sunrise.Shared.Extensions.Beatmaps; -using Sunrise.Shared.Objects; -using Sunrise.Shared.Services; -using GameMode = Sunrise.Shared.Enums.Beatmaps.GameMode; -using SubmissionStatus = Sunrise.Shared.Enums.Scores.SubmissionStatus; - -namespace Sunrise.Shared.Extensions.Users; - -public static class UserStatsExtensions -{ - public static async Task UpdateWithScore(this UserStats userStats, Score score, UserPersonalBestScores? personalBestScores, int timeElapsed) - { - var isNewScore = personalBestScores == null; - var isBetterTotalScoreValue = !isNewScore && score.TotalScore > personalBestScores?.BestScoreBasedByTotalScore.TotalScore; - var isBetterPerformanceValue = Configuration.UseNewPerformanceCalculationAlgorithm - ? !isNewScore && score.PerformancePoints > personalBestScores?.BestScoreForPerformanceCalculation.PerformancePoints - : isBetterTotalScoreValue; - var isFailed = !score.IsPassed && !score.Mods.HasFlag(Mods.NoFail); - - userStats.IncreaseTotalScore(score.TotalScore); - userStats.IncreaseTotalHits(score); - userStats.IncreasePlayTime(timeElapsed); - userStats.IncreasePlaycount(); - - if (isFailed || !score.IsScoreable) - return; - - userStats.UpdateMaxCombo(score.MaxCombo); - - if ((isNewScore || isBetterTotalScoreValue) && score.LocalProperties.IsRanked) - { - // If new score, add it to the ranked score. If a better score, add the difference between the new and the previous score. - userStats.RankedScore += isNewScore ? score.TotalScore : score.TotalScore - personalBestScores!.BestScoreBasedByTotalScore.TotalScore; - } - - if ((isNewScore || isBetterPerformanceValue) && score.LocalProperties.IsRanked) - { - using var scope = ServicesProviderHolder.CreateScope(); - var calculatorService = scope.ServiceProvider.GetRequiredService(); - - var user = userStats.User; - - if (user == null) - { - var database = scope.ServiceProvider.GetRequiredService(); - await database.DbContext.Entry(userStats).Reference(s => s.User).LoadAsync(); - user = userStats.User; - } - - userStats.PerformancePoints = - await calculatorService.CalculateUserWeightedPerformance(user, score.GameMode, score); - userStats.Accuracy = await calculatorService.CalculateUserWeightedAccuracy(user, score.GameMode, score); - } - } - - public static void UpdateWithDbScore(this UserStats userStats, Score score) - { - var isFailed = !score.IsPassed && !score.Mods.HasFlag(Mods.NoFail); - - userStats.IncreaseTotalScore(score.TotalScore); - userStats.IncreaseTotalHits(score); - userStats.IncreasePlaycount(); - - if (isFailed || !score.IsScoreable) - return; - - userStats.UpdateMaxCombo(score.MaxCombo); - - if (score.SubmissionStatus == SubmissionStatus.Best && score.BeatmapStatus.IsRanked()) - { - userStats.RankedScore += score.TotalScore; - } - } - - private static void IncreaseTotalHits(this UserStats userStats, Score newScore) - { - userStats.TotalHits += newScore.Count300 + newScore.Count100 + newScore.Count50; - if ((GameMode)newScore.GameMode.ToVanillaGameMode() is GameMode.Taiko or GameMode.Mania) - userStats.TotalHits += newScore.CountGeki + newScore.CountKatu; - } - - private static void UpdateMaxCombo(this UserStats userStats, int combo) - { - userStats.MaxCombo = Math.Max(userStats.MaxCombo, combo); - } - - private static void IncreasePlayTime(this UserStats userStats, int time) - { - userStats.PlayTime += time; - } - - private static void IncreaseTotalScore(this UserStats userStats, long score) - { - userStats.TotalScore += score; - } - - private static void IncreasePlaycount(this UserStats userStats) - { - userStats.PlayCount++; - } -} \ No newline at end of file diff --git a/Sunrise.Shared/Objects/ScoreProcessingError.cs b/Sunrise.Shared/Objects/ScoreProcessingError.cs new file mode 100644 index 00000000..298d35f8 --- /dev/null +++ b/Sunrise.Shared/Objects/ScoreProcessingError.cs @@ -0,0 +1,16 @@ +using CSharpFunctionalExtensions; +using Sunrise.Shared.Enums.Scores; + +namespace Sunrise.Shared.Objects; + +public readonly record struct ScoreProcessingError( + ScoreProcessingErrorCode Code, + string Message, + ScoreProcessingDisposition Disposition = ScoreProcessingDisposition.Permanent) +{ + public Result ToResult() + => Result.Failure(this); + + public UnitResult ToUnit() + => UnitResult.Failure(this); +} diff --git a/Sunrise.Shared/Objects/SubmittedScore.cs b/Sunrise.Shared/Objects/SubmittedScore.cs new file mode 100644 index 00000000..85acf5c8 --- /dev/null +++ b/Sunrise.Shared/Objects/SubmittedScore.cs @@ -0,0 +1,28 @@ +using osu.Shared; +using GameMode = Sunrise.Shared.Enums.Beatmaps.GameMode; + +namespace Sunrise.Shared.Objects; + +public class SubmittedScore +{ + public required string PlayerUsername { get; init; } + public required string ScoreHash { get; init; } + public required string BeatmapHash { get; init; } + public required long TotalScore { get; init; } + public required int MaxCombo { get; init; } + public required int Count300 { get; init; } + public required int Count100 { get; init; } + public required int Count50 { get; init; } + public required int CountMiss { get; init; } + public required int CountKatu { get; init; } + public required int CountGeki { get; init; } + public required bool Perfect { get; init; } + public required Mods Mods { get; init; } + public required string Grade { get; init; } + public required bool IsPassed { get; init; } + public required GameMode GameMode { get; set; } + public required DateTime WhenPlayed { get; init; } + public required string OsuVersion { get; init; } + public required DateTime ClientTime { get; init; } + public required double Accuracy { get; set; } +} \ No newline at end of file diff --git a/Sunrise.Shared/Objects/UserBeatmapPeers.cs b/Sunrise.Shared/Objects/UserBeatmapPeers.cs new file mode 100644 index 00000000..194fc79b --- /dev/null +++ b/Sunrise.Shared/Objects/UserBeatmapPeers.cs @@ -0,0 +1,5 @@ +namespace Sunrise.Shared.Objects; + +public sealed record UserBeatmapPeers( + UserPersonalBestScores? SameModsPeer, + UserPersonalBestScores? OverallPeer); \ No newline at end of file diff --git a/Sunrise.Shared/Objects/UserPersonalBestScores.cs b/Sunrise.Shared/Objects/UserPersonalBestScores.cs index bf8319ca..5085f653 100644 --- a/Sunrise.Shared/Objects/UserPersonalBestScores.cs +++ b/Sunrise.Shared/Objects/UserPersonalBestScores.cs @@ -3,8 +3,8 @@ namespace Sunrise.Shared.Objects; -public class UserPersonalBestScores(Score bestScoreBasedByTotalScore, Score? bestScoreBasedByPerformancePoints = null) +public class UserPersonalBestScores(Score bestScoreByScoreValue, Score? bestScoreBasedByPerformancePoints = null) { - public Score BestScoreBasedByTotalScore { get; } = bestScoreBasedByTotalScore; - public Score BestScoreForPerformanceCalculation { get; } = Configuration.UseNewPerformanceCalculationAlgorithm ? bestScoreBasedByPerformancePoints ?? bestScoreBasedByTotalScore : bestScoreBasedByTotalScore; + public Score BestScoreByScoreValue { get; } = bestScoreByScoreValue; + public Score BestScoreForPerformanceCalculation { get; } = Configuration.UseNewPerformanceCalculationAlgorithm ? bestScoreBasedByPerformancePoints ?? bestScoreByScoreValue : bestScoreByScoreValue; } \ No newline at end of file diff --git a/Sunrise.Shared/Repositories/ChatChannelRepository.cs b/Sunrise.Shared/Repositories/ChatChannelRepository.cs index 4350fa39..367e8287 100644 --- a/Sunrise.Shared/Repositories/ChatChannelRepository.cs +++ b/Sunrise.Shared/Repositories/ChatChannelRepository.cs @@ -84,6 +84,11 @@ private ChatChannel CreateAbstractChannel(string name) return channel; } + public ChatChannel? GetScoreAnnouncementChannel() + { + return _channels.GetValueOrDefault("#announce"); + } + public List GetChannels(Session? session = null) { if (session == null) @@ -102,4 +107,4 @@ public List GetChannels(Session? session = null) return _channels.Values.Where(x => x.IsPublic || sessionPrivilege.HasFlag(UserPrivilege.Admin)).ToList(); } -} \ No newline at end of file +} diff --git a/Sunrise.Shared/Services/BeatmapService.cs b/Sunrise.Shared/Services/BeatmapService.cs index a0db2f5b..d2a6bcfb 100644 --- a/Sunrise.Shared/Services/BeatmapService.cs +++ b/Sunrise.Shared/Services/BeatmapService.cs @@ -32,7 +32,7 @@ public async Task> GetBeatmapSet(BaseSession se Status = HttpStatusCode.BadRequest }); - BeatmapSet? beatmapSet; + BeatmapSet beatmapSet; // TODO: Since this logic is only required to not accidentally lose submitted scores if we cant fetch beatmaps (observatory/mirrors are down, etc.), // I would suggest writing scores as is in the database and have a background task that retries fetching beatmaps for scores that dont have them until they are found. (This would also allow the server to be rebooted without losing scores) @@ -45,8 +45,13 @@ public async Task> GetBeatmapSet(BaseSession se { await _dbSemaphore.WaitAsync(linkedCts.Token); - beatmapSet = await database.Beatmaps.GetCachedBeatmapSet(beatmapSetId, beatmapHash, beatmapId); - if (beatmapSet != null) return beatmapSet; + var cachedBeatmapSet = await database.Beatmaps.GetCachedBeatmapSet(beatmapSetId, beatmapHash, beatmapId); + + if (cachedBeatmapSet != null) + { + beatmapSet = cachedBeatmapSet; + return beatmapSet; + } var beatmapSetTask = Result.Failure(new ErrorMessage { @@ -115,7 +120,7 @@ public async Task, ErrorMessage>> GetBeatmapSets(BaseSes var beatmapSetsResults = await Task.WhenAll(beatmapSetsTasks); - if (beatmapSetsResults.Any(b => b.IsFailure && (ignoreNotFoundBeatmapSets == false || b.Error.Status != HttpStatusCode.NotFound))) + if (beatmapSetsResults.Any(b => b.IsFailure && (!ignoreNotFoundBeatmapSets || b.Error.Status != HttpStatusCode.NotFound))) { return beatmapSetsResults.First(v => v.IsFailure).Error; } diff --git a/Sunrise.Shared/Services/CalculatorService.cs b/Sunrise.Shared/Services/CalculatorService.cs index 89900f1a..aea9bbd8 100644 --- a/Sunrise.Shared/Services/CalculatorService.cs +++ b/Sunrise.Shared/Services/CalculatorService.cs @@ -27,11 +27,10 @@ public async Task> CalculateScorePer Mods = score.Mods.IgnoreNotStandardModsForRecalculation() }; - // TODO: Since this logic is only required to not accidentally lose submitted scores if we cant fetch beatmaps (observatory/mirrors are down, etc.), - // I would suggest writing scores as is in the database and have a background task that retries fetching beatmaps for scores that dont have them until they are found. (This would also allow the server to be rebooted without losing scores) - using var timeoutCts = retryCount == int.MaxValue - ? new CancellationTokenSource() - : new CancellationTokenSource(TimeSpan.FromMinutes(10)); + if (retryCount > 3) + throw new ArgumentException("Retry count cannot be greater than 3 to avoid excessively long-running requests.", nameof(retryCount)); + + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromMinutes(5)); using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(timeoutCts.Token, ct); var performanceResult = await client.PostRequestWithBody(session, ApiType.CalculateScorePerformance, serializedScore, shouldSendRateLimitWarning: shouldSendRateLimitWarning, ct: linkedCts.Token); @@ -111,7 +110,7 @@ public async Task> CalculateBeatmapP return (performances[0], performances[1], performances[2], performances[3]); } - public async Task CalculateUserWeightedAccuracy(User user, GameMode mode, Score? score = null) + public async Task CalculateUserWeightedAccuracy(User user, GameMode mode) { var (userBestScores, _) = await database.Value.Scores.GetUserScores(user.Id, mode, @@ -121,17 +120,36 @@ public async Task CalculateUserWeightedAccuracy(User user, GameMode mode IgnoreCountQueryIfExists = true }); - return PerformanceCalculator.CalculateUserWeightedAccuracy(userBestScores, score); + return PerformanceCalculator.CalculateUserWeightedAccuracy(userBestScores); } - public async Task CalculateUserWeightedPerformance(User user, GameMode mode, Score? score = null) + public async Task CalculateUserWeightedPerformance(User user, GameMode mode) { var (userBestScores, _) = await database.Value.Scores.GetUserScores(user.Id, mode, ScoreTableType.Best, - new QueryOptions(true, new Pagination(1, 100))); + new QueryOptions(true, new Pagination(1, 100)) + { + IgnoreCountQueryIfExists = true + }); + + return PerformanceCalculator.CalculateUserWeightedPerformance(userBestScores); + } + + public async Task<(double PerformancePoints, double Accuracy)> CalculateUserWeightedStats(User user, GameMode mode) + { + var (userBestScores, _) = await database.Value.Scores.GetUserScores(user.Id, + mode, + ScoreTableType.Best, + new QueryOptions(true, new Pagination(1, 100)) + { + IgnoreCountQueryIfExists = true + }); + + var pp = PerformanceCalculator.CalculateUserWeightedPerformance(userBestScores); + var accuracy = PerformanceCalculator.CalculateUserWeightedAccuracy(userBestScores); - return PerformanceCalculator.CalculateUserWeightedPerformance(userBestScores, score); + return (pp, accuracy); } private bool IsValidResult(Result result) diff --git a/Sunrise.Shared/Sunrise.Shared.csproj b/Sunrise.Shared/Sunrise.Shared.csproj index 1e180c0d..7b7f9d0c 100644 --- a/Sunrise.Shared/Sunrise.Shared.csproj +++ b/Sunrise.Shared/Sunrise.Shared.csproj @@ -34,6 +34,7 @@ + diff --git a/Sunrise.Shared/Utils/Calculators/PerformanceCalculator.cs b/Sunrise.Shared/Utils/Calculators/PerformanceCalculator.cs index 8d5056e5..29f23e93 100644 --- a/Sunrise.Shared/Utils/Calculators/PerformanceCalculator.cs +++ b/Sunrise.Shared/Utils/Calculators/PerformanceCalculator.cs @@ -1,23 +1,26 @@ using Sunrise.Shared.Database.Models; using Sunrise.Shared.Extensions.Beatmaps; -using Sunrise.Shared.Extensions.Scores; +using Sunrise.Shared.Objects; using Mods = osu.Shared.Mods; -using GameMode = Sunrise.Shared.Enums.Beatmaps.GameMode; +using GameModeVanilla = osu.Shared.GameMode; namespace Sunrise.Shared.Utils.Calculators; public static class PerformanceCalculator { - public static double CalculateUserWeightedAccuracy(List userBestScores, Score? score = null) + public static (double PerformancePoints, double Accuracy) CalculateUserWeightedStats(List userBestScores) { - if (userBestScores.Count == 0 && score == null) return 0; + var pp = CalculateUserWeightedPerformance(userBestScores); + var accuracy = CalculateUserWeightedAccuracy(userBestScores); - if (userBestScores.Count > 100) throw new ArgumentOutOfRangeException(nameof(userBestScores)); + return (pp, accuracy); + } - if (score != null) - { - userBestScores = userBestScores.UpsertUserScoreToSortedScores(score).SortScoresByPerformancePoints(); - } + public static double CalculateUserWeightedAccuracy(List userBestScores) + { + if (userBestScores.Count == 0) return 0; + + if (userBestScores.Count > 100) throw new ArgumentOutOfRangeException(nameof(userBestScores)); var top100Scores = userBestScores.Take(100).ToList(); @@ -29,17 +32,12 @@ public static double CalculateUserWeightedAccuracy(List userBestScores, S return weightedAccuracy * bonusAccuracy / 100; } - public static double CalculateUserWeightedPerformance(List userBestScores, Score? score = null) + public static double CalculateUserWeightedPerformance(List userBestScores) { - if (userBestScores.Count == 0 && score == null) return 0; + if (userBestScores.Count == 0) return 0; if (userBestScores.Count > 100) throw new ArgumentOutOfRangeException(nameof(userBestScores)); - if (score != null) - { - userBestScores = userBestScores.UpsertUserScoreToSortedScores(score).SortScoresByPerformancePoints(); - } - var top100Scores = userBestScores.Take(100).ToList(); const double bonusNumber = 416.6667; @@ -53,30 +51,46 @@ public static double CalculateUserWeightedPerformance(List userBestScores public static float CalculateAccuracy(Score score) { - var scoreVanillaGameMode = (GameMode)score.GameMode.ToVanillaGameMode(); + return CalculateAccuracy(score.Count300, score.Count100, score.Count50, score.CountMiss, score.CountKatu, score.CountGeki, score.GameMode.ToVanillaGameMode(), score.Mods); + } + + public static float CalculateAccuracy(SubmittedScore score) + { + return CalculateAccuracy(score.Count300, score.Count100, score.Count50, score.CountMiss, score.CountKatu, score.CountGeki, score.GameMode.ToVanillaGameMode(), score.Mods); + } - var totalHits = scoreVanillaGameMode switch + private static float CalculateAccuracy( + int count300, + int count100, + int count50, + int countMiss, + int countKatu, + int countGeki, + GameModeVanilla mode, + Mods mods) + { + var totalHits = mode switch { - GameMode.Standard => score.Count300 + score.Count100 + score.Count50 + score.CountMiss, - GameMode.Taiko => score.Count300 + score.Count100 + score.CountMiss, - GameMode.CatchTheBeat => score.Count300 + score.Count100 + score.Count50 + score.CountKatu + score.CountMiss, - GameMode.Mania => score.Count300 + score.Count100 + score.Count50 + score.CountGeki + score.CountKatu + score.CountMiss, - _ => 0 + GameModeVanilla.Standard => count300 + count100 + count50 + countMiss, + GameModeVanilla.Taiko => count300 + count100 + countMiss, + GameModeVanilla.CatchTheBeat => count300 + count100 + count50 + countKatu + countMiss, + GameModeVanilla.Mania => count300 + count100 + count50 + countGeki + countKatu + countMiss, + _ => throw new ArgumentOutOfRangeException(nameof(mode), mode, null) }; if (totalHits == 0) return 0; - return scoreVanillaGameMode switch + return mode switch { - GameMode.Standard => 100f * (score.Count300 * 300f + score.Count100 * 100f + score.Count50 * 50f) / (totalHits * 300f), - GameMode.Taiko => 100f * (score.Count300 + score.Count100 * 0.5f) / totalHits, - GameMode.CatchTheBeat => 100f * (score.Count300 + score.Count100 + score.Count50) / totalHits, - GameMode.Mania => score.Mods.HasFlag(Mods.ScoreV2) switch + GameModeVanilla.Standard => 100f * (count300 * 300f + count100 * 100f + count50 * 50f) / (totalHits * 300f), + GameModeVanilla.Taiko => 100f * (count300 + count100 * 0.5f) / totalHits, + GameModeVanilla.CatchTheBeat => 100f * (count300 + count100 + count50) / totalHits, + GameModeVanilla.Mania => mods.HasFlag(Mods.ScoreV2) switch { - true => 100f * (score.CountGeki * 305f + score.Count300 * 300f + score.CountKatu * 200f + score.Count100 * 100f + score.Count50 * 50f) / (totalHits * 305f), - false => 100f * ((score.Count300 + score.CountGeki) * 300f + score.CountKatu * 200f + score.Count100 * 100f + score.Count50 * 50f) / (totalHits * 300f) + true => 100f * (countGeki * 305f + count300 * 300f + countKatu * 200f + count100 * 100f + count50 * 50f) / (totalHits * 305f), + false => 100f * ((count300 + countGeki) * 300f + countKatu * 200f + count100 * 100f + count50 * 50f) / (totalHits * 300f) }, - _ => 0 + _ => throw new ArgumentOutOfRangeException(nameof(mode), mode, null) }; } } \ No newline at end of file diff --git a/Sunrise.Shared/Utils/ModsValidationUtil.cs b/Sunrise.Shared/Utils/ModsValidationUtil.cs new file mode 100644 index 00000000..8a1be692 --- /dev/null +++ b/Sunrise.Shared/Utils/ModsValidationUtil.cs @@ -0,0 +1,84 @@ +using CSharpFunctionalExtensions; +using osu.Shared; + +namespace Sunrise.Shared.Utils; + +public static class ModsValidationUtil +{ + // NOTE: Data from https://osu.ppy.sh/wiki/en/Gameplay/Game_modifier + + public static readonly List InvalidMods = [Mods.Target, Mods.Random, Mods.KeyCoop, Mods.Cinema, Mods.Autoplay]; + + public static readonly List IgnoreMods = [Mods.None, Mods.TouchDevice, Mods.Relax, Mods.Relax2, Mods.ScoreV2]; + + public static readonly List DefaultDifficultyReductionMods = + [ + Mods.Easy, + Mods.NoFail, + Mods.HalfTime + ]; + + public static readonly List DefaultDifficultyIncreaseMods = + [ + Mods.HardRock, + Mods.SuddenDeath, + Mods.Perfect, + Mods.DoubleTime, + Mods.Nightcore, + Mods.Hidden, + Mods.Flashlight + ]; + + public static readonly List DefaultMods = DefaultDifficultyIncreaseMods.Concat(DefaultDifficultyReductionMods).ToList(); + + public static readonly Dictionary> GameModesToAllowedMods = new() + { + { + GameMode.Standard, new List([Mods.SpunOut]).Concat(DefaultMods).ToList() + }, + { + GameMode.Taiko, DefaultMods + }, + { + GameMode.CatchTheBeat, DefaultMods + }, + { + GameMode.Mania, new List([Mods.Key1, Mods.Key2, Mods.Key3, Mods.Key4, Mods.Key5, Mods.Key6, Mods.Key7, Mods.Key8, Mods.Key9, Mods.KeyCoop, Mods.FadeIn, Mods.Mirror, Mods.Random]).Concat(DefaultMods).ToList() + } + }; + + public static readonly List> ModsWithSinglePossibleInstance = new() + { + new List([Mods.DoubleTime, Mods.HalfTime]), + new List([Mods.NoFail, Mods.SuddenDeath]), + new List([Mods.Key1, Mods.Key2, Mods.Key3, Mods.Key4, Mods.Key5, Mods.Key6, Mods.Key7, Mods.Key8, Mods.Key9]), + new List([Mods.Relax, Mods.Relax2, Mods.ScoreV2]), // Actually, ScoreV2 can be played with both Relax and Relax2 (Autopilot), but as we treat each mode differently, we only allow one at the time. + new List([Mods.Easy, Mods.HardRock]) + }; + + public static Result ValidateMods(Mods mods, GameMode gameMode) + { + var hasInvalidMods = InvalidMods.Any(mod => mods.HasFlag(mod)); + + if (hasInvalidMods) + return Result.Failure("Score includes invalid mods"); + + var allowedGameModeMods = GameModesToAllowedMods[gameMode]; + var nonIgnoredMods = mods & ~IgnoreMods.Aggregate(Mods.None, (current, mod) => current | mod); + var hasInvalidModeCombination = nonIgnoredMods != Mods.None && !allowedGameModeMods.Any(mod => nonIgnoredMods.HasFlag(mod)); + + if (hasInvalidModeCombination) + return Result.Failure("Score includes mods that are not allowed for the game mode"); + + var hasMultipleInstancesOfSingleInstanceMods = ModsWithSinglePossibleInstance.Any(modList => + { + var count = modList.Count(mod => mods.HasFlag(mod)); + return count > 1; + }); + + if (hasMultipleInstancesOfSingleInstanceMods) + return Result.Failure("Score includes multiple instances of single instance mods"); + + return Result.Success(); + } +} \ No newline at end of file diff --git a/Sunrise.Tests/Abstracts/DatabaseTest.cs b/Sunrise.Tests/Abstracts/DatabaseTest.cs index e886b135..6f2b481d 100644 --- a/Sunrise.Tests/Abstracts/DatabaseTest.cs +++ b/Sunrise.Tests/Abstracts/DatabaseTest.cs @@ -3,6 +3,7 @@ using Sunrise.Shared.Application; using Sunrise.Shared.Database; using Sunrise.Shared.Database.Models; +using Sunrise.Shared.Database.Models.Scores; using Sunrise.Shared.Database.Models.Users; using Sunrise.Shared.Enums.Beatmaps; using Sunrise.Shared.Objects; @@ -12,17 +13,28 @@ using Sunrise.Tests.Extensions; using Sunrise.Tests.Services; using Sunrise.Tests.Services.Mock; +using Sunrise.Tests.Utils.Processing; namespace Sunrise.Tests.Abstracts; -public abstract class DatabaseTest(IntegrationDatabaseFixture fixture) : BaseTest, IAsyncLifetime +// TODO: Switch reuseScopeInContext to true and remove it after fixing tests who depends on EF Core context being clear +// - Okay, this is actually might be the heated topic, so I will clarify some things about this +// New scope was always almost fine for us, since we were refetching the entities with new DB call, not EF native reload +// But if we are going to try to use services like Database inside the test method, they are going to be working incorrectly, since the context is expected to be the sape per HTTP call +// Not to mention that it should be actually expected to have the same scope during the test suite execution, since we are testing the database, and not the scope itself +// BUT! Since for the HTTP calls we create new scope, after any HTTP call execution any of our EF core internal entities would be outdated +// The problem is that EF Core is lazy and will get the element directly after the HTTP call execution instead of going to the database, which is probably outdated. The only way to fix this is to manually clear tracking for entities AFAIK. +// We *could* clear the tracking automatically after the HTTP calls, but I'm afraid it's not the better solution than just to recreate the scope on each call. +// For now, this is actually kinda works, but this is a technical debt :/ +public abstract class DatabaseTest(IntegrationDatabaseFixture fixture, bool reuseScopeInContext = false) : BaseTest, IAsyncLifetime { private readonly FileService _fileService = new(); private readonly MockService _mocker = new(); + private IServiceScope? _scope; protected SunriseServerFactory App => fixture.App; - protected IServiceScope Scope => App.Server.Services.CreateScope(); + protected IServiceScope Scope => reuseScopeInContext ? _scope ??= App.Server.Services.CreateScope() : App.Server.Services.CreateScope(); protected DatabaseService Database => Scope.ServiceProvider.GetRequiredService(); protected SessionRepository Sessions => Scope.ServiceProvider.GetRequiredService(); @@ -34,6 +46,12 @@ public async Task InitializeAsync() public Task DisposeAsync() { + if (!reuseScopeInContext) + { + _scope?.Dispose(); + _scope = null; + } + return Task.CompletedTask; } @@ -194,4 +212,23 @@ protected async Task CreateTestScore(User user, bool withReplay = true) return (replay, beatmapId); } + + protected async Task CreateReplayFileId(int userId) + { + IFormFile replayFile = new FormFile(new MemoryStream(new byte[1024]), 0, 1024, "data", "score.osr"); + var replayResult = await Database.Scores.Files.AddReplayFile(userId, replayFile); + + Assert.True(replayResult.IsSuccess); + return replayResult.Value.Id; + } + + protected async Task CreateTestScoreSubmissionRequest(Score score, User user, bool withReplay = true) + { + int? replayFileId = withReplay ? await CreateReplayFileId(user.Id) : null; + var queueEntry = ScoreSubmissionRequestTestDataFactory.CreateQueueEntry(score, user.Username, replayFileId: replayFileId); + + await Database.ScoreSubmissionRequests.AddQueueEntry(queueEntry); + + return queueEntry; + } } \ No newline at end of file diff --git a/Sunrise.Tests/Extensions/UserGradesExtensions.cs b/Sunrise.Tests/Extensions/UserGradesExtensions.cs new file mode 100644 index 00000000..b1fcecf5 --- /dev/null +++ b/Sunrise.Tests/Extensions/UserGradesExtensions.cs @@ -0,0 +1,49 @@ +using osu.Shared; +using Sunrise.Shared.Database.Models; +using Sunrise.Shared.Database.Models.Users; +using Sunrise.Shared.Extensions.Beatmaps; +using SubmissionStatus = Sunrise.Shared.Enums.Scores.SubmissionStatus; + +namespace Sunrise.Tests.Extensions; + +public static class UserGradesExtensions +{ + public static void UpdateWithDbScore(this UserGrades userGrades, Score score) + { + var isFailed = !score.IsPassed && !score.Mods.HasFlag(Mods.NoFail); + + if (isFailed || !score.IsScoreable) + return; + + if (score.SubmissionStatus != SubmissionStatus.Best || !score.BeatmapStatus.IsRanked()) + return; + + switch (score.Grade) + { + case "XH": + userGrades.CountXH++; + break; + case "X": + userGrades.CountX++; + break; + case "SH": + userGrades.CountSH++; + break; + case "S": + userGrades.CountS++; + break; + case "A": + userGrades.CountA++; + break; + case "B": + userGrades.CountB++; + break; + case "C": + userGrades.CountC++; + break; + case "D": + userGrades.CountD++; + break; + } + } +} \ No newline at end of file diff --git a/Sunrise.Tests/Extensions/UserStatsExtensions.cs b/Sunrise.Tests/Extensions/UserStatsExtensions.cs new file mode 100644 index 00000000..b0eb367c --- /dev/null +++ b/Sunrise.Tests/Extensions/UserStatsExtensions.cs @@ -0,0 +1,44 @@ +using osu.Shared; +using Sunrise.Shared.Database.Models; +using Sunrise.Shared.Database.Models.Users; +using Sunrise.Shared.Extensions.Beatmaps; +using GameMode = Sunrise.Shared.Enums.Beatmaps.GameMode; +using SubmissionStatus = Sunrise.Shared.Enums.Scores.SubmissionStatus; + +namespace Sunrise.Tests.Extensions; + +public static class UserStatsExtensions +{ + public static void UpdateWithDbScore(this UserStats userStats, Score score) + { + if (score.SubmissionStatus == SubmissionStatus.Deleted) + return; + + var isFailed = !score.IsPassed && !score.Mods.HasFlag(Mods.NoFail); + + userStats.TotalScore += score.TotalScore; + IncreaseTotalHits(userStats, score); + userStats.PlayTime += score.TimeElapsed; + userStats.PlayCount++; + + if (isFailed || !score.IsScoreable) + return; + + userStats.MaxCombo = Math.Max(userStats.MaxCombo, score.MaxCombo); + + if (score.SubmissionStatus == SubmissionStatus.Best && score.BeatmapStatus.IsRanked()) + userStats.RankedScore += score.TotalScore; + } + + public static void EnrichWithUserData(this UserStats stats, User user) + { + stats.UserId = user.Id; + } + + private static void IncreaseTotalHits(UserStats userStats, Score score) + { + userStats.TotalHits += score.Count300 + score.Count100 + score.Count50; + if ((GameMode)score.GameMode.ToVanillaGameMode() is GameMode.Taiko or GameMode.Mania) + userStats.TotalHits += score.CountGeki + score.CountKatu; + } +} \ No newline at end of file diff --git a/Sunrise.Tests/Services/Mock/MockHttpClientService.cs b/Sunrise.Tests/Services/Mock/MockHttpClientService.cs index 06067f19..7fb84541 100644 --- a/Sunrise.Tests/Services/Mock/MockHttpClientService.cs +++ b/Sunrise.Tests/Services/Mock/MockHttpClientService.cs @@ -20,6 +20,26 @@ public void MockResponse(ApiType apiType, Func res _mockResponses[apiType] = args => responseFactory(args)!; } + public void MockBeatmapSetByBeatmapIdNotFound(int beatmapId) + { + MockResponse(ApiType.BeatmapSetDataByBeatmapId, + _ => new ErrorMessage + { + Message = $"Beatmap set with beatmap ID {beatmapId} not found.", + Status = HttpStatusCode.NotFound + }); + } + + public void MockBeatmapSetByHashInternalServerError() + { + MockResponse(ApiType.BeatmapSetDataByHash, + _ => new ErrorMessage + { + Message = "Internal server error while fetching beatmap set. ", + Status = HttpStatusCode.InternalServerError + }); + } + public void MockPerformanceCalculation(double performancePoints = 500, double difficultyRating = 5.0) { MockResponse(ApiType.CalculateScorePerformance, diff --git a/Sunrise.Tests/Services/Mock/Services/MockBeatmapService.cs b/Sunrise.Tests/Services/Mock/Services/MockBeatmapService.cs index ae3f9848..ffd10a08 100644 --- a/Sunrise.Tests/Services/Mock/Services/MockBeatmapService.cs +++ b/Sunrise.Tests/Services/Mock/Services/MockBeatmapService.cs @@ -1,5 +1,7 @@ -using Sunrise.Shared.Enums.Beatmaps; +using Sunrise.Shared.Database.Models; +using Sunrise.Shared.Enums.Beatmaps; using Sunrise.Shared.Objects.Serializable; +using Sunrise.Tests.Extensions; using Beatmap = Sunrise.Shared.Objects.Serializable.Beatmap; using GameMode = osu.Shared.GameMode; @@ -123,8 +125,50 @@ public async Task MockRandomBeatmapSet() return await MockBeatmapSet(beatmapSet); } + public async Task<(BeatmapSet, Beatmap)> MockRandomBeatmapWithSet() + { + var beatmapSet = service.Beatmap.GetRandomBeatmapSet(); + + beatmapSet = await MockBeatmapSet(beatmapSet); + var beatmap = beatmapSet.Beatmaps?.First() ?? throw new NullReferenceException(); + + return (beatmapSet, beatmap); + } + public async Task MockBeatmapSet(BeatmapSet beatmapSet) { return await service.Redis.MockBeatmapSetCache(beatmapSet); } + + public async Task<(BeatmapSet, Beatmap)> MockRankedBeatmapWithSetForScore(Score score) + { + var beatmapSet = service.Beatmap.GetRandomBeatmapSet(); + beatmapSet.Ranked = (int)BeatmapStatusWeb.Ranked; + beatmapSet.StatusString = "ranked"; + + var beatmap = beatmapSet.Beatmaps!.First(); + beatmap.EnrichWithScoreData(score); + beatmap.Ranked = (int)BeatmapStatusWeb.Ranked; + beatmap.StatusString = "ranked"; + + await service.Beatmap.MockBeatmapSet(beatmapSet); + + return (beatmapSet, beatmap); + } + + public async Task<(BeatmapSet, Beatmap)> MockGraveyardBeatmapWithSetForScore(Score score) + { + var beatmapSet = service.Beatmap.GetRandomBeatmapSet(); + beatmapSet.Ranked = (int)BeatmapStatusWeb.Graveyard; + beatmapSet.StatusString = "graveyard"; + + var beatmap = beatmapSet.Beatmaps!.First(); + beatmap.EnrichWithScoreData(score); + beatmap.Ranked = (int)BeatmapStatusWeb.Graveyard; + beatmap.StatusString = "graveyard"; + + await service.Beatmap.MockBeatmapSet(beatmapSet); + + return (beatmapSet, beatmap); + } } \ No newline at end of file diff --git a/Sunrise.Tests/Services/Mock/Services/MockScoreService.cs b/Sunrise.Tests/Services/Mock/Services/MockScoreService.cs index 0f3aa6bc..939da6e2 100644 --- a/Sunrise.Tests/Services/Mock/Services/MockScoreService.cs +++ b/Sunrise.Tests/Services/Mock/Services/MockScoreService.cs @@ -1,7 +1,10 @@ using osu.Shared; using Sunrise.Shared.Database.Models; using Sunrise.Shared.Enums.Beatmaps; +using Sunrise.Shared.Extensions.Beatmaps; +using Sunrise.Shared.Objects; using Sunrise.Shared.Objects.Serializable.Performances; +using Sunrise.Shared.Utils; using Sunrise.Tests.Extensions; using GameMode = Sunrise.Shared.Enums.Beatmaps.GameMode; using SubmissionStatus = Sunrise.Shared.Enums.Scores.SubmissionStatus; @@ -18,7 +21,7 @@ public class MockScoreService(MockService service) /// public Score GetRandomScore() { - return new Score + var score = new Score { UserId = service.GetRandomInteger(length: 6), BeatmapId = service.GetRandomInteger(length: 6), @@ -30,7 +33,6 @@ public Score GetRandomScore() CountMiss = service.GetRandomInteger(length: 3), Grade = GetRandomBeatmapGrade(), IsScoreable = service.GetRandomBoolean(), - Mods = GetRandomMods(), Accuracy = service.GetRandomInteger(length: 2), Perfect = service.GetRandomBoolean(), GameMode = GetRandomGameMode(), @@ -45,8 +47,40 @@ public Score GetRandomScore() ClientTime = service.GetRandomDateTime(), OsuVersion = service.GetRandomInteger(length: 6).ToString() }; + + score.Mods = GetRandomMods(score.GameMode); + + return score; } + public SubmittedScore GetRandomSubmittedScore(Score score) + { + var submittedScore = new SubmittedScore + { + PlayerUsername = service.GetRandomString(), + Count300 = score.Count300, + Count100 = score.Count100, + Count50 = score.Count50, + CountGeki = score.CountGeki, + CountKatu = score.CountKatu, + CountMiss = score.CountMiss, + Grade = score.Grade, + Accuracy = score.Accuracy, + Perfect = score.Perfect, + GameMode = score.GameMode, + Mods = score.Mods, + IsPassed = score.IsPassed, + BeatmapHash = score.BeatmapHash, + MaxCombo = score.MaxCombo, + ScoreHash = score.ScoreHash, + TotalScore = score.TotalScore, + WhenPlayed = score.WhenPlayed, + ClientTime = score.ClientTime, + OsuVersion = score.OsuVersion + }; + + return submittedScore; + } public PerformanceAttributes GetRandomPerformanceAttributes() { @@ -129,10 +163,18 @@ public string GetRandomBeatmapGrade() return BeatmapGradeChars[new Random().Next(0, BeatmapGradeChars.Length)]; } - public Mods GetRandomMods() + public Mods GetRandomMods(GameMode gameMode) { var random = new Random(); var values = Enum.GetValues(typeof(Mods)); - return (Mods)values.GetValue(random.Next(values.Length))!; + + var mods = (Mods)values.GetValue(random.Next(values.Length))!; + + if (ModsValidationUtil.ValidateMods(mods, gameMode.ToVanillaGameMode()).IsFailure) + { + return GetRandomMods(gameMode); // TODO: Please just make it generate the valid mods combination from the first time. + } + + return mods; } } \ No newline at end of file diff --git a/Sunrise.Tests/Utils/Processing/ScoreCommitContextFactory.cs b/Sunrise.Tests/Utils/Processing/ScoreCommitContextFactory.cs new file mode 100644 index 00000000..18b892bd --- /dev/null +++ b/Sunrise.Tests/Utils/Processing/ScoreCommitContextFactory.cs @@ -0,0 +1,57 @@ +using System.Reflection; +using Sunrise.Processing.Scores.Pipeline; +using Sunrise.Shared.Database.Models; +using Sunrise.Shared.Database.Models.Users; +using Sunrise.Shared.Enums.Scores; +using Sunrise.Shared.Objects; +using Sunrise.Shared.Objects.Serializable; + +namespace Sunrise.Tests.Utils.Processing; + +public static class ScoreCommitContextFactory +{ + public static ScoreCommitContext Create( + ScoreTaskType taskType, + Score score, + User user, + UserStats userStats, + UserGrades userGrades, + Beatmap? beatmap = null, + BeatmapSet? beatmapSet = null, + UserBeatmapPeers? userPersonalBestScores = null, + ScoreStateSnapshot? originalState = null) + { + var context = new ScoreCommitContext(taskType, score, user, userStats, userGrades, beatmap, beatmapSet); + + if (userPersonalBestScores != null) + SetUserPersonalBestScores(context, userPersonalBestScores); + + if (originalState.HasValue) + SetOriginalState(context, originalState.Value); + + return context; + } + + public static void SetOriginalState(ScoreCommitContext context, ScoreStateSnapshot originalState) + { + SetInternalProperty(context, nameof(ScoreCommitContext.OriginalState), originalState); + } + + public static void SetUserPersonalBestScores(ScoreCommitContext context, UserBeatmapPeers? userPersonalBestScores) + { + SetInternalProperty(context, nameof(ScoreCommitContext.UserPersonalBestScores), userPersonalBestScores); + } + + private static void SetInternalProperty(object instance, string propertyName, object? value) + { + var property = instance.GetType().GetProperty(propertyName, BindingFlags.Instance | BindingFlags.Public); + if (property == null) + throw new InvalidOperationException($"Property {propertyName} was not found on {instance.GetType().Name}."); + + var setter = property.GetSetMethod(nonPublic: true); + if (setter == null) + throw new InvalidOperationException($"Property {propertyName} does not have a writable setter."); + + setter.Invoke(instance, [value]); + } +} \ No newline at end of file diff --git a/Sunrise.Tests/Utils/Processing/ScoreSubmissionRequestTestDataFactory.cs b/Sunrise.Tests/Utils/Processing/ScoreSubmissionRequestTestDataFactory.cs new file mode 100644 index 00000000..6b9aa47b --- /dev/null +++ b/Sunrise.Tests/Utils/Processing/ScoreSubmissionRequestTestDataFactory.cs @@ -0,0 +1,33 @@ +using Sunrise.Shared.Database.Models; +using Sunrise.Shared.Database.Models.Scores; +using Sunrise.Shared.Extensions.Scores; + +namespace Sunrise.Tests.Utils.Processing; + +public static class ScoreSubmissionRequestTestDataFactory +{ + public static ScoreSubmissionRequest CreateQueueEntry( + Score score, + string username = "player", + string clientHash = "client-hash", + int? replayFileId = 321, + string? storyboardHash = null) + { + score.ScoreHash = score.ComputeOnlineHash(username, clientHash, storyboardHash); + + return new ScoreSubmissionRequest + { + UserId = score.UserId, + ScoreHash = score.ScoreHash, + ScoreSerialized = score.ToScoreString(username), + BeatmapHash = score.BeatmapHash, + TimeElapsed = score.TimeElapsed, + OsuVersion = score.OsuVersion, + ClientHash = clientHash, + ReplayFileId = replayFileId, + StoryboardHash = storyboardHash, + UserHash = clientHash, + WhenPlayed = score.WhenPlayed + }; + } +} \ No newline at end of file diff --git a/Sunrise.sln b/Sunrise.sln index 44f9ace3..607eda92 100644 --- a/Sunrise.sln +++ b/Sunrise.sln @@ -1,6 +1,6 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# +# Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sunrise.Server", "Sunrise.Server\Sunrise.Server.csproj", "{7D059CAB-5B22-4E12-B69E-CC45838060CE}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sunrise.Server.Tests", "Sunrise.Server.Tests\Sunrise.Server.Tests.csproj", "{64B08BB6-2F73-492F-AFA5-EBEA99E5467D}" @@ -13,6 +13,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sunrise.Tests", "Sunrise.Te EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sunrise.Shared.Tests", "Sunrise.Shared.Tests\Sunrise.Shared.Tests.csproj", "{7A8F3727-3308-4BBB-AA0A-E7841B69418A}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sunrise.Processing", "Sunrise.Processing\Sunrise.Processing.csproj", "{35E8C62C-200B-4732-8469-A1E4D3F34A98}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sunrise.Processing.Tests", "Sunrise.Processing.Tests\Sunrise.Processing.Tests.csproj", "{9AE97ECF-E80F-4726-9ED5-540D005133B7}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -43,5 +47,13 @@ Global {7A8F3727-3308-4BBB-AA0A-E7841B69418A}.Debug|Any CPU.Build.0 = Debug|Any CPU {7A8F3727-3308-4BBB-AA0A-E7841B69418A}.Release|Any CPU.ActiveCfg = Release|Any CPU {7A8F3727-3308-4BBB-AA0A-E7841B69418A}.Release|Any CPU.Build.0 = Release|Any CPU + {35E8C62C-200B-4732-8469-A1E4D3F34A98}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {35E8C62C-200B-4732-8469-A1E4D3F34A98}.Debug|Any CPU.Build.0 = Debug|Any CPU + {35E8C62C-200B-4732-8469-A1E4D3F34A98}.Release|Any CPU.ActiveCfg = Release|Any CPU + {35E8C62C-200B-4732-8469-A1E4D3F34A98}.Release|Any CPU.Build.0 = Release|Any CPU + {9AE97ECF-E80F-4726-9ED5-540D005133B7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9AE97ECF-E80F-4726-9ED5-540D005133B7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9AE97ECF-E80F-4726-9ED5-540D005133B7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9AE97ECF-E80F-4726-9ED5-540D005133B7}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal