Files
rr3-server/RR3CommunityServer/Controllers/ProgressionController.cs
Daniel Elliott a6167c8249 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>
2026-02-22 17:49:23 -08:00

533 lines
17 KiB
C#

using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using RR3CommunityServer.Data;
using RR3CommunityServer.Models;
using static RR3CommunityServer.Data.RR3DbContext;
namespace RR3CommunityServer.Controllers;
[ApiController]
[Route("synergy/[controller]")]
public class ProgressionController : ControllerBase
{
private readonly RR3DbContext _context;
private readonly ILogger<ProgressionController> _logger;
public ProgressionController(RR3DbContext context, ILogger<ProgressionController> logger)
{
_context = context;
_logger = logger;
}
/// <summary>
/// Get player progression data (career, owned cars, upgrades, etc.)
/// </summary>
[HttpGet("player/{synergyId}")]
public async Task<IActionResult> GetPlayerProgression(string synergyId)
{
_logger.LogInformation("Getting progression for {SynergyId}", synergyId);
var user = await _context.Users
.Include(u => u.OwnedCars)
.Include(u => u.CareerProgress)
.FirstOrDefaultAsync(u => u.SynergyId == synergyId);
if (user == null)
{
return NotFound(new { error = "User not found" });
}
return Ok(new
{
playerId = user.SynergyId,
level = user.Level ?? 1,
experience = user.Experience ?? 0,
gold = user.Gold ?? 0,
cash = user.Cash ?? 0,
reputation = user.Reputation ?? 0,
ownedCars = user.OwnedCars.Select(c => new
{
id = c.CarId,
name = c.CarName,
manufacturer = c.Manufacturer,
class_type = c.ClassType,
performance_rating = c.PerformanceRating,
upgrade_level = c.UpgradeLevel,
purchased_upgrades = c.PurchasedUpgrades
}),
careerProgress = user.CareerProgress.Select(cp => new
{
series = cp.SeriesName,
eventName = cp.EventName,
completed = cp.Completed,
stars = cp.StarsEarned,
best_time = cp.BestTime
})
});
}
/// <summary>
/// Update player progression (complete event, earn XP, etc.)
/// </summary>
[HttpPost("player/{synergyId}/update")]
public async Task<IActionResult>UpdateProgression(string synergyId, [FromBody] ProgressionUpdate update)
{
_logger.LogInformation("Updating progression for {SynergyId}", synergyId);
var user = await _context.Users.FirstOrDefaultAsync(u => u.SynergyId == synergyId);
if (user == null)
{
return NotFound(new { error = "User not found" });
}
// Update currency
if (update.GoldEarned.HasValue)
{
user.Gold = (user.Gold ?? 0) + update.GoldEarned.Value;
}
if (update.CashEarned.HasValue)
{
user.Cash = (user.Cash ?? 0) + update.CashEarned.Value;
}
// Update XP and level
if (update.ExperienceEarned.HasValue)
{
user.Experience = (user.Experience ?? 0) + update.ExperienceEarned.Value;
// Level up calculation (every 1000 XP = 1 level)
int newLevel = (user.Experience.Value / 1000) + 1;
if (newLevel > (user.Level ?? 1))
{
user.Level = newLevel;
// Level up rewards
user.Gold = (user.Gold ?? 0) + (newLevel * 10); // 10 gold per level
user.Cash = (user.Cash ?? 0) + (newLevel * 5000); // 5k cash per level
}
}
// Update reputation
if (update.ReputationEarned.HasValue)
{
user.Reputation = (user.Reputation ?? 0) + update.ReputationEarned.Value;
}
await _context.SaveChangesAsync();
return Ok(new
{
success = true,
newGold = user.Gold,
newCash = user.Cash,
newExperience = user.Experience,
newLevel = user.Level,
newReputation = user.Reputation,
leveledUp = update.ExperienceEarned.HasValue && (user.Experience.Value / 1000) + 1 > (user.Level ?? 1) - 1
});
}
/// <summary>
/// Purchase/unlock a car
/// </summary>
[HttpPost("car/purchase")]
public async Task<IActionResult> PurchaseCar([FromBody] CarPurchaseRequest request)
{
_logger.LogInformation("Purchasing car {CarId} for {SynergyId}", request.CarId, request.SynergyId);
var user = await _context.Users
.Include(u => u.OwnedCars)
.FirstOrDefaultAsync(u => u.SynergyId == request.SynergyId);
if (user == null)
{
return NotFound(new { error = "User not found" });
}
// Check if already owned
if (user.OwnedCars.Any(c => c.CarId == request.CarId))
{
return BadRequest(new { error = "Car already owned" });
}
// Get car data from catalog
var carData = await _context.Cars.FirstOrDefaultAsync(c => c.CarId == request.CarId);
if (carData == null)
{
return NotFound(new { error = "Car not found in catalog" });
}
// Check currency (in community server, can make it free or use price)
int costGold = request.UseGold ? carData.GoldPrice : 0;
int costCash = !request.UseGold ? carData.CashPrice : 0;
if (request.UseGold && (user.Gold ?? 0) < costGold)
{
return BadRequest(new { error = "Insufficient gold" });
}
if (!request.UseGold && (user.Cash ?? 0) < costCash)
{
return BadRequest(new { error = "Insufficient cash" });
}
// Deduct currency
if (request.UseGold)
{
user.Gold -= costGold;
}
else
{
user.Cash -= costCash;
}
// Add car to garage
var ownedCar = new OwnedCar
{
UserId = user.Id,
CarId = carData.CarId,
CarName = carData.Name,
Manufacturer = carData.Manufacturer,
ClassType = carData.ClassType,
PerformanceRating = carData.BasePerformanceRating,
UpgradeLevel = 0,
PurchasedUpgrades = "",
PurchasedAt = DateTime.UtcNow
};
_context.OwnedCars.Add(ownedCar);
await _context.SaveChangesAsync();
return Ok(new
{
success = true,
carId = ownedCar.CarId,
carName = ownedCar.CarName,
goldSpent = costGold,
cashSpent = costCash,
remainingGold = user.Gold,
remainingCash = user.Cash
});
}
/// <summary>
/// Upgrade a car
/// </summary>
[HttpPost("car/upgrade")]
public async Task<IActionResult> UpgradeCar([FromBody] CarUpgradeRequest request)
{
_logger.LogInformation("Upgrading car {CarId} for {SynergyId}: {Upgrade}",
request.CarId, request.SynergyId, request.UpgradeType);
var user = await _context.Users
.Include(u => u.OwnedCars)
.FirstOrDefaultAsync(u => u.SynergyId == request.SynergyId);
if (user == null)
{
return NotFound(new { error = "User not found" });
}
var ownedCar = user.OwnedCars.FirstOrDefault(c => c.CarId == request.CarId);
if (ownedCar == null)
{
return BadRequest(new { error = "Car not owned" });
}
// Get upgrade cost
var upgrade = await _context.CarUpgrades
.FirstOrDefaultAsync(u => u.CarId == request.CarId && u.UpgradeType == request.UpgradeType);
if (upgrade == null)
{
return NotFound(new { error = "Upgrade not found" });
}
// Check if already purchased
var purchasedUpgrades = ownedCar.PurchasedUpgrades?.Split(',') ?? Array.Empty<string>();
if (purchasedUpgrades.Contains(request.UpgradeType))
{
return BadRequest(new { error = "Upgrade already purchased" });
}
// Check currency
if ((user.Cash ?? 0) < upgrade.CashCost)
{
return BadRequest(new { error = "Insufficient cash" });
}
// Apply upgrade
user.Cash -= upgrade.CashCost;
ownedCar.UpgradeLevel++;
ownedCar.PerformanceRating += upgrade.PerformanceIncrease;
var newUpgrades = purchasedUpgrades.Append(request.UpgradeType).ToArray();
ownedCar.PurchasedUpgrades = string.Join(",", newUpgrades);
await _context.SaveChangesAsync();
return Ok(new
{
success = true,
upgradeType = request.UpgradeType,
cashSpent = upgrade.CashCost,
newPerformanceRating = ownedCar.PerformanceRating,
newUpgradeLevel = ownedCar.UpgradeLevel,
remainingCash = user.Cash
});
}
/// <summary>
/// Complete a career event
/// </summary>
[HttpPost("career/complete")]
public async Task<IActionResult> CompleteCareerEvent([FromBody] CareerEventCompletion completion)
{
_logger.LogInformation("Completing career event {Event} for {SynergyId}",
completion.EventName, completion.SynergyId);
var user = await _context.Users
.Include(u => u.CareerProgress)
.FirstOrDefaultAsync(u => u.SynergyId == completion.SynergyId);
if (user == null)
{
return NotFound(new { error = "User not found" });
}
// Find or create progress entry
var progress = user.CareerProgress.FirstOrDefault(cp =>
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,
SeriesName = completion.SeriesName,
EventName = completion.EventName,
Completed = false,
StarsEarned = 0
};
_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();
// 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,
IsNewPersonalBest = isNewPersonalBest,
IsNewGlobalRecord = globalRank == 1,
GlobalRank = globalRank,
PreviousBestTime = previousBestTime,
Improvement = improvement,
GoldEarned = goldReward,
CashEarned = cashReward
});
}
/// <summary>
/// Save player game state as JSON blob
/// </summary>
[HttpPost("save/{synergyId}")]
public async Task<IActionResult> SavePlayerData(string synergyId, [FromBody] SaveDataRequest request)
{
_logger.LogInformation("Saving data for {SynergyId} ({Bytes} bytes)",
synergyId, request.SaveData?.Length ?? 0);
if (string.IsNullOrEmpty(request.SaveData))
{
return BadRequest(new { error = "Save data is empty" });
}
// Find or create save record
var save = await _context.PlayerSaves
.FirstOrDefaultAsync(s => s.SynergyId == synergyId);
if (save == null)
{
save = new PlayerSave
{
SynergyId = synergyId,
SaveDataJson = request.SaveData,
Version = 1,
CreatedAt = DateTime.UtcNow,
LastModified = DateTime.UtcNow
};
_context.PlayerSaves.Add(save);
}
else
{
save.SaveDataJson = request.SaveData;
save.Version++;
save.LastModified = DateTime.UtcNow;
}
await _context.SaveChangesAsync();
var response = new SynergyResponse<SaveDataResponse>
{
resultCode = 0,
message = "Save successful",
data = new SaveDataResponse
{
Success = true,
Version = save.Version,
LastModified = new DateTimeOffset(save.LastModified).ToUnixTimeSeconds(),
SaveData = string.Empty // Don't echo back the full data
}
};
return Ok(response);
}
/// <summary>
/// Load player game state JSON blob
/// </summary>
[HttpGet("save/{synergyId}/load")]
public async Task<IActionResult> LoadPlayerData(string synergyId)
{
_logger.LogInformation("Loading save data for {SynergyId}", synergyId);
var save = await _context.PlayerSaves
.FirstOrDefaultAsync(s => s.SynergyId == synergyId);
if (save == null)
{
// Return empty save for new players
var emptySave = new SaveDataResponse
{
SaveData = "{}",
Version = 0,
LastModified = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
Success = true
};
var emptyResponse = new SynergyResponse<SaveDataResponse>
{
resultCode = 0,
message = "No save found - new player",
data = emptySave
};
return Ok(emptyResponse);
}
var response = new SynergyResponse<SaveDataResponse>
{
resultCode = 0,
message = "Save loaded successfully",
data = new SaveDataResponse
{
SaveData = save.SaveDataJson,
Version = save.Version,
LastModified = new DateTimeOffset(save.LastModified).ToUnixTimeSeconds(),
Success = true
}
};
return Ok(response);
}
}