- Added ConfigController with 4 endpoints:
- getGameConfig: Server config, feature flags, URLs
- getServerTime: UTC timestamps
- getFeatureFlags: Feature toggles
- getServerStatus: Health check
- Added save/load system to ProgressionController:
- POST /save/{synergyId}: Save JSON blob
- GET /save/{synergyId}/load: Load JSON blob
- Version tracking and timestamps
- Added PlayerSave entity to database:
- Stores arbitrary JSON game state
- Version tracking (increments on save)
- LastModified timestamps
- Updated appsettings.json:
- ServerSettings section (version, URLs, MOTD)
- FeatureFlags section (7 feature toggles)
- Created migration: AddPlayerSavesAndConfig
- Updated ApiModels with new DTOs
- All endpoints tested and working
Phase 1 objectives complete:
✅ Synergy ID generation (already existed)
✅ Configuration endpoints (new)
✅ Save/load system (new)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
451 lines
14 KiB
C#
451 lines
14 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);
|
|
|
|
if (progress == null)
|
|
{
|
|
progress = new CareerProgress
|
|
{
|
|
UserId = user.Id,
|
|
SeriesName = completion.SeriesName,
|
|
EventName = completion.EventName,
|
|
Completed = false,
|
|
StarsEarned = 0
|
|
};
|
|
_context.CareerProgress.Add(progress);
|
|
}
|
|
|
|
// Update progress
|
|
progress.Completed = true;
|
|
progress.StarsEarned = Math.Max(progress.StarsEarned, completion.StarsEarned);
|
|
progress.BestTime = progress.BestTime == 0 ? completion.RaceTime :
|
|
Math.Min(progress.BestTime, completion.RaceTime);
|
|
progress.CompletedAt = DateTime.UtcNow;
|
|
|
|
// 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
|
|
|
|
user.Gold = (user.Gold ?? 0) + goldReward;
|
|
user.Cash = (user.Cash ?? 0) + cashReward;
|
|
user.Experience = (user.Experience ?? 0) + xpReward;
|
|
|
|
await _context.SaveChangesAsync();
|
|
|
|
return Ok(new
|
|
{
|
|
success = true,
|
|
stars = completion.StarsEarned,
|
|
goldEarned = goldReward,
|
|
cashEarned = cashReward,
|
|
xpEarned = xpReward,
|
|
bestTime = progress.BestTime,
|
|
totalGold = user.Gold,
|
|
totalCash = user.Cash,
|
|
totalExperience = user.Experience
|
|
});
|
|
}
|
|
|
|
/// <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);
|
|
}
|
|
}
|