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>
538 lines
18 KiB
C#
538 lines
18 KiB
C#
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using RR3CommunityServer.Data;
|
|
using RR3CommunityServer.Services;
|
|
using RR3CommunityServer.Models;
|
|
using static RR3CommunityServer.Data.RR3DbContext;
|
|
|
|
namespace RR3CommunityServer.Controllers;
|
|
|
|
[ApiController]
|
|
[Route("synergy/[controller]")]
|
|
public class RewardsController : ControllerBase
|
|
{
|
|
private readonly RR3DbContext _context;
|
|
private readonly ILogger<RewardsController> _logger;
|
|
|
|
public RewardsController(RR3DbContext context, ILogger<RewardsController> logger)
|
|
{
|
|
_context = context;
|
|
_logger = logger;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get daily reward status for a user
|
|
/// </summary>
|
|
[HttpGet("daily/{synergyId}")]
|
|
public async Task<IActionResult> GetDailyReward(string synergyId)
|
|
{
|
|
_logger.LogInformation("Getting daily reward for {SynergyId}", synergyId);
|
|
|
|
var user = await _context.Users.FirstOrDefaultAsync(u => u.SynergyId == synergyId);
|
|
if (user == null)
|
|
{
|
|
return NotFound(new { error = "User not found" });
|
|
}
|
|
|
|
var today = DateTime.UtcNow.Date;
|
|
var reward = await _context.DailyRewards
|
|
.FirstOrDefaultAsync(r => r.UserId == user.Id && r.RewardDate.Date == today);
|
|
|
|
if (reward == null)
|
|
{
|
|
// Create new daily reward
|
|
reward = new DailyReward
|
|
{
|
|
UserId = user.Id,
|
|
RewardDate = DateTime.UtcNow,
|
|
GoldAmount = 50, // Daily gold reward
|
|
CashAmount = 5000, // Daily cash reward
|
|
Claimed = false,
|
|
Streak = await CalculateStreak(user.Id)
|
|
};
|
|
|
|
_context.DailyRewards.Add(reward);
|
|
await _context.SaveChangesAsync();
|
|
}
|
|
|
|
return Ok(new
|
|
{
|
|
available = !reward.Claimed,
|
|
gold = reward.GoldAmount,
|
|
cash = reward.CashAmount,
|
|
streak = reward.Streak,
|
|
nextRewardIn = reward.Claimed ? (24 - (DateTime.UtcNow - reward.RewardDate).TotalHours) : 0
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Claim daily reward
|
|
/// </summary>
|
|
[HttpPost("daily/{synergyId}/claim")]
|
|
public async Task<IActionResult> ClaimDailyReward(string synergyId)
|
|
{
|
|
_logger.LogInformation("Claiming daily reward for {SynergyId}", synergyId);
|
|
|
|
var user = await _context.Users.FirstOrDefaultAsync(u => u.SynergyId == synergyId);
|
|
if (user == null)
|
|
{
|
|
return NotFound(new { error = "User not found" });
|
|
}
|
|
|
|
var today = DateTime.UtcNow.Date;
|
|
var reward = await _context.DailyRewards
|
|
.FirstOrDefaultAsync(r => r.UserId == user.Id && r.RewardDate.Date == today);
|
|
|
|
if (reward == null || reward.Claimed)
|
|
{
|
|
return BadRequest(new { error = "No reward available to claim" });
|
|
}
|
|
|
|
// Mark as claimed
|
|
reward.Claimed = true;
|
|
reward.ClaimedAt = DateTime.UtcNow;
|
|
|
|
// Update user's gold and cash
|
|
if (user.Gold == null) user.Gold = 0;
|
|
if (user.Cash == null) user.Cash = 0;
|
|
|
|
user.Gold += reward.GoldAmount;
|
|
user.Cash += reward.CashAmount;
|
|
|
|
await _context.SaveChangesAsync();
|
|
|
|
return Ok(new
|
|
{
|
|
success = true,
|
|
goldEarned = reward.GoldAmount,
|
|
cashEarned = reward.CashAmount,
|
|
totalGold = user.Gold,
|
|
totalCash = user.Cash,
|
|
streak = reward.Streak
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Purchase gold with real money (free in community server)
|
|
/// </summary>
|
|
/// <summary>
|
|
/// Purchase gold - FREE in community server per EA agreement
|
|
/// IMPORTANT: No real money transactions allowed!
|
|
/// </summary>
|
|
[HttpPost("gold/purchase")]
|
|
public async Task<IActionResult> PurchaseGold([FromBody] GoldPurchaseRequest request)
|
|
{
|
|
_logger.LogInformation("Processing gold purchase for {SynergyId}: {Amount} gold (FREE - community server)",
|
|
request.SynergyId, request.GoldAmount);
|
|
|
|
var user = await _context.Users.FirstOrDefaultAsync(u => u.SynergyId == request.SynergyId);
|
|
if (user == null)
|
|
{
|
|
return NotFound(new { error = "User not found" });
|
|
}
|
|
|
|
// ⚖️ EA COMPLIANCE: All gold purchases are FREE in community server!
|
|
// Per EA agreement: No real money in-app purchases allowed
|
|
if (user.Gold == null) user.Gold = 0;
|
|
user.Gold += request.GoldAmount;
|
|
|
|
// Log the purchase (for tracking, not billing)
|
|
var purchase = new Purchase
|
|
{
|
|
SynergyId = request.SynergyId,
|
|
UserId = user.Id,
|
|
ItemId = $"gold_{request.GoldAmount}",
|
|
Sku = request.Sku ?? $"com.ea.rr3.gold_{request.GoldAmount}",
|
|
OrderId = Guid.NewGuid().ToString(),
|
|
PurchaseTime = DateTime.UtcNow,
|
|
Token = Guid.NewGuid().ToString(),
|
|
Price = 0, // ⚖️ MUST BE 0 - EA COMPLIANCE
|
|
Status = "approved"
|
|
};
|
|
|
|
_context.Purchases.Add(purchase);
|
|
await _context.SaveChangesAsync();
|
|
|
|
return Ok(new
|
|
{
|
|
success = true,
|
|
goldPurchased = request.GoldAmount,
|
|
totalGold = user.Gold,
|
|
orderId = purchase.OrderId,
|
|
message = "Gold added to your account (FREE in community server!)"
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get time trial events
|
|
/// </summary>
|
|
[HttpGet("timetrials")]
|
|
public async Task<IActionResult> GetTimeTrials()
|
|
{
|
|
_logger.LogInformation("Getting time trial events");
|
|
|
|
var trials = await _context.TimeTrials
|
|
.Where(t => t.Active)
|
|
.OrderBy(t => t.StartDate)
|
|
.ToListAsync();
|
|
|
|
return Ok(new
|
|
{
|
|
events = trials.Select(t => new
|
|
{
|
|
id = t.Id,
|
|
name = t.Name,
|
|
track = t.TrackName,
|
|
car = t.CarName,
|
|
startDate = t.StartDate,
|
|
endDate = t.EndDate,
|
|
goldReward = t.GoldReward,
|
|
cashReward = t.CashReward,
|
|
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>
|
|
[HttpPost("timetrials/{trialId}/submit")]
|
|
public async Task<IActionResult> SubmitTimeTrial(int trialId, [FromBody] TimeTrialSubmission submission)
|
|
{
|
|
_logger.LogInformation("Submitting time trial {TrialId} for {SynergyId}: {Time}s",
|
|
trialId, submission.SynergyId, submission.TimeSeconds);
|
|
|
|
var user = await _context.Users.FirstOrDefaultAsync(u => u.SynergyId == submission.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 beat target time
|
|
bool beatTarget = submission.TimeSeconds <= trial.TargetTime;
|
|
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
|
|
{
|
|
UserId = user.Id,
|
|
TimeTrialId = trialId,
|
|
TimeSeconds = submission.TimeSeconds,
|
|
SubmittedAt = DateTime.UtcNow,
|
|
BeatTarget = beatTarget,
|
|
GoldEarned = goldEarned,
|
|
CashEarned = cashEarned
|
|
};
|
|
|
|
_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();
|
|
|
|
// 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,
|
|
IsNewPersonalBest = isNewPersonalBest,
|
|
IsNewGlobalRecord = isNewGlobalRecord,
|
|
GlobalRank = globalRank,
|
|
PreviousBestTime = previousBestTime,
|
|
Improvement = improvement,
|
|
GoldEarned = goldEarned,
|
|
CashEarned = cashEarned
|
|
});
|
|
}
|
|
|
|
private async Task<int> CalculateStreak(int userId)
|
|
{
|
|
var rewards = await _context.DailyRewards
|
|
.Where(r => r.UserId == userId && r.Claimed)
|
|
.OrderByDescending(r => r.RewardDate)
|
|
.ToListAsync();
|
|
|
|
int streak = 0;
|
|
var currentDate = DateTime.UtcNow.Date;
|
|
|
|
foreach (var reward in rewards)
|
|
{
|
|
if (reward.RewardDate.Date == currentDate.AddDays(-streak))
|
|
{
|
|
streak++;
|
|
}
|
|
else
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
|
|
return streak;
|
|
}
|
|
}
|
|
|
|
public class GoldPurchaseRequest
|
|
{
|
|
public string SynergyId { get; set; } = string.Empty;
|
|
public int GoldAmount { get; set; }
|
|
public string? Sku { get; set; }
|
|
}
|
|
|
|
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;
|
|
}
|