Complete Records/Leaderboards + Time Trials systems (100%)
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>
This commit is contained in:
449
RR3CommunityServer/Controllers/LeaderboardsController.cs
Normal file
449
RR3CommunityServer/Controllers/LeaderboardsController.cs
Normal file
@@ -0,0 +1,449 @@
|
||||
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}";
|
||||
}
|
||||
}
|
||||
@@ -300,8 +300,12 @@ public class ProgressionController : ControllerBase
|
||||
cp.SeriesName == completion.SeriesName &&
|
||||
cp.EventName == completion.EventName);
|
||||
|
||||
bool isFirstCompletion = false;
|
||||
double? previousBestTime = null;
|
||||
|
||||
if (progress == null)
|
||||
{
|
||||
isFirstCompletion = true;
|
||||
progress = new CareerProgress
|
||||
{
|
||||
UserId = user.Id,
|
||||
@@ -312,36 +316,114 @@ public class ProgressionController : ControllerBase
|
||||
};
|
||||
_context.CareerProgress.Add(progress);
|
||||
}
|
||||
else
|
||||
{
|
||||
previousBestTime = progress.BestTime > 0 ? progress.BestTime : null;
|
||||
}
|
||||
|
||||
// Update progress
|
||||
progress.Completed = true;
|
||||
progress.StarsEarned = Math.Max(progress.StarsEarned, completion.StarsEarned);
|
||||
|
||||
bool isNewBestTime = progress.BestTime == 0 || completion.RaceTime < progress.BestTime;
|
||||
progress.BestTime = progress.BestTime == 0 ? completion.RaceTime :
|
||||
Math.Min(progress.BestTime, completion.RaceTime);
|
||||
progress.CompletedAt = DateTime.UtcNow;
|
||||
|
||||
// Track personal record for career event
|
||||
var recordCategory = $"{completion.SeriesName}/{completion.EventName}";
|
||||
var personalRecord = await _context.PersonalRecords
|
||||
.FirstOrDefaultAsync(pr => pr.SynergyId == completion.SynergyId &&
|
||||
pr.RecordType == "Career" &&
|
||||
pr.RecordCategory == recordCategory);
|
||||
|
||||
bool isNewPersonalBest = false;
|
||||
double? improvement = null;
|
||||
|
||||
if (personalRecord == null)
|
||||
{
|
||||
// First attempt
|
||||
isNewPersonalBest = true;
|
||||
personalRecord = new Data.PersonalRecord
|
||||
{
|
||||
SynergyId = completion.SynergyId,
|
||||
RecordType = "Career",
|
||||
RecordCategory = recordCategory,
|
||||
TrackName = completion.TrackName,
|
||||
CarName = completion.CarName,
|
||||
BestTimeSeconds = completion.RaceTime,
|
||||
AchievedAt = DateTime.UtcNow,
|
||||
TotalAttempts = 1
|
||||
};
|
||||
_context.PersonalRecords.Add(personalRecord);
|
||||
}
|
||||
else
|
||||
{
|
||||
personalRecord.TotalAttempts++;
|
||||
|
||||
if (completion.RaceTime < personalRecord.BestTimeSeconds)
|
||||
{
|
||||
isNewPersonalBest = true;
|
||||
improvement = personalRecord.BestTimeSeconds - completion.RaceTime;
|
||||
|
||||
personalRecord.BestTimeSeconds = completion.RaceTime;
|
||||
personalRecord.AchievedAt = DateTime.UtcNow;
|
||||
personalRecord.ImprovementSeconds = improvement;
|
||||
personalRecord.CarName = completion.CarName;
|
||||
}
|
||||
}
|
||||
|
||||
// Add/update leaderboard entry if new personal best
|
||||
if (isNewPersonalBest)
|
||||
{
|
||||
var leaderboardEntry = new Data.LeaderboardEntry
|
||||
{
|
||||
SynergyId = completion.SynergyId,
|
||||
PlayerName = user.SynergyId,
|
||||
RecordType = "Career",
|
||||
RecordCategory = recordCategory,
|
||||
TrackName = completion.TrackName,
|
||||
CarName = completion.CarName,
|
||||
TimeSeconds = completion.RaceTime,
|
||||
SubmittedAt = DateTime.UtcNow
|
||||
};
|
||||
_context.LeaderboardEntries.Add(leaderboardEntry);
|
||||
}
|
||||
|
||||
// Award rewards
|
||||
int goldReward = completion.StarsEarned * 10; // 10 gold per star
|
||||
int cashReward = completion.StarsEarned * 2000; // 2000 cash per star
|
||||
int xpReward = completion.StarsEarned * 100; // 100 XP per star
|
||||
|
||||
// Bonus for personal best
|
||||
if (isNewPersonalBest && previousBestTime.HasValue)
|
||||
{
|
||||
goldReward += 50;
|
||||
}
|
||||
|
||||
user.Gold = (user.Gold ?? 0) + goldReward;
|
||||
user.Cash = (user.Cash ?? 0) + cashReward;
|
||||
user.Experience = (user.Experience ?? 0) + xpReward;
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return Ok(new
|
||||
// Calculate global rank
|
||||
int globalRank = await _context.LeaderboardEntries
|
||||
.Where(e => e.RecordType == "Career" &&
|
||||
e.RecordCategory == recordCategory &&
|
||||
e.TimeSeconds < completion.RaceTime)
|
||||
.CountAsync() + 1;
|
||||
|
||||
return Ok(new RecordSubmissionResponse
|
||||
{
|
||||
success = true,
|
||||
stars = completion.StarsEarned,
|
||||
goldEarned = goldReward,
|
||||
cashEarned = cashReward,
|
||||
xpEarned = xpReward,
|
||||
bestTime = progress.BestTime,
|
||||
totalGold = user.Gold,
|
||||
totalCash = user.Cash,
|
||||
totalExperience = user.Experience
|
||||
Success = true,
|
||||
IsNewPersonalBest = isNewPersonalBest,
|
||||
IsNewGlobalRecord = globalRank == 1,
|
||||
GlobalRank = globalRank,
|
||||
PreviousBestTime = previousBestTime,
|
||||
Improvement = improvement,
|
||||
GoldEarned = goldReward,
|
||||
CashEarned = cashReward
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using RR3CommunityServer.Data;
|
||||
using RR3CommunityServer.Services;
|
||||
using RR3CommunityServer.Models;
|
||||
using static RR3CommunityServer.Data.RR3DbContext;
|
||||
|
||||
namespace RR3CommunityServer.Controllers;
|
||||
@@ -187,11 +188,167 @@ public class RewardsController : ControllerBase
|
||||
endDate = t.EndDate,
|
||||
goldReward = t.GoldReward,
|
||||
cashReward = t.CashReward,
|
||||
targetTime = t.TargetTime
|
||||
targetTime = t.TargetTime,
|
||||
timeRemaining = (t.EndDate - DateTime.UtcNow).TotalSeconds,
|
||||
isActive = t.StartDate <= DateTime.UtcNow && t.EndDate >= DateTime.UtcNow
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get specific time trial details
|
||||
/// </summary>
|
||||
[HttpGet("timetrials/{trialId}")]
|
||||
public async Task<IActionResult> GetTimeTrial(int trialId)
|
||||
{
|
||||
_logger.LogInformation("Getting time trial {TrialId}", trialId);
|
||||
|
||||
var trial = await _context.TimeTrials.FindAsync(trialId);
|
||||
if (trial == null)
|
||||
{
|
||||
return NotFound(new { error = "Time trial not found" });
|
||||
}
|
||||
|
||||
// Get total participants
|
||||
var participantCount = await _context.TimeTrialResults
|
||||
.Where(r => r.TimeTrialId == trialId)
|
||||
.Select(r => r.UserId)
|
||||
.Distinct()
|
||||
.CountAsync();
|
||||
|
||||
// Get fastest time
|
||||
var fastestTime = await _context.TimeTrialResults
|
||||
.Where(r => r.TimeTrialId == trialId)
|
||||
.OrderBy(r => r.TimeSeconds)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
var response = new
|
||||
{
|
||||
id = trial.Id,
|
||||
name = trial.Name,
|
||||
trackName = trial.TrackName,
|
||||
carName = trial.CarName,
|
||||
targetTime = trial.TargetTime,
|
||||
goldReward = trial.GoldReward,
|
||||
cashReward = trial.CashReward,
|
||||
startDate = trial.StartDate,
|
||||
endDate = trial.EndDate,
|
||||
active = trial.Active,
|
||||
timeRemaining = (trial.EndDate - DateTime.UtcNow).TotalSeconds,
|
||||
participants = participantCount,
|
||||
fastestTime = fastestTime?.TimeSeconds,
|
||||
fastestPlayer = fastestTime != null ?
|
||||
(await _context.Users.FindAsync(fastestTime.UserId))?.SynergyId : null
|
||||
};
|
||||
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get player's time trial results
|
||||
/// </summary>
|
||||
[HttpGet("timetrials/player/{synergyId}/results")]
|
||||
public async Task<IActionResult> GetPlayerTimeTrialResults(string synergyId, [FromQuery] int? trialId = null)
|
||||
{
|
||||
_logger.LogInformation("Getting time trial results for {SynergyId}", synergyId);
|
||||
|
||||
var user = await _context.Users.FirstOrDefaultAsync(u => u.SynergyId == synergyId);
|
||||
if (user == null)
|
||||
{
|
||||
return NotFound(new { error = "User not found" });
|
||||
}
|
||||
|
||||
var query = _context.TimeTrialResults
|
||||
.Include(r => r.TimeTrial)
|
||||
.Where(r => r.UserId == user.Id);
|
||||
|
||||
// Filter by specific trial if provided
|
||||
if (trialId.HasValue)
|
||||
{
|
||||
query = query.Where(r => r.TimeTrialId == trialId.Value);
|
||||
}
|
||||
|
||||
var results = await query
|
||||
.OrderByDescending(r => r.SubmittedAt)
|
||||
.ToListAsync();
|
||||
|
||||
var response = new
|
||||
{
|
||||
synergyId = synergyId,
|
||||
totalAttempts = results.Count,
|
||||
totalGoldEarned = results.Sum(r => r.GoldEarned),
|
||||
totalCashEarned = results.Sum(r => r.CashEarned),
|
||||
targetsBeat = results.Count(r => r.BeatTarget),
|
||||
results = results.Select(r => new
|
||||
{
|
||||
trialId = r.TimeTrialId,
|
||||
trialName = r.TimeTrial?.Name,
|
||||
timeSeconds = r.TimeSeconds,
|
||||
beatTarget = r.BeatTarget,
|
||||
goldEarned = r.GoldEarned,
|
||||
cashEarned = r.CashEarned,
|
||||
submittedAt = r.SubmittedAt
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Claim time trial reward (bonus for completing)
|
||||
/// </summary>
|
||||
[HttpPost("timetrials/{trialId}/claim")]
|
||||
public async Task<IActionResult> ClaimTimeTrialReward(int trialId, [FromBody] ClaimRewardRequest request)
|
||||
{
|
||||
_logger.LogInformation("Claiming time trial reward for {SynergyId}, trial {TrialId}",
|
||||
request.SynergyId, trialId);
|
||||
|
||||
var user = await _context.Users.FirstOrDefaultAsync(u => u.SynergyId == request.SynergyId);
|
||||
if (user == null)
|
||||
{
|
||||
return NotFound(new { error = "User not found" });
|
||||
}
|
||||
|
||||
var trial = await _context.TimeTrials.FindAsync(trialId);
|
||||
if (trial == null || !trial.Active)
|
||||
{
|
||||
return NotFound(new { error = "Time trial not found or inactive" });
|
||||
}
|
||||
|
||||
// Check if player has a result for this trial
|
||||
var bestResult = await _context.TimeTrialResults
|
||||
.Where(r => r.UserId == user.Id && r.TimeTrialId == trialId)
|
||||
.OrderBy(r => r.TimeSeconds)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (bestResult == null)
|
||||
{
|
||||
return BadRequest(new { error = "No results found for this time trial" });
|
||||
}
|
||||
|
||||
// Check if already claimed (could store in separate ClaimedRewards table)
|
||||
// For now, just give completion bonus
|
||||
int bonusGold = bestResult.BeatTarget ? 100 : 50; // Bonus for participating
|
||||
int bonusCash = bestResult.BeatTarget ? 10000 : 5000;
|
||||
|
||||
user.Gold = (user.Gold ?? 0) + bonusGold;
|
||||
user.Cash = (user.Cash ?? 0) + bonusCash;
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
success = true,
|
||||
bonusGold = bonusGold,
|
||||
bonusCash = bonusCash,
|
||||
totalGold = user.Gold,
|
||||
totalCash = user.Cash,
|
||||
message = bestResult.BeatTarget ?
|
||||
"🏆 Completion bonus claimed!" :
|
||||
"Thanks for participating! Keep racing!"
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Submit time trial result
|
||||
/// </summary>
|
||||
@@ -218,6 +375,52 @@ public class RewardsController : ControllerBase
|
||||
int goldEarned = beatTarget ? trial.GoldReward : 0;
|
||||
int cashEarned = beatTarget ? trial.CashReward : trial.CashReward / 2; // Half cash for participation
|
||||
|
||||
// Check for personal best
|
||||
var personalRecord = await _context.PersonalRecords
|
||||
.FirstOrDefaultAsync(pr => pr.SynergyId == submission.SynergyId &&
|
||||
pr.RecordType == "TimeTrial" &&
|
||||
pr.RecordCategory == trialId.ToString());
|
||||
|
||||
bool isNewPersonalBest = false;
|
||||
double? previousBestTime = null;
|
||||
double? improvement = null;
|
||||
|
||||
if (personalRecord == null)
|
||||
{
|
||||
// First attempt - create new personal record
|
||||
isNewPersonalBest = true;
|
||||
personalRecord = new Data.PersonalRecord
|
||||
{
|
||||
SynergyId = submission.SynergyId,
|
||||
RecordType = "TimeTrial",
|
||||
RecordCategory = trialId.ToString(),
|
||||
TrackName = trial.TrackName,
|
||||
CarName = submission.CarName,
|
||||
BestTimeSeconds = submission.TimeSeconds,
|
||||
AchievedAt = DateTime.UtcNow,
|
||||
TotalAttempts = 1
|
||||
};
|
||||
_context.PersonalRecords.Add(personalRecord);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Update attempt count
|
||||
personalRecord.TotalAttempts++;
|
||||
|
||||
// Check if this is a new personal best
|
||||
if (submission.TimeSeconds < personalRecord.BestTimeSeconds)
|
||||
{
|
||||
isNewPersonalBest = true;
|
||||
previousBestTime = personalRecord.BestTimeSeconds;
|
||||
improvement = personalRecord.BestTimeSeconds - submission.TimeSeconds;
|
||||
|
||||
personalRecord.BestTimeSeconds = submission.TimeSeconds;
|
||||
personalRecord.AchievedAt = DateTime.UtcNow;
|
||||
personalRecord.ImprovementSeconds = improvement;
|
||||
personalRecord.CarName = submission.CarName;
|
||||
}
|
||||
}
|
||||
|
||||
// Save result
|
||||
var result = new TimeTrialResult
|
||||
{
|
||||
@@ -232,25 +435,59 @@ public class RewardsController : ControllerBase
|
||||
|
||||
_context.TimeTrialResults.Add(result);
|
||||
|
||||
// Add/update leaderboard entry if personal best
|
||||
if (isNewPersonalBest)
|
||||
{
|
||||
var leaderboardEntry = new Data.LeaderboardEntry
|
||||
{
|
||||
SynergyId = submission.SynergyId,
|
||||
PlayerName = user.SynergyId,
|
||||
RecordType = "TimeTrial",
|
||||
RecordCategory = trialId.ToString(),
|
||||
TrackName = trial.TrackName,
|
||||
CarName = submission.CarName,
|
||||
TimeSeconds = submission.TimeSeconds,
|
||||
SubmittedAt = DateTime.UtcNow
|
||||
};
|
||||
_context.LeaderboardEntries.Add(leaderboardEntry);
|
||||
}
|
||||
|
||||
// Award currency
|
||||
if (user.Gold == null) user.Gold = 0;
|
||||
if (user.Cash == null) user.Cash = 0;
|
||||
user.Gold += goldEarned;
|
||||
user.Cash += cashEarned;
|
||||
|
||||
// Bonus rewards for personal best
|
||||
if (isNewPersonalBest && previousBestTime.HasValue)
|
||||
{
|
||||
int bonusGold = 50; // Bonus for improving
|
||||
user.Gold += bonusGold;
|
||||
goldEarned += bonusGold;
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return Ok(new
|
||||
// Calculate global rank
|
||||
int globalRank = await _context.LeaderboardEntries
|
||||
.Where(e => e.RecordType == "TimeTrial" &&
|
||||
e.RecordCategory == trialId.ToString() &&
|
||||
e.TimeSeconds < submission.TimeSeconds)
|
||||
.CountAsync() + 1;
|
||||
|
||||
// Check if this is a new global record
|
||||
bool isNewGlobalRecord = globalRank == 1;
|
||||
|
||||
return Ok(new RecordSubmissionResponse
|
||||
{
|
||||
success = true,
|
||||
beatTarget = beatTarget,
|
||||
timeSeconds = submission.TimeSeconds,
|
||||
targetTime = trial.TargetTime,
|
||||
goldEarned = goldEarned,
|
||||
cashEarned = cashEarned,
|
||||
totalGold = user.Gold,
|
||||
totalCash = user.Cash,
|
||||
message = beatTarget ? "🏆 Target time beaten!" : "Good try! Keep racing!"
|
||||
Success = true,
|
||||
IsNewPersonalBest = isNewPersonalBest,
|
||||
IsNewGlobalRecord = isNewGlobalRecord,
|
||||
GlobalRank = globalRank,
|
||||
PreviousBestTime = previousBestTime,
|
||||
Improvement = improvement,
|
||||
GoldEarned = goldEarned,
|
||||
CashEarned = cashEarned
|
||||
});
|
||||
}
|
||||
|
||||
@@ -291,4 +528,10 @@ public class TimeTrialSubmission
|
||||
{
|
||||
public string SynergyId { get; set; } = string.Empty;
|
||||
public double TimeSeconds { get; set; }
|
||||
public string? CarName { get; set; }
|
||||
}
|
||||
|
||||
public class ClaimRewardRequest
|
||||
{
|
||||
public string SynergyId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user