Skip to content

Commit

Permalink
Merge pull request #568 from osu-tournament-rating/feature/missing-ru…
Browse files Browse the repository at this point in the history
…leset-col

Fix stats & add missing ruleset column to `rating_adjustments` table
  • Loading branch information
hburn7 authored Dec 27, 2024
2 parents 29f9fb0 + 1e2a52f commit 37a69d4
Show file tree
Hide file tree
Showing 14 changed files with 2,363 additions and 154 deletions.
5 changes: 4 additions & 1 deletion API.Tests/Services/LeaderboardServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
using API.Services.Implementations;
using API.Utilities;
using APITests.MockRepositories;
using AutoMapper;
using Database.Enums;
using MapperProfile = OsuApiClient.Configurations.MapperProfile;

namespace APITests.Services;

Expand Down Expand Up @@ -44,7 +46,8 @@ public LeaderboardServiceTests()
playerRatingsRepository.Object,
matchStatsRepository.Object,
playerRepository.Object,
tournamentsService
tournamentsService,
new Mapper(new MapperConfiguration(configuration => configuration.AddProfile(new MapperProfile())))
);

_leaderboardService = new LeaderboardService(
Expand Down
47 changes: 27 additions & 20 deletions API/DTOs/PlayerRatingChartDataPointDTO.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using Database.Enums;

namespace API.DTOs;

/// <summary>
Expand All @@ -6,62 +8,67 @@ namespace API.DTOs;
public class PlayerRatingChartDataPointDTO
{
/// <summary>
/// Match name
/// Match name, if applicable
/// </summary>
public string Name { get; set; } = string.Empty;
public string? Name { get; init; }

Check warning on line 13 in API/DTOs/PlayerRatingChartDataPointDTO.cs

View workflow job for this annotation

GitHub Actions / Qodana for .NET

Auto-property accessor is never used (non-private accessibility)

Auto-property accessor 'Name.get' is never used

/// <summary>
/// Match id
/// Match id, if applicable
/// </summary>
public int? MatchId { get; set; }
public int? MatchId { get; init; }

Check warning on line 18 in API/DTOs/PlayerRatingChartDataPointDTO.cs

View workflow job for this annotation

GitHub Actions / Qodana for .NET

Auto-property accessor is never used (non-private accessibility)

Auto-property accessor 'MatchId.get' is never used

/// <summary>
/// osu! match id
/// osu! match id, if applicable
/// </summary>
public long? MatchOsuId { get; set; }
public long? MatchOsuId { get; init; }

Check warning on line 23 in API/DTOs/PlayerRatingChartDataPointDTO.cs

View workflow job for this annotation

GitHub Actions / Qodana for .NET

Auto-property accessor is never used (non-private accessibility)

Auto-property accessor 'MatchOsuId.get' is never used

/// <summary>
/// Match cost of the player
/// Match cost of the player, if applicable
/// </summary>
public double? MatchCost { get; set; }
public double? MatchCost { get; init; }

Check warning on line 28 in API/DTOs/PlayerRatingChartDataPointDTO.cs

View workflow job for this annotation

GitHub Actions / Qodana for .NET

Auto-property accessor is never used (non-private accessibility)

Auto-property accessor 'MatchCost.get' is never used

/// <summary>
/// Rating of the player before this match occurred
/// Rating of the player before the adjustment
/// </summary>
public double RatingBefore { get; set; }
public double RatingBefore { get; init; }

/// <summary>
/// Rating of the player after this match occurred
/// Rating of the player after the adjustment
/// </summary>
public double RatingAfter { get; set; }
public double RatingAfter { get; init; }

/// <summary>
/// Volatility of the player before this match occurred
/// </summary>
public double VolatilityBefore { get; set; }
public double VolatilityBefore { get; init; }

/// <summary>
/// Volatility of the player after this match occurred
/// Volatility of the player after this adjustment
/// </summary>
public double VolatilityAfter { get; set; }
public double VolatilityAfter { get; init; }

/// <summary>
/// Difference in rating for the player after this match occurred
/// Difference in rating between now and the previous adjustment
/// </summary>
public double RatingChange => RatingAfter - RatingBefore;

Check notice on line 53 in API/DTOs/PlayerRatingChartDataPointDTO.cs

View workflow job for this annotation

GitHub Actions / Qodana for .NET

Type member is never used (non-private accessibility)

Property 'RatingChange' is never used

/// <summary>
/// Difference in volatility for the player after this match occurred
/// Difference in volatility between now and the previous adjustment
/// </summary>
public double VolatilityChange => VolatilityAfter - VolatilityBefore;

Check notice on line 58 in API/DTOs/PlayerRatingChartDataPointDTO.cs

View workflow job for this annotation

GitHub Actions / Qodana for .NET

Type member is never used (non-private accessibility)

Property 'VolatilityChange' is never used

/// <summary>
/// Indicates whether this data point is from a rating change that occurred outside of a match (i.e. decay)
/// Ruleset of the adjustment
/// </summary>
public Ruleset Ruleset { get; init; }

Check warning on line 63 in API/DTOs/PlayerRatingChartDataPointDTO.cs

View workflow job for this annotation

GitHub Actions / Qodana for .NET

Auto-property accessor is never used (non-private accessibility)

Auto-property accessor 'Ruleset.get' is never used

/// <summary>
/// Adjustment type
/// </summary>
public bool IsAdjustment { get; set; }
public RatingAdjustmentType RatingAdjustmentType { get; init; }

Check warning on line 68 in API/DTOs/PlayerRatingChartDataPointDTO.cs

View workflow job for this annotation

GitHub Actions / Qodana for .NET

Auto-property accessor is never used (non-private accessibility)

Auto-property accessor 'RatingAdjustmentType.get' is never used

/// <summary>
/// Match start time
/// </summary>
public DateTime? Timestamp { get; set; }
public DateTime? Timestamp { get; init; }

Check warning on line 73 in API/DTOs/PlayerRatingChartDataPointDTO.cs

View workflow job for this annotation

GitHub Actions / Qodana for .NET

Auto-property accessor is never used (non-private accessibility)

Auto-property accessor 'Timestamp.get' is never used
}
117 changes: 48 additions & 69 deletions API/Repositories/Implementations/ApiMatchRatingStatsRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,85 +3,64 @@
using Database;
using Database.Enums;
using Database.Repositories.Implementations;
using Microsoft.EntityFrameworkCore;

namespace API.Repositories.Implementations;

public class ApiMatchRatingStatsRepository(OtrContext context) : MatchRatingStatsRepository(context), IApiMatchRatingStatsRepository
public class ApiMatchRatingStatsRepository(OtrContext context)

Check notice on line 10 in API/Repositories/Implementations/ApiMatchRatingStatsRepository.cs

View workflow job for this annotation

GitHub Actions / Qodana for .NET

Class is never instantiated (non-private accessibility)

Class 'ApiMatchRatingStatsRepository' is never instantiated
: MatchRatingStatsRepository(context), IApiMatchRatingStatsRepository
{
public Task<PlayerRatingChartDTO> GetRatingChartAsync(
private readonly OtrContext _context = context;

public async Task<PlayerRatingChartDTO> GetRatingChartAsync(
int playerId,
Ruleset ruleset,
DateTime? dateMin = null,
DateTime? dateMax = null
)
{
// TODO: Rewrite this
// // Default date range to min and max values if not provided
// dateMin ??= DateTime.MinValue;
// dateMax ??= DateTime.MaxValue;
//
// // Fetch Match Rating Stats and group by Match.StartTime.Date
// var matchRatingStats = await _context
// .MatchRatingStats.Where(mrs =>
// mrs.PlayerId == playerId
// && mrs.Match.Tournament.Ruleset == (Ruleset)ruleset
// && mrs.Match.StartTime >= dateMin
// && mrs.Match.StartTime <= dateMax
// )
// .Select(mrs => new
// {
// mrs.Match.StartTime,
// DataPoint = new PlayerRatingChartDataPointDTO
// {
// Name = mrs.Match.Name ?? "<Unknown>",
// MatchId = mrs.MatchId,
// MatchOsuId = mrs.Match.OsuId,
// // MatchCost = mrs.MatchCost,
// RatingBefore = mrs.RatingBefore,
// RatingAfter = mrs.RatingAfter,
// VolatilityBefore = mrs.VolatilityBefore,
// VolatilityAfter = mrs.VolatilityAfter,
// IsAdjustment = false,
// Timestamp = mrs.Match.StartTime
// }
// })
// .ToListAsync();
//
// // Assuming RatingAdjustments should be grouped by their own timestamp since they may not have a Match.StartTime
// var ratingAdjustments = await _context
// .RatingAdjustments
// .Where(ra =>
// ra.PlayerId == playerId
// && ra.Ruleset == (Ruleset)ruleset
// && ra.Timestamp >= dateMin
// && ra.Timestamp <= dateMax
// )
// .Select(ra => new
// {
// ra.Timestamp, // Use Timestamp for grouping as fallback
// DataPoint = new PlayerRatingChartDataPointDTO
// {
// Name = ra.RatingAdjustmentType == 0 ? "Decay" : "Adjustment",
// RatingBefore = ra.RatingBefore,
// RatingAfter = ra.RatingAfter,
// VolatilityBefore = ra.VolatilityBefore,
// VolatilityAfter = ra.VolatilityAfter,
// IsAdjustment = true,
// Timestamp = ra.Timestamp
// }
// })
// .ToListAsync();
//
// // Combine data points, converting Match.StartTime and RatingAdjustment.Timestamp to Date for grouping
// var combinedDataPoints = matchRatingStats
// .Select(mrs => new { mrs.StartTime.Date, mrs.DataPoint })
// .Concat(ratingAdjustments.Select(ra => new { ra.Timestamp.Date, ra.DataPoint }))
// .GroupBy(x => x.Date)
// .OrderBy(g => g.Key)
// .Select(g => g.Select(x => x.DataPoint).ToList())
// .ToList();
// Default date range to min and max values if not provided
dateMin ??= DateTime.MinValue;
dateMax ??= DateTime.MaxValue;

// Fetch Match Rating Stats and group by Match.StartTime.Date
List<IGrouping<DateTime?, PlayerRatingChartDataPointDTO>> chartPoints = await _context
.RatingAdjustments
.Include(ra => ra.Match)
.Where(ra =>
ra.PlayerId == playerId
&& ra.Ruleset == ruleset
&& ra.Timestamp >= dateMin
&& ra.Timestamp <= dateMax
)
.Select(ra =>
new PlayerRatingChartDataPointDTO
{
Name = ra.Match == null
? null
: ra.Match.Name,
MatchId = ra.MatchId,
MatchOsuId = ra.Match == null
? null
: ra.Match.OsuId,
MatchCost = _context.PlayerMatchStats
.Where(pms => pms.PlayerId == ra.PlayerId &&
pms.MatchId == ra.MatchId)
.Select(pms => pms.MatchCost)
.FirstOrDefault(),
RatingBefore = ra.RatingBefore,
RatingAfter = ra.RatingAfter,
VolatilityBefore = ra.VolatilityBefore,
VolatilityAfter = ra.VolatilityAfter,
Ruleset = ra.Ruleset,
RatingAdjustmentType = ra.AdjustmentType,
Timestamp = ra.Timestamp
})
.OrderBy(ra => ra.Timestamp)
.GroupBy(ra => ra.Timestamp)
.ToListAsync();

// Prepare and return the DTO
return Task.FromResult(new PlayerRatingChartDTO { ChartData = new List<List<PlayerRatingChartDataPointDTO>>() });
// Combine data points, converting Match.StartTime and RatingAdjustment.Timestamp to Date for grouping
return new PlayerRatingChartDTO { ChartData = chartPoints };
}
}
7 changes: 5 additions & 2 deletions API/Services/Implementations/PlayerRatingsService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using API.Repositories.Interfaces;
using API.Services.Interfaces;
using API.Utilities;
using AutoMapper;
using Database.Entities.Processor;
using Database.Enums;
using Database.Repositories.Interfaces;
Expand All @@ -13,7 +14,8 @@ public class PlayerRatingsService(
IApiPlayerRatingsRepository playerRatingsRepository,
IPlayerMatchStatsRepository matchStatsRepository,
IPlayersRepository playerRepository,
ITournamentsService tournamentsService
ITournamentsService tournamentsService,
IMapper mapper
) : IPlayerRatingsService
{
public async Task<IEnumerable<PlayerRatingStatsDTO?>> GetAsync(long osuPlayerId)
Expand Down Expand Up @@ -72,7 +74,8 @@ ITournamentsService tournamentsService
Volatility = currentStats.Volatility,
WinRate = winRate,
TournamentsPlayed = tournamentsPlayed,
RankProgress = rankProgress
RankProgress = rankProgress,
Adjustments = mapper.Map<ICollection<RatingAdjustmentDTO>>(currentStats.Adjustments.OrderBy(a => a.Timestamp))
};
}

Expand Down
42 changes: 25 additions & 17 deletions API/Services/Implementations/PlayerStatsService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -173,8 +173,7 @@ public async Task<double> GetPeakRatingAsync(int playerId, Ruleset ruleset, Date
DateTime? dateMax = null)
{
return (await ratingStatsRepository.GetForPlayerAsync(playerId, ruleset, dateMin, dateMax))
.SelectMany(x => x)
.Max(x => x.RatingAfter);
.Max(ra => ra.RatingAfter);
}

private async Task<PlayerRatingStatsDTO?> GetCurrentAsync(int playerId, Ruleset ruleset)
Expand Down Expand Up @@ -260,35 +259,44 @@ DateTime dateMax
var matchStats = (await matchStatsRepository.GetForPlayerAsync(id, ruleset, dateMin, dateMax)).ToList();
var adjustments =
(await ratingStatsRepository.GetForPlayerAsync(id, ruleset, dateMin, dateMax))
.ToList()
.SelectMany(x => x)
.GroupBy(ra => ra.Timestamp)
.SelectMany(ra => ra)
.ToList();

if (matchStats.Count == 0)
if (matchStats.Count == 0 || adjustments.Count == 0)
{
return new AggregatePlayerMatchStatsDTO();
}

/**
* If the adjustments list contains an initial rating, we need to subtract the
* rating gained value by the initial rating. Essentially we are displaying
* the net gain in rating without considering the initial rating.
*/
var initialRatingValue = adjustments
.FirstOrDefault(ra => ra.AdjustmentType == RatingAdjustmentType.Initial)?.RatingAfter
?? 0;

return new AggregatePlayerMatchStatsDTO
{
// TODO: Different way of calcing this
// AverageMatchCostAggregate = ratingStats.Average(x => x.MatchCost),
HighestRating = adjustments.Max(x => x.RatingAfter),
RatingGained = adjustments.Last().RatingAfter - adjustments.First().RatingAfter,
GamesWon = matchStats.Sum(x => x.GamesWon),
GamesLost = matchStats.Sum(x => x.GamesLost),
GamesPlayed = matchStats.Sum(x => x.GamesPlayed),
MatchesWon = matchStats.Count(x => x.Won),
MatchesLost = matchStats.Count(x => !x.Won),
HighestRating = adjustments.Max(ra => ra.RatingAfter),
RatingGained = adjustments.Sum(ra => ra.RatingDelta) - initialRatingValue,
GamesWon = matchStats.Sum(ra => ra.GamesWon),
GamesLost = matchStats.Sum(ra => ra.GamesLost),
GamesPlayed = matchStats.Sum(ra => ra.GamesPlayed),
MatchesWon = matchStats.Count(ra => ra.Won),
MatchesLost = matchStats.Count(ra => !ra.Won),
// TODO: Different way of calcing this
// AverageTeammateRating = ratingStats.Average(x => x.AverageTeammateRating),
// AverageOpponentRating = ratingStats.Average(x => x.AverageOpponentRating),
BestWinStreak = GetHighestWinStreak(matchStats),
MatchAverageScoreAggregate = matchStats.Average(x => x.AverageScore),
MatchAverageAccuracyAggregate = matchStats.Average(x => x.AverageAccuracy),
MatchAverageMissesAggregate = matchStats.Average(x => x.AverageMisses),
AverageGamesPlayedAggregate = matchStats.Average(x => x.GamesPlayed),
AveragePlacingAggregate = matchStats.Average(x => x.AveragePlacement),
MatchAverageScoreAggregate = matchStats.Average(pms => pms.AverageScore),
MatchAverageAccuracyAggregate = matchStats.Average(pms => pms.AverageAccuracy),
MatchAverageMissesAggregate = matchStats.Average(pms => pms.AverageMisses),
AverageGamesPlayedAggregate = matchStats.Average(pms => pms.GamesPlayed),
AveragePlacingAggregate = matchStats.Average(pms => pms.AveragePlacement),
PeriodStart = dateMin,
PeriodEnd = dateMax
};
Expand Down
2 changes: 1 addition & 1 deletion Database/Entities/Processor/PlayerRating.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,5 +72,5 @@ public class PlayerRating : EntityBase
/// A collection of <see cref="RatingAdjustment"/>s that represent
/// the individual changes to the <see cref="PlayerRating"/> over time
/// </summary>
public ICollection<RatingAdjustment> Adjustments { get; init; } = [];
public ICollection<RatingAdjustment> Adjustments { get; set; } = [];

Check notice on line 75 in Database/Entities/Processor/PlayerRating.cs

View workflow job for this annotation

GitHub Actions / Qodana for .NET

Property can be made init-only (non-private accessibility)

Property can be made init-only
}
8 changes: 7 additions & 1 deletion Database/Entities/Processor/RatingAdjustment.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,17 @@ namespace Database.Entities.Processor;
public class RatingAdjustment : EntityBase
{
/// <summary>
/// The <see cref="RatingAdjustmentType"/> of the adjustment
/// The <see cref="RatingAdjustmentType"/>
/// </summary>
[Column("adjustment_type")]
public RatingAdjustmentType AdjustmentType { get; init; }

/// <summary>
/// The <see cref="Ruleset" />
/// </summary>
[Column("ruleset")]
public Ruleset Ruleset { get; set; }

Check notice on line 34 in Database/Entities/Processor/RatingAdjustment.cs

View workflow job for this annotation

GitHub Actions / Qodana for .NET

Property can be made init-only (non-private accessibility)

Property can be made init-only

/// <summary>
/// Timestamp for when the adjustment was applied
/// </summary>
Expand Down
Loading

0 comments on commit 37a69d4

Please sign in to comment.