Files
chesscubing/ChessCubing.Server/Stats/MySqlPlayerStatsStore.cs

993 lines
38 KiB
C#

using ChessCubing.Server.Data;
using ChessCubing.Server.Users;
using Microsoft.Extensions.Options;
using MySqlConnector;
namespace ChessCubing.Server.Stats;
public sealed class MySqlPlayerStatsStore(
IOptions<SiteDataOptions> options,
ILogger<MySqlPlayerStatsStore> logger)
{
private const string MatchResultWhite = "white";
private const string MatchResultBlack = "black";
private const string MatchResultStopped = "stopped";
private const int DefaultElo = 1200;
private const int EloKFactor = 32;
private const int RecentMatchLimit = 12;
private const string CreatePlayerStatsTableSql = """
CREATE TABLE IF NOT EXISTS site_player_stats (
subject VARCHAR(190) NOT NULL,
current_elo INT NOT NULL,
ranked_games INT NOT NULL,
casual_games INT NOT NULL,
wins INT NOT NULL,
losses INT NOT NULL,
stopped_games INT NOT NULL,
white_wins INT NOT NULL,
black_wins INT NOT NULL,
white_losses INT NOT NULL,
black_losses INT NOT NULL,
total_moves INT NOT NULL,
total_cube_rounds INT NOT NULL,
total_cube_entries INT NOT NULL,
total_cube_time_ms BIGINT NOT NULL,
best_cube_time_ms BIGINT NULL,
last_match_utc DATETIME(6) NULL,
updated_utc DATETIME(6) NOT NULL,
PRIMARY KEY (subject)
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
""";
private const string CreateMatchResultsTableSql = """
CREATE TABLE IF NOT EXISTS site_match_results (
id BIGINT NOT NULL AUTO_INCREMENT,
match_id VARCHAR(80) NOT NULL,
collaboration_session_id VARCHAR(80) NULL,
recorded_by_subject VARCHAR(190) NOT NULL,
white_subject VARCHAR(190) NULL,
white_name VARCHAR(120) NOT NULL,
black_subject VARCHAR(190) NULL,
black_name VARCHAR(120) NOT NULL,
winner_subject VARCHAR(190) NULL,
result VARCHAR(20) NOT NULL,
mode VARCHAR(40) NOT NULL,
preset VARCHAR(40) NOT NULL,
match_label VARCHAR(120) NULL,
block_number INT NOT NULL,
white_moves INT NOT NULL,
black_moves INT NOT NULL,
cube_rounds INT NOT NULL,
white_best_cube_ms BIGINT NULL,
black_best_cube_ms BIGINT NULL,
white_average_cube_ms BIGINT NULL,
black_average_cube_ms BIGINT NULL,
is_ranked TINYINT(1) NOT NULL,
white_elo_before INT NULL,
white_elo_after INT NULL,
black_elo_before INT NULL,
black_elo_after INT NULL,
completed_utc DATETIME(6) NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uq_site_match_results_match_id (match_id),
KEY idx_site_match_results_white_subject (white_subject, completed_utc),
KEY idx_site_match_results_black_subject (black_subject, completed_utc)
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
""";
private const string EnsurePlayerStatsRowSql = """
INSERT INTO site_player_stats (
subject,
current_elo,
ranked_games,
casual_games,
wins,
losses,
stopped_games,
white_wins,
black_wins,
white_losses,
black_losses,
total_moves,
total_cube_rounds,
total_cube_entries,
total_cube_time_ms,
best_cube_time_ms,
last_match_utc,
updated_utc
)
VALUES (
@subject,
@currentElo,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
NULL,
NULL,
@updatedUtc
)
ON DUPLICATE KEY UPDATE
subject = VALUES(subject);
""";
private const string SelectPlayerStatsForUpdateSql = """
SELECT
subject,
current_elo,
ranked_games,
casual_games,
wins,
losses,
stopped_games,
white_wins,
black_wins,
white_losses,
black_losses,
total_moves,
total_cube_rounds,
total_cube_entries,
total_cube_time_ms,
best_cube_time_ms,
last_match_utc,
updated_utc
FROM site_player_stats
WHERE subject = @subject
LIMIT 1
FOR UPDATE;
""";
private const string SelectPlayerStatsSql = """
SELECT
subject,
current_elo,
ranked_games,
casual_games,
wins,
losses,
stopped_games,
white_wins,
black_wins,
white_losses,
black_losses,
total_moves,
total_cube_rounds,
total_cube_entries,
total_cube_time_ms,
best_cube_time_ms,
last_match_utc,
updated_utc
FROM site_player_stats
WHERE subject = @subject
LIMIT 1;
""";
private const string UpdatePlayerStatsSql = """
UPDATE site_player_stats
SET
current_elo = @currentElo,
ranked_games = @rankedGames,
casual_games = @casualGames,
wins = @wins,
losses = @losses,
stopped_games = @stoppedGames,
white_wins = @whiteWins,
black_wins = @blackWins,
white_losses = @whiteLosses,
black_losses = @blackLosses,
total_moves = @totalMoves,
total_cube_rounds = @totalCubeRounds,
total_cube_entries = @totalCubeEntries,
total_cube_time_ms = @totalCubeTimeMs,
best_cube_time_ms = @bestCubeTimeMs,
last_match_utc = @lastMatchUtc,
updated_utc = @updatedUtc
WHERE subject = @subject;
""";
private const string InsertMatchResultSql = """
INSERT INTO site_match_results (
match_id,
collaboration_session_id,
recorded_by_subject,
white_subject,
white_name,
black_subject,
black_name,
winner_subject,
result,
mode,
preset,
match_label,
block_number,
white_moves,
black_moves,
cube_rounds,
white_best_cube_ms,
black_best_cube_ms,
white_average_cube_ms,
black_average_cube_ms,
is_ranked,
white_elo_before,
white_elo_after,
black_elo_before,
black_elo_after,
completed_utc
)
VALUES (
@matchId,
@collaborationSessionId,
@recordedBySubject,
@whiteSubject,
@whiteName,
@blackSubject,
@blackName,
@winnerSubject,
@result,
@mode,
@preset,
@matchLabel,
@blockNumber,
@whiteMoves,
@blackMoves,
@cubeRounds,
@whiteBestCubeMs,
@blackBestCubeMs,
@whiteAverageCubeMs,
@blackAverageCubeMs,
@isRanked,
NULL,
NULL,
NULL,
NULL,
@completedUtc
);
""";
private const string UpdateMatchResultEloSql = """
UPDATE site_match_results
SET
white_elo_before = @whiteEloBefore,
white_elo_after = @whiteEloAfter,
black_elo_before = @blackEloBefore,
black_elo_after = @blackEloAfter
WHERE match_id = @matchId;
""";
private const string SelectRecentMatchesSql = """
SELECT
match_id,
white_subject,
white_name,
black_subject,
black_name,
result,
mode,
preset,
match_label,
white_moves,
black_moves,
cube_rounds,
white_best_cube_ms,
black_best_cube_ms,
white_average_cube_ms,
black_average_cube_ms,
is_ranked,
white_elo_before,
white_elo_after,
black_elo_before,
black_elo_after,
completed_utc
FROM site_match_results
WHERE white_subject = @subject OR black_subject = @subject
ORDER BY completed_utc DESC, id DESC
LIMIT @limit;
""";
private readonly SiteDataOptions _options = options.Value;
private readonly ILogger<MySqlPlayerStatsStore> _logger = logger;
public async Task InitializeAsync(CancellationToken cancellationToken)
{
for (var attempt = 1; attempt <= _options.InitializationRetries; attempt++)
{
try
{
await using var connection = new MySqlConnection(_options.BuildConnectionString());
await connection.OpenAsync(cancellationToken);
await CreateSchemaAsync(connection, cancellationToken);
return;
}
catch (Exception exception) when (attempt < _options.InitializationRetries)
{
_logger.LogWarning(
exception,
"Initialisation MySQL impossible pour les statistiques joueurs (tentative {Attempt}/{MaxAttempts}).",
attempt,
_options.InitializationRetries);
await Task.Delay(TimeSpan.FromSeconds(_options.InitializationDelaySeconds), cancellationToken);
}
}
await using var finalConnection = new MySqlConnection(_options.BuildConnectionString());
await finalConnection.OpenAsync(cancellationToken);
await CreateSchemaAsync(finalConnection, cancellationToken);
}
public async Task<ReportCompletedMatchResponse> RecordCompletedMatchAsync(
AuthenticatedSiteUser reporter,
ReportCompletedMatchRequest request,
CancellationToken cancellationToken)
{
var normalized = NormalizeRequest(reporter, request);
await using var connection = new MySqlConnection(_options.BuildConnectionString());
await connection.OpenAsync(cancellationToken);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
try
{
if (!await TryInsertMatchReservationAsync(connection, transaction, normalized, cancellationToken))
{
await transaction.RollbackAsync(cancellationToken);
return new ReportCompletedMatchResponse
{
Recorded = true,
IsDuplicate = true,
IsRanked = normalized.IsRanked,
};
}
PlayerStatsRow? whiteStats = null;
PlayerStatsRow? blackStats = null;
EloSnapshot? elo = null;
if (normalized.WhiteSubject is not null)
{
await EnsurePlayerStatsRowAsync(connection, transaction, normalized.WhiteSubject, normalized.CompletedUtc, cancellationToken);
whiteStats = await ReadPlayerStatsForUpdateAsync(connection, transaction, normalized.WhiteSubject, cancellationToken);
}
if (normalized.BlackSubject is not null)
{
await EnsurePlayerStatsRowAsync(connection, transaction, normalized.BlackSubject, normalized.CompletedUtc, cancellationToken);
blackStats = await ReadPlayerStatsForUpdateAsync(connection, transaction, normalized.BlackSubject, cancellationToken);
}
if (normalized.IsRanked && whiteStats is not null && blackStats is not null)
{
elo = ComputeElo(whiteStats.CurrentElo, blackStats.CurrentElo, normalized.Result);
whiteStats = whiteStats with { CurrentElo = elo.WhiteAfter };
blackStats = blackStats with { CurrentElo = elo.BlackAfter };
}
if (whiteStats is not null)
{
whiteStats = ApplyMatchToStats(
whiteStats,
normalized,
MatchResultWhite,
normalized.WhiteMoves,
normalized.WhiteCubeTimes,
normalized.IsRanked,
elo?.WhiteAfter);
await UpdatePlayerStatsAsync(connection, transaction, whiteStats, cancellationToken);
}
if (blackStats is not null)
{
blackStats = ApplyMatchToStats(
blackStats,
normalized,
MatchResultBlack,
normalized.BlackMoves,
normalized.BlackCubeTimes,
normalized.IsRanked,
elo?.BlackAfter);
await UpdatePlayerStatsAsync(connection, transaction, blackStats, cancellationToken);
}
await UpdateMatchEloAsync(connection, transaction, normalized.MatchId, elo, cancellationToken);
await transaction.CommitAsync(cancellationToken);
return new ReportCompletedMatchResponse
{
Recorded = true,
IsDuplicate = false,
IsRanked = normalized.IsRanked,
WhiteEloAfter = elo?.WhiteAfter,
BlackEloAfter = elo?.BlackAfter,
};
}
catch
{
await transaction.RollbackAsync(cancellationToken);
throw;
}
}
public async Task<UserStatsResponse> GetUserStatsAsync(string subject, CancellationToken cancellationToken)
{
var normalizedSubject = NormalizeRequiredValue(subject, "subject", 190);
await using var connection = new MySqlConnection(_options.BuildConnectionString());
await connection.OpenAsync(cancellationToken);
var stats = await ReadPlayerStatsAsync(connection, normalizedSubject, cancellationToken);
var recentMatches = await ReadRecentMatchesAsync(connection, normalizedSubject, cancellationToken);
if (stats is null)
{
return new UserStatsResponse
{
Subject = normalizedSubject,
CurrentElo = DefaultElo,
RecentMatches = recentMatches,
};
}
return new UserStatsResponse
{
Subject = stats.Subject,
CurrentElo = stats.CurrentElo,
RankedGames = stats.RankedGames,
CasualGames = stats.CasualGames,
Wins = stats.Wins,
Losses = stats.Losses,
StoppedGames = stats.StoppedGames,
WhiteWins = stats.WhiteWins,
BlackWins = stats.BlackWins,
WhiteLosses = stats.WhiteLosses,
BlackLosses = stats.BlackLosses,
TotalMoves = stats.TotalMoves,
TotalCubeRounds = stats.TotalCubeRounds,
BestCubeTimeMs = stats.BestCubeTimeMs,
AverageCubeTimeMs = stats.TotalCubeEntries <= 0
? null
: (long?)Math.Round((double)stats.TotalCubeTimeMs / stats.TotalCubeEntries, MidpointRounding.AwayFromZero),
LastMatchUtc = stats.LastMatchUtc,
RecentMatches = recentMatches,
};
}
private static async Task CreateSchemaAsync(MySqlConnection connection, CancellationToken cancellationToken)
{
await using (var command = connection.CreateCommand())
{
command.CommandText = CreatePlayerStatsTableSql;
await command.ExecuteNonQueryAsync(cancellationToken);
}
await using (var command = connection.CreateCommand())
{
command.CommandText = CreateMatchResultsTableSql;
await command.ExecuteNonQueryAsync(cancellationToken);
}
}
private static async Task EnsurePlayerStatsRowAsync(
MySqlConnection connection,
MySqlTransaction transaction,
string subject,
DateTime nowUtc,
CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = EnsurePlayerStatsRowSql;
command.Parameters.AddWithValue("@subject", subject);
command.Parameters.AddWithValue("@currentElo", DefaultElo);
command.Parameters.AddWithValue("@updatedUtc", nowUtc);
await command.ExecuteNonQueryAsync(cancellationToken);
}
private static async Task<bool> TryInsertMatchReservationAsync(
MySqlConnection connection,
MySqlTransaction transaction,
NormalizedCompletedMatch normalized,
CancellationToken cancellationToken)
{
try
{
await using var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = InsertMatchResultSql;
command.Parameters.AddWithValue("@matchId", normalized.MatchId);
command.Parameters.AddWithValue("@collaborationSessionId", (object?)normalized.CollaborationSessionId ?? DBNull.Value);
command.Parameters.AddWithValue("@recordedBySubject", normalized.RecordedBySubject);
command.Parameters.AddWithValue("@whiteSubject", (object?)normalized.WhiteSubject ?? DBNull.Value);
command.Parameters.AddWithValue("@whiteName", normalized.WhiteName);
command.Parameters.AddWithValue("@blackSubject", (object?)normalized.BlackSubject ?? DBNull.Value);
command.Parameters.AddWithValue("@blackName", normalized.BlackName);
command.Parameters.AddWithValue("@winnerSubject", (object?)normalized.WinnerSubject ?? DBNull.Value);
command.Parameters.AddWithValue("@result", normalized.Result);
command.Parameters.AddWithValue("@mode", normalized.Mode);
command.Parameters.AddWithValue("@preset", normalized.Preset);
command.Parameters.AddWithValue("@matchLabel", (object?)normalized.MatchLabel ?? DBNull.Value);
command.Parameters.AddWithValue("@blockNumber", normalized.BlockNumber);
command.Parameters.AddWithValue("@whiteMoves", normalized.WhiteMoves);
command.Parameters.AddWithValue("@blackMoves", normalized.BlackMoves);
command.Parameters.AddWithValue("@cubeRounds", normalized.CubeRounds.Length);
command.Parameters.AddWithValue("@whiteBestCubeMs", (object?)normalized.WhiteCubeTimes.BestMs ?? DBNull.Value);
command.Parameters.AddWithValue("@blackBestCubeMs", (object?)normalized.BlackCubeTimes.BestMs ?? DBNull.Value);
command.Parameters.AddWithValue("@whiteAverageCubeMs", (object?)normalized.WhiteCubeTimes.AverageMs ?? DBNull.Value);
command.Parameters.AddWithValue("@blackAverageCubeMs", (object?)normalized.BlackCubeTimes.AverageMs ?? DBNull.Value);
command.Parameters.AddWithValue("@isRanked", normalized.IsRanked);
command.Parameters.AddWithValue("@completedUtc", normalized.CompletedUtc);
await command.ExecuteNonQueryAsync(cancellationToken);
return true;
}
catch (MySqlException exception) when (exception.Number == 1062)
{
return false;
}
}
private static async Task<PlayerStatsRow?> ReadPlayerStatsAsync(
MySqlConnection connection,
string subject,
CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
command.CommandText = SelectPlayerStatsSql;
command.Parameters.AddWithValue("@subject", subject);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
return await reader.ReadAsync(cancellationToken)
? MapPlayerStats(reader)
: null;
}
private static async Task<PlayerStatsRow> ReadPlayerStatsForUpdateAsync(
MySqlConnection connection,
MySqlTransaction transaction,
string subject,
CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = SelectPlayerStatsForUpdateSql;
command.Parameters.AddWithValue("@subject", subject);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
if (!await reader.ReadAsync(cancellationToken))
{
throw new InvalidOperationException("La ligne de statistiques joueur est introuvable.");
}
return MapPlayerStats(reader);
}
private static async Task UpdatePlayerStatsAsync(
MySqlConnection connection,
MySqlTransaction transaction,
PlayerStatsRow stats,
CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = UpdatePlayerStatsSql;
command.Parameters.AddWithValue("@subject", stats.Subject);
command.Parameters.AddWithValue("@currentElo", stats.CurrentElo);
command.Parameters.AddWithValue("@rankedGames", stats.RankedGames);
command.Parameters.AddWithValue("@casualGames", stats.CasualGames);
command.Parameters.AddWithValue("@wins", stats.Wins);
command.Parameters.AddWithValue("@losses", stats.Losses);
command.Parameters.AddWithValue("@stoppedGames", stats.StoppedGames);
command.Parameters.AddWithValue("@whiteWins", stats.WhiteWins);
command.Parameters.AddWithValue("@blackWins", stats.BlackWins);
command.Parameters.AddWithValue("@whiteLosses", stats.WhiteLosses);
command.Parameters.AddWithValue("@blackLosses", stats.BlackLosses);
command.Parameters.AddWithValue("@totalMoves", stats.TotalMoves);
command.Parameters.AddWithValue("@totalCubeRounds", stats.TotalCubeRounds);
command.Parameters.AddWithValue("@totalCubeEntries", stats.TotalCubeEntries);
command.Parameters.AddWithValue("@totalCubeTimeMs", stats.TotalCubeTimeMs);
command.Parameters.AddWithValue("@bestCubeTimeMs", (object?)stats.BestCubeTimeMs ?? DBNull.Value);
command.Parameters.AddWithValue("@lastMatchUtc", (object?)stats.LastMatchUtc ?? DBNull.Value);
command.Parameters.AddWithValue("@updatedUtc", stats.UpdatedUtc);
await command.ExecuteNonQueryAsync(cancellationToken);
}
private static async Task UpdateMatchEloAsync(
MySqlConnection connection,
MySqlTransaction transaction,
string matchId,
EloSnapshot? elo,
CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = UpdateMatchResultEloSql;
command.Parameters.AddWithValue("@matchId", matchId);
command.Parameters.AddWithValue("@whiteEloBefore", (object?)elo?.WhiteBefore ?? DBNull.Value);
command.Parameters.AddWithValue("@whiteEloAfter", (object?)elo?.WhiteAfter ?? DBNull.Value);
command.Parameters.AddWithValue("@blackEloBefore", (object?)elo?.BlackBefore ?? DBNull.Value);
command.Parameters.AddWithValue("@blackEloAfter", (object?)elo?.BlackAfter ?? DBNull.Value);
await command.ExecuteNonQueryAsync(cancellationToken);
}
private static async Task<UserRecentMatchResponse[]> ReadRecentMatchesAsync(
MySqlConnection connection,
string subject,
CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
command.CommandText = SelectRecentMatchesSql;
command.Parameters.AddWithValue("@subject", subject);
command.Parameters.AddWithValue("@limit", RecentMatchLimit);
var matches = new List<UserRecentMatchResponse>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
matches.Add(MapRecentMatch(reader, subject));
}
return matches.ToArray();
}
private static PlayerStatsRow ApplyMatchToStats(
PlayerStatsRow current,
NormalizedCompletedMatch normalized,
string playerColor,
int playerMoves,
CubeTimeSummary cubeTimes,
bool isRanked,
int? eloAfter)
{
var isWhite = playerColor == MatchResultWhite;
var isWin = normalized.Result == playerColor;
var isLoss = normalized.Result is MatchResultWhite or MatchResultBlack && normalized.Result != playerColor;
var isStopped = normalized.Result == MatchResultStopped;
return current with
{
RankedGames = current.RankedGames + (isRanked ? 1 : 0),
CasualGames = current.CasualGames + (isRanked ? 0 : 1),
Wins = current.Wins + (isWin ? 1 : 0),
Losses = current.Losses + (isLoss ? 1 : 0),
StoppedGames = current.StoppedGames + (isStopped ? 1 : 0),
WhiteWins = current.WhiteWins + (isWhite && isWin ? 1 : 0),
BlackWins = current.BlackWins + (!isWhite && isWin ? 1 : 0),
WhiteLosses = current.WhiteLosses + (isWhite && isLoss ? 1 : 0),
BlackLosses = current.BlackLosses + (!isWhite && isLoss ? 1 : 0),
TotalMoves = current.TotalMoves + playerMoves,
TotalCubeRounds = current.TotalCubeRounds + normalized.CubeRounds.Length,
TotalCubeEntries = current.TotalCubeEntries + cubeTimes.Count,
TotalCubeTimeMs = current.TotalCubeTimeMs + cubeTimes.TotalMs,
BestCubeTimeMs = MinNullable(current.BestCubeTimeMs, cubeTimes.BestMs),
LastMatchUtc = normalized.CompletedUtc,
UpdatedUtc = normalized.CompletedUtc,
CurrentElo = eloAfter ?? current.CurrentElo,
};
}
private static EloSnapshot ComputeElo(int whiteRating, int blackRating, string result)
{
var whiteScore = result == MatchResultWhite ? 1d : 0d;
var expectedWhite = 1d / (1d + Math.Pow(10d, (blackRating - whiteRating) / 400d));
var whiteDelta = (int)Math.Round(EloKFactor * (whiteScore - expectedWhite), MidpointRounding.AwayFromZero);
return new EloSnapshot(
whiteRating,
whiteRating + whiteDelta,
blackRating,
blackRating - whiteDelta);
}
private static UserRecentMatchResponse MapRecentMatch(MySqlDataReader reader, string subject)
{
var whiteSubject = ReadNullableString(reader, "white_subject");
var blackSubject = ReadNullableString(reader, "black_subject");
var playerColor = string.Equals(whiteSubject, subject, StringComparison.Ordinal) ? MatchResultWhite : MatchResultBlack;
var isWhite = playerColor == MatchResultWhite;
var result = ReadString(reader, "result");
var isWin = result == playerColor;
var isLoss = result is MatchResultWhite or MatchResultBlack && result != playerColor;
var eloBefore = isWhite ? ReadNullableInt(reader, "white_elo_before") : ReadNullableInt(reader, "black_elo_before");
var eloAfter = isWhite ? ReadNullableInt(reader, "white_elo_after") : ReadNullableInt(reader, "black_elo_after");
return new UserRecentMatchResponse
{
MatchId = ReadString(reader, "match_id"),
CompletedUtc = ReadDateTime(reader, "completed_utc"),
Result = result,
Mode = ReadString(reader, "mode"),
Preset = ReadString(reader, "preset"),
MatchLabel = ReadNullableString(reader, "match_label"),
PlayerColor = playerColor,
PlayerName = isWhite ? ReadString(reader, "white_name") : ReadString(reader, "black_name"),
OpponentName = isWhite ? ReadString(reader, "black_name") : ReadString(reader, "white_name"),
OpponentSubject = isWhite ? blackSubject : whiteSubject,
IsRanked = ReadBoolean(reader, "is_ranked"),
IsWin = isWin,
IsLoss = isLoss,
PlayerMoves = isWhite ? ReadInt(reader, "white_moves") : ReadInt(reader, "black_moves"),
OpponentMoves = isWhite ? ReadInt(reader, "black_moves") : ReadInt(reader, "white_moves"),
CubeRounds = ReadInt(reader, "cube_rounds"),
PlayerBestCubeTimeMs = isWhite ? ReadNullableLong(reader, "white_best_cube_ms") : ReadNullableLong(reader, "black_best_cube_ms"),
PlayerAverageCubeTimeMs = isWhite ? ReadNullableLong(reader, "white_average_cube_ms") : ReadNullableLong(reader, "black_average_cube_ms"),
EloBefore = eloBefore,
EloAfter = eloAfter,
EloDelta = eloBefore is not null && eloAfter is not null ? eloAfter.Value - eloBefore.Value : null,
};
}
private static PlayerStatsRow MapPlayerStats(MySqlDataReader reader)
=> new(
ReadString(reader, "subject"),
ReadInt(reader, "current_elo"),
ReadInt(reader, "ranked_games"),
ReadInt(reader, "casual_games"),
ReadInt(reader, "wins"),
ReadInt(reader, "losses"),
ReadInt(reader, "stopped_games"),
ReadInt(reader, "white_wins"),
ReadInt(reader, "black_wins"),
ReadInt(reader, "white_losses"),
ReadInt(reader, "black_losses"),
ReadInt(reader, "total_moves"),
ReadInt(reader, "total_cube_rounds"),
ReadInt(reader, "total_cube_entries"),
ReadLong(reader, "total_cube_time_ms"),
ReadNullableLong(reader, "best_cube_time_ms"),
ReadNullableDateTime(reader, "last_match_utc"),
ReadDateTime(reader, "updated_utc"));
private static NormalizedCompletedMatch NormalizeRequest(AuthenticatedSiteUser reporter, ReportCompletedMatchRequest request)
{
var matchId = NormalizeRequiredValue(request.MatchId, "identifiant de match", 80);
var collaborationSessionId = NormalizeOptionalValue(request.CollaborationSessionId, "session collaborative", 80);
var whiteSubject = NormalizeOptionalValue(request.WhiteSubject, "subject blanc", 190);
var blackSubject = NormalizeOptionalValue(request.BlackSubject, "subject noir", 190);
var whiteName = NormalizeRequiredValue(request.WhiteName, "joueur blanc", 120);
var blackName = NormalizeRequiredValue(request.BlackName, "joueur noir", 120);
var mode = NormalizeRequiredValue(request.Mode, "mode", 40);
var preset = NormalizeRequiredValue(request.Preset, "preset", 40);
var matchLabel = NormalizeOptionalValue(request.MatchLabel, "nom de rencontre", 120);
var result = NormalizeResult(request.Result);
var blockNumber = Math.Clamp(request.BlockNumber, 1, 999);
var whiteMoves = Math.Clamp(request.WhiteMoves, 0, 9999);
var blackMoves = Math.Clamp(request.BlackMoves, 0, 9999);
if (whiteSubject is null && blackSubject is null)
{
throw new PlayerStatsValidationException("Impossible d'enregistrer une partie sans joueur identifie.");
}
if (whiteSubject is not null &&
blackSubject is not null &&
string.Equals(whiteSubject, blackSubject, StringComparison.Ordinal))
{
throw new PlayerStatsValidationException("Les deux cotes ne peuvent pas pointer vers le meme compte.");
}
if (!string.Equals(reporter.Subject, whiteSubject, StringComparison.Ordinal) &&
!string.Equals(reporter.Subject, blackSubject, StringComparison.Ordinal))
{
throw new PlayerStatsValidationException("Le compte connecte doit correspondre a l'un des deux joueurs pour enregistrer la partie.");
}
var cubeRounds = (request.CubeRounds ?? [])
.Take(64)
.Select(round => new NormalizedCubeRound(
Math.Clamp(round.BlockNumber, 1, 999),
round.Number is null ? null : Math.Clamp(round.Number.Value, 1, 999),
NormalizeCubeDuration(round.White),
NormalizeCubeDuration(round.Black)))
.ToArray();
var whiteCubeTimes = SummarizeCubeTimes(cubeRounds.Select(round => round.White));
var blackCubeTimes = SummarizeCubeTimes(cubeRounds.Select(round => round.Black));
var isRanked = whiteSubject is not null &&
blackSubject is not null &&
result is MatchResultWhite or MatchResultBlack;
return new NormalizedCompletedMatch(
matchId,
collaborationSessionId,
reporter.Subject,
whiteSubject,
whiteName,
blackSubject,
blackName,
result,
mode,
preset,
matchLabel,
blockNumber,
whiteMoves,
blackMoves,
cubeRounds,
whiteCubeTimes,
blackCubeTimes,
isRanked,
result == MatchResultWhite
? whiteSubject
: result == MatchResultBlack
? blackSubject
: null,
DateTime.UtcNow);
}
private static CubeTimeSummary SummarizeCubeTimes(IEnumerable<long?> values)
{
var normalized = values
.Where(value => value is > 0)
.Select(value => value!.Value)
.ToArray();
if (normalized.Length == 0)
{
return new CubeTimeSummary(0, 0, null, null);
}
return new CubeTimeSummary(
normalized.Length,
normalized.Sum(),
normalized.Min(),
(long)Math.Round(normalized.Average(), MidpointRounding.AwayFromZero));
}
private static long? NormalizeCubeDuration(long? value)
=> value is > 0
? Math.Clamp(value.Value, 1, 3_600_000)
: null;
private static string NormalizeResult(string? value)
{
var normalized = NormalizeRequiredValue(value, "resultat", 20).ToLowerInvariant();
return normalized switch
{
MatchResultWhite => MatchResultWhite,
MatchResultBlack => MatchResultBlack,
MatchResultStopped => MatchResultStopped,
_ => throw new PlayerStatsValidationException("Le resultat doit etre white, black ou stopped."),
};
}
private static string NormalizeRequiredValue(string? value, string fieldName, int maxLength)
=> NormalizeOptionalValue(value, fieldName, maxLength)
?? throw new PlayerStatsValidationException($"Le champ {fieldName} est obligatoire.");
private static string? NormalizeOptionalValue(string? value, string fieldName, int maxLength)
{
var trimmed = value?.Trim();
if (string.IsNullOrWhiteSpace(trimmed))
{
return null;
}
if (trimmed.Length > maxLength)
{
throw new PlayerStatsValidationException($"Le champ {fieldName} depasse {maxLength} caracteres.");
}
return trimmed;
}
private static long? MinNullable(long? current, long? candidate)
{
if (candidate is null)
{
return current;
}
return current is null
? candidate
: Math.Min(current.Value, candidate.Value);
}
private static string ReadString(MySqlDataReader reader, string column)
=> reader.GetString(reader.GetOrdinal(column));
private static string? ReadNullableString(MySqlDataReader reader, string column)
{
var ordinal = reader.GetOrdinal(column);
return reader.IsDBNull(ordinal) ? null : reader.GetString(ordinal);
}
private static int ReadInt(MySqlDataReader reader, string column)
=> reader.GetInt32(reader.GetOrdinal(column));
private static int? ReadNullableInt(MySqlDataReader reader, string column)
{
var ordinal = reader.GetOrdinal(column);
return reader.IsDBNull(ordinal) ? null : reader.GetInt32(ordinal);
}
private static long ReadLong(MySqlDataReader reader, string column)
=> reader.GetInt64(reader.GetOrdinal(column));
private static long? ReadNullableLong(MySqlDataReader reader, string column)
{
var ordinal = reader.GetOrdinal(column);
return reader.IsDBNull(ordinal) ? null : reader.GetInt64(ordinal);
}
private static bool ReadBoolean(MySqlDataReader reader, string column)
=> reader.GetBoolean(reader.GetOrdinal(column));
private static DateTime ReadDateTime(MySqlDataReader reader, string column)
=> reader.GetDateTime(reader.GetOrdinal(column));
private static DateTime? ReadNullableDateTime(MySqlDataReader reader, string column)
{
var ordinal = reader.GetOrdinal(column);
return reader.IsDBNull(ordinal) ? null : reader.GetDateTime(ordinal);
}
private sealed record PlayerStatsRow(
string Subject,
int CurrentElo,
int RankedGames,
int CasualGames,
int Wins,
int Losses,
int StoppedGames,
int WhiteWins,
int BlackWins,
int WhiteLosses,
int BlackLosses,
int TotalMoves,
int TotalCubeRounds,
int TotalCubeEntries,
long TotalCubeTimeMs,
long? BestCubeTimeMs,
DateTime? LastMatchUtc,
DateTime UpdatedUtc);
private sealed record EloSnapshot(
int WhiteBefore,
int WhiteAfter,
int BlackBefore,
int BlackAfter);
private sealed record NormalizedCubeRound(
int BlockNumber,
int? Number,
long? White,
long? Black);
private sealed record CubeTimeSummary(
int Count,
long TotalMs,
long? BestMs,
long? AverageMs);
private sealed record NormalizedCompletedMatch(
string MatchId,
string? CollaborationSessionId,
string RecordedBySubject,
string? WhiteSubject,
string WhiteName,
string? BlackSubject,
string BlackName,
string Result,
string Mode,
string Preset,
string? MatchLabel,
int BlockNumber,
int WhiteMoves,
int BlackMoves,
NormalizedCubeRound[] CubeRounds,
CubeTimeSummary WhiteCubeTimes,
CubeTimeSummary BlackCubeTimes,
bool IsRanked,
string? WinnerSubject,
DateTime CompletedUtc);
}