RECORDS & LEADERBOARDS (5/5 endpoints - 100%):
- Created LeaderboardsController with 5 endpoints
- GET /synergy/leaderboards/timetrials/{trialId}
- GET /synergy/leaderboards/career/{series}/{event}
- GET /synergy/leaderboards/global/top100
- GET /synergy/leaderboards/player/{synergyId}/records
- GET /synergy/leaderboards/compare/{synergyId1}/{synergyId2}
Added LeaderboardEntry and PersonalRecord models and database tables.
Migration applied: AddLeaderboardsAndRecords
Updated RewardsController.SubmitTimeTrial to track personal bests,
update leaderboards, and award 50 gold bonus for improvements.
Updated ProgressionController.CompleteCareerEvent similarly for
career event personal records.
TIME TRIALS (6/6 endpoints - 100%):
- GET /synergy/rewards/timetrials - List with time remaining
- GET /synergy/rewards/timetrials/{id} - Details with stats
- POST /synergy/rewards/timetrials/{id}/submit - Submit with PB tracking
- GET /synergy/rewards/timetrials/player/{synergyId}/results - History
- POST /synergy/rewards/timetrials/{id}/claim - Claim bonuses
- GET /synergy/leaderboards/timetrials/{id} - Leaderboards (above)
Added navigation properties to TimeTrialResult for easier queries.
Server progress: 66/73 endpoints (90%)
Two complete systems: Records/Leaderboards + Time Trials
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
450 lines
16 KiB
C#
450 lines
16 KiB
C#
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using RR3CommunityServer.Data;
|
|
using RR3CommunityServer.Models;
|
|
|
|
namespace RR3CommunityServer.Controllers;
|
|
|
|
[ApiController]
|
|
[Route("synergy/[controller]")]
|
|
public class LeaderboardsController : ControllerBase
|
|
{
|
|
private readonly RR3DbContext _context;
|
|
private readonly ILogger<LeaderboardsController> _logger;
|
|
|
|
public LeaderboardsController(RR3DbContext context, ILogger<LeaderboardsController> logger)
|
|
{
|
|
_context = context;
|
|
_logger = logger;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get leaderboard for a specific time trial
|
|
/// </summary>
|
|
[HttpGet("timetrials/{trialId}")]
|
|
public async Task<IActionResult> GetTimeTrialLeaderboard(int trialId, [FromQuery] int limit = 100, [FromQuery] string? synergyId = null)
|
|
{
|
|
_logger.LogInformation("Getting time trial leaderboard for trial {TrialId}, limit {Limit}", trialId, limit);
|
|
|
|
var trial = await _context.TimeTrials.FindAsync(trialId);
|
|
if (trial == null)
|
|
{
|
|
return NotFound(new { error = "Time trial not found" });
|
|
}
|
|
|
|
// Get all entries for this time trial, ordered by time
|
|
var entries = await _context.LeaderboardEntries
|
|
.Where(e => e.RecordType == "TimeTrial" && e.RecordCategory == trialId.ToString())
|
|
.OrderBy(e => e.TimeSeconds)
|
|
.Take(limit)
|
|
.ToListAsync();
|
|
|
|
// Convert to DTOs with rankings
|
|
var leaderboard = entries.Select((entry, index) => new LeaderboardEntryDto
|
|
{
|
|
Rank = index + 1,
|
|
PlayerName = entry.PlayerName,
|
|
SynergyId = entry.SynergyId,
|
|
TimeSeconds = entry.TimeSeconds,
|
|
FormattedTime = FormatTime(entry.TimeSeconds),
|
|
SubmittedAt = entry.SubmittedAt,
|
|
CarName = entry.CarName,
|
|
IsCurrentPlayer = !string.IsNullOrEmpty(synergyId) && entry.SynergyId == synergyId
|
|
}).ToList();
|
|
|
|
// Find player's entry if synergyId provided
|
|
LeaderboardEntryDto? playerEntry = null;
|
|
if (!string.IsNullOrEmpty(synergyId))
|
|
{
|
|
var playerRecord = await _context.LeaderboardEntries
|
|
.Where(e => e.RecordType == "TimeTrial" && e.RecordCategory == trialId.ToString() && e.SynergyId == synergyId)
|
|
.OrderBy(e => e.TimeSeconds)
|
|
.FirstOrDefaultAsync();
|
|
|
|
if (playerRecord != null)
|
|
{
|
|
// Calculate player's rank
|
|
var betterTimes = await _context.LeaderboardEntries
|
|
.Where(e => e.RecordType == "TimeTrial" && e.RecordCategory == trialId.ToString() && e.TimeSeconds < playerRecord.TimeSeconds)
|
|
.CountAsync();
|
|
|
|
playerEntry = new LeaderboardEntryDto
|
|
{
|
|
Rank = betterTimes + 1,
|
|
PlayerName = playerRecord.PlayerName,
|
|
SynergyId = playerRecord.SynergyId,
|
|
TimeSeconds = playerRecord.TimeSeconds,
|
|
FormattedTime = FormatTime(playerRecord.TimeSeconds),
|
|
SubmittedAt = playerRecord.SubmittedAt,
|
|
CarName = playerRecord.CarName,
|
|
IsCurrentPlayer = true
|
|
};
|
|
}
|
|
}
|
|
|
|
var response = new LeaderboardResponse
|
|
{
|
|
RecordType = "TimeTrial",
|
|
RecordCategory = $"{trial.Name} - {trial.TrackName}",
|
|
TotalEntries = await _context.LeaderboardEntries
|
|
.Where(e => e.RecordType == "TimeTrial" && e.RecordCategory == trialId.ToString())
|
|
.CountAsync(),
|
|
Entries = leaderboard,
|
|
PlayerEntry = playerEntry
|
|
};
|
|
|
|
return Ok(new SynergyResponse<LeaderboardResponse>
|
|
{
|
|
resultCode = 0,
|
|
data = response
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get leaderboard for a specific career event
|
|
/// </summary>
|
|
[HttpGet("career/{series}/{eventName}")]
|
|
public async Task<IActionResult> GetCareerLeaderboard(string series, string eventName, [FromQuery] int limit = 100, [FromQuery] string? synergyId = null)
|
|
{
|
|
_logger.LogInformation("Getting career leaderboard for {Series}/{Event}, limit {Limit}", series, eventName, limit);
|
|
|
|
var entries = await _context.LeaderboardEntries
|
|
.Where(e => e.RecordType == "Career" && e.RecordCategory == $"{series}/{eventName}")
|
|
.OrderBy(e => e.TimeSeconds)
|
|
.Take(limit)
|
|
.ToListAsync();
|
|
|
|
var leaderboard = entries.Select((entry, index) => new LeaderboardEntryDto
|
|
{
|
|
Rank = index + 1,
|
|
PlayerName = entry.PlayerName,
|
|
SynergyId = entry.SynergyId,
|
|
TimeSeconds = entry.TimeSeconds,
|
|
FormattedTime = FormatTime(entry.TimeSeconds),
|
|
SubmittedAt = entry.SubmittedAt,
|
|
CarName = entry.CarName,
|
|
IsCurrentPlayer = !string.IsNullOrEmpty(synergyId) && entry.SynergyId == synergyId
|
|
}).ToList();
|
|
|
|
// Find player's entry
|
|
LeaderboardEntryDto? playerEntry = null;
|
|
if (!string.IsNullOrEmpty(synergyId))
|
|
{
|
|
var playerRecord = await _context.LeaderboardEntries
|
|
.Where(e => e.RecordType == "Career" && e.RecordCategory == $"{series}/{eventName}" && e.SynergyId == synergyId)
|
|
.OrderBy(e => e.TimeSeconds)
|
|
.FirstOrDefaultAsync();
|
|
|
|
if (playerRecord != null)
|
|
{
|
|
var betterTimes = await _context.LeaderboardEntries
|
|
.Where(e => e.RecordType == "Career" && e.RecordCategory == $"{series}/{eventName}" && e.TimeSeconds < playerRecord.TimeSeconds)
|
|
.CountAsync();
|
|
|
|
playerEntry = new LeaderboardEntryDto
|
|
{
|
|
Rank = betterTimes + 1,
|
|
PlayerName = playerRecord.PlayerName,
|
|
SynergyId = playerRecord.SynergyId,
|
|
TimeSeconds = playerRecord.TimeSeconds,
|
|
FormattedTime = FormatTime(playerRecord.TimeSeconds),
|
|
SubmittedAt = playerRecord.SubmittedAt,
|
|
CarName = playerRecord.CarName,
|
|
IsCurrentPlayer = true
|
|
};
|
|
}
|
|
}
|
|
|
|
var response = new LeaderboardResponse
|
|
{
|
|
RecordType = "Career",
|
|
RecordCategory = $"{series} - {eventName}",
|
|
TotalEntries = await _context.LeaderboardEntries
|
|
.Where(e => e.RecordType == "Career" && e.RecordCategory == $"{series}/{eventName}")
|
|
.CountAsync(),
|
|
Entries = leaderboard,
|
|
PlayerEntry = playerEntry
|
|
};
|
|
|
|
return Ok(new SynergyResponse<LeaderboardResponse>
|
|
{
|
|
resultCode = 0,
|
|
data = response
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get global top 100 players (by total records count or best average time)
|
|
/// </summary>
|
|
[HttpGet("global/top100")]
|
|
public async Task<IActionResult> GetGlobalTop100([FromQuery] string metric = "records")
|
|
{
|
|
_logger.LogInformation("Getting global top 100 players by {Metric}", metric);
|
|
|
|
if (metric == "records")
|
|
{
|
|
// Rank by total number of personal records
|
|
var topPlayers = await _context.PersonalRecords
|
|
.GroupBy(pr => new { pr.SynergyId })
|
|
.Select(g => new
|
|
{
|
|
g.Key.SynergyId,
|
|
RecordCount = g.Count(),
|
|
AverageTime = g.Average(pr => pr.BestTimeSeconds)
|
|
})
|
|
.OrderByDescending(p => p.RecordCount)
|
|
.ThenBy(p => p.AverageTime)
|
|
.Take(100)
|
|
.ToListAsync();
|
|
|
|
// Get player names
|
|
var result = new List<object>();
|
|
foreach (var player in topPlayers)
|
|
{
|
|
var user = await _context.Users.FirstOrDefaultAsync(u => u.SynergyId == player.SynergyId);
|
|
result.Add(new
|
|
{
|
|
rank = topPlayers.IndexOf(player) + 1,
|
|
synergy_id = player.SynergyId,
|
|
player_name = user?.SynergyId ?? "Unknown",
|
|
total_records = player.RecordCount,
|
|
average_time = player.AverageTime,
|
|
formatted_average = FormatTime(player.AverageTime)
|
|
});
|
|
}
|
|
|
|
return Ok(new SynergyResponse<object>
|
|
{
|
|
resultCode = 0,
|
|
data = new
|
|
{
|
|
metric = "total_records",
|
|
top_players = result
|
|
}
|
|
});
|
|
}
|
|
else
|
|
{
|
|
// Rank by best average time across all records
|
|
var topPlayers = await _context.PersonalRecords
|
|
.GroupBy(pr => new { pr.SynergyId })
|
|
.Select(g => new
|
|
{
|
|
g.Key.SynergyId,
|
|
RecordCount = g.Count(),
|
|
AverageTime = g.Average(pr => pr.BestTimeSeconds)
|
|
})
|
|
.Where(p => p.RecordCount >= 5) // Must have at least 5 records
|
|
.OrderBy(p => p.AverageTime)
|
|
.Take(100)
|
|
.ToListAsync();
|
|
|
|
var result = new List<object>();
|
|
foreach (var player in topPlayers)
|
|
{
|
|
var user = await _context.Users.FirstOrDefaultAsync(u => u.SynergyId == player.SynergyId);
|
|
result.Add(new
|
|
{
|
|
rank = topPlayers.IndexOf(player) + 1,
|
|
synergy_id = player.SynergyId,
|
|
player_name = user?.SynergyId ?? "Unknown",
|
|
total_records = player.RecordCount,
|
|
average_time = player.AverageTime,
|
|
formatted_average = FormatTime(player.AverageTime)
|
|
});
|
|
}
|
|
|
|
return Ok(new SynergyResponse<object>
|
|
{
|
|
resultCode = 0,
|
|
data = new
|
|
{
|
|
metric = "average_time",
|
|
minimum_records = 5,
|
|
top_players = result
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get all personal records for a player
|
|
/// </summary>
|
|
[HttpGet("player/{synergyId}/records")]
|
|
public async Task<IActionResult> GetPlayerRecords(string synergyId)
|
|
{
|
|
_logger.LogInformation("Getting personal records for {SynergyId}", synergyId);
|
|
|
|
var user = await _context.Users.FirstOrDefaultAsync(u => u.SynergyId == synergyId);
|
|
if (user == null)
|
|
{
|
|
return NotFound(new { error = "User not found" });
|
|
}
|
|
|
|
var records = await _context.PersonalRecords
|
|
.Where(pr => pr.SynergyId == synergyId)
|
|
.ToListAsync();
|
|
|
|
var timeTrialRecords = new List<PersonalRecordDto>();
|
|
var careerRecords = new List<PersonalRecordDto>();
|
|
|
|
foreach (var record in records)
|
|
{
|
|
// Calculate global rank
|
|
var betterTimes = await _context.LeaderboardEntries
|
|
.Where(e => e.RecordType == record.RecordType &&
|
|
e.RecordCategory == record.RecordCategory &&
|
|
e.TimeSeconds < record.BestTimeSeconds)
|
|
.CountAsync();
|
|
|
|
var dto = new PersonalRecordDto
|
|
{
|
|
RecordCategory = record.RecordCategory,
|
|
TrackName = record.TrackName,
|
|
CarName = record.CarName,
|
|
BestTimeSeconds = record.BestTimeSeconds,
|
|
FormattedTime = FormatTime(record.BestTimeSeconds),
|
|
AchievedAt = record.AchievedAt,
|
|
TotalAttempts = record.TotalAttempts,
|
|
GlobalRank = betterTimes + 1,
|
|
ImprovementSeconds = record.ImprovementSeconds
|
|
};
|
|
|
|
if (record.RecordType == "TimeTrial")
|
|
{
|
|
timeTrialRecords.Add(dto);
|
|
}
|
|
else if (record.RecordType == "Career")
|
|
{
|
|
careerRecords.Add(dto);
|
|
}
|
|
}
|
|
|
|
var response = new PersonalRecordsResponse
|
|
{
|
|
SynergyId = synergyId,
|
|
PlayerName = user.SynergyId,
|
|
TotalRecords = records.Count,
|
|
TimeTrialRecords = timeTrialRecords,
|
|
CareerRecords = careerRecords
|
|
};
|
|
|
|
return Ok(new SynergyResponse<PersonalRecordsResponse>
|
|
{
|
|
resultCode = 0,
|
|
data = response
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Compare records between two players
|
|
/// </summary>
|
|
[HttpGet("compare/{synergyId1}/{synergyId2}")]
|
|
public async Task<IActionResult> CompareRecords(string synergyId1, string synergyId2)
|
|
{
|
|
_logger.LogInformation("Comparing records between {Player1} and {Player2}", synergyId1, synergyId2);
|
|
|
|
var user1 = await _context.Users.FirstOrDefaultAsync(u => u.SynergyId == synergyId1);
|
|
var user2 = await _context.Users.FirstOrDefaultAsync(u => u.SynergyId == synergyId2);
|
|
|
|
if (user1 == null || user2 == null)
|
|
{
|
|
return NotFound(new { error = "One or both users not found" });
|
|
}
|
|
|
|
var records1 = await _context.PersonalRecords.Where(pr => pr.SynergyId == synergyId1).ToListAsync();
|
|
var records2 = await _context.PersonalRecords.Where(pr => pr.SynergyId == synergyId2).ToListAsync();
|
|
|
|
// Find matching records (same category)
|
|
var comparisons = new List<RecordComparison>();
|
|
var allCategories = records1.Select(r => new { r.RecordType, r.RecordCategory })
|
|
.Union(records2.Select(r => new { r.RecordType, r.RecordCategory }))
|
|
.Distinct()
|
|
.ToList();
|
|
|
|
int player1Better = 0;
|
|
int player2Better = 0;
|
|
|
|
foreach (var category in allCategories)
|
|
{
|
|
var record1 = records1.FirstOrDefault(r => r.RecordType == category.RecordType && r.RecordCategory == category.RecordCategory);
|
|
var record2 = records2.FirstOrDefault(r => r.RecordType == category.RecordType && r.RecordCategory == category.RecordCategory);
|
|
|
|
string? winner = null;
|
|
double? timeDiff = null;
|
|
|
|
if (record1 != null && record2 != null)
|
|
{
|
|
if (record1.BestTimeSeconds < record2.BestTimeSeconds)
|
|
{
|
|
winner = "player1";
|
|
timeDiff = record2.BestTimeSeconds - record1.BestTimeSeconds;
|
|
player1Better++;
|
|
}
|
|
else if (record2.BestTimeSeconds < record1.BestTimeSeconds)
|
|
{
|
|
winner = "player2";
|
|
timeDiff = record1.BestTimeSeconds - record2.BestTimeSeconds;
|
|
player2Better++;
|
|
}
|
|
else
|
|
{
|
|
winner = "tie";
|
|
timeDiff = 0;
|
|
}
|
|
}
|
|
else if (record1 != null)
|
|
{
|
|
winner = "player1";
|
|
player1Better++;
|
|
}
|
|
else if (record2 != null)
|
|
{
|
|
winner = "player2";
|
|
player2Better++;
|
|
}
|
|
|
|
comparisons.Add(new RecordComparison
|
|
{
|
|
RecordCategory = category.RecordCategory,
|
|
TrackName = record1?.TrackName ?? record2?.TrackName,
|
|
Player1Time = record1?.BestTimeSeconds,
|
|
Player2Time = record2?.BestTimeSeconds,
|
|
Winner = winner,
|
|
TimeDifference = timeDiff
|
|
});
|
|
}
|
|
|
|
var response = new RecordComparisonResponse
|
|
{
|
|
Player1 = new PlayerRecordSummary
|
|
{
|
|
SynergyId = synergyId1,
|
|
PlayerName = user1.SynergyId,
|
|
TotalRecords = records1.Count,
|
|
BetterRecords = player1Better
|
|
},
|
|
Player2 = new PlayerRecordSummary
|
|
{
|
|
SynergyId = synergyId2,
|
|
PlayerName = user2.SynergyId,
|
|
TotalRecords = records2.Count,
|
|
BetterRecords = player2Better
|
|
},
|
|
Comparisons = comparisons
|
|
};
|
|
|
|
return Ok(new SynergyResponse<RecordComparisonResponse>
|
|
{
|
|
resultCode = 0,
|
|
data = response
|
|
});
|
|
}
|
|
|
|
private string FormatTime(double seconds)
|
|
{
|
|
var timeSpan = TimeSpan.FromSeconds(seconds);
|
|
return $"{(int)timeSpan.TotalMinutes}:{timeSpan.Seconds:D2}.{timeSpan.Milliseconds:D3}";
|
|
}
|
|
}
|