feat(bp): /battle_pass/info — service + controller + 3 tests
Also fixes BattlePassRepository.GetActiveSeasonAsync to use client-side DateTimeOffset filtering (SQLite provider cannot translate DateTimeOffset comparisons in LINQ WHERE/ORDER BY clauses).
This commit is contained in:
31
SVSim.EmulatedEntrypoint/Controllers/BattlePassController.cs
Normal file
31
SVSim.EmulatedEntrypoint/Controllers/BattlePassController.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.BattlePass;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
|
||||
using SVSim.EmulatedEntrypoint.Services;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// /battle_pass/* — season metadata, premium-pass purchase. Wire shapes mirror
|
||||
/// Wizard/BattlePass{Info,PurchaseInfo,Buy}Task.cs.
|
||||
/// </summary>
|
||||
[Route("battle_pass")]
|
||||
public class BattlePassController : SVSimController
|
||||
{
|
||||
private readonly IBattlePassService _battlePass;
|
||||
|
||||
public BattlePassController(IBattlePassService battlePass)
|
||||
{
|
||||
_battlePass = battlePass;
|
||||
}
|
||||
|
||||
[HttpPost("info")]
|
||||
public async Task<IActionResult> Info(BaseRequest request, CancellationToken ct)
|
||||
{
|
||||
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
|
||||
|
||||
var info = await _battlePass.GetInfoAsync(viewerId, ct);
|
||||
if (info is null) return Ok(new { }); // off-season: empty payload
|
||||
return Ok(info);
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,33 @@
|
||||
using System.Globalization;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.Database.Repositories.BattlePass;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.BattlePass;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Services;
|
||||
|
||||
public sealed class BattlePassService : IBattlePassService
|
||||
{
|
||||
private readonly IBattlePassRepository _bp;
|
||||
// Default cap mirrors the captured /battle_pass/info.gauge_info.weekly_limit_point.
|
||||
public const int WeeklyLimitPointDefault = 3000;
|
||||
|
||||
public BattlePassService(IBattlePassRepository bp)
|
||||
private readonly IBattlePassRepository _bp;
|
||||
private readonly IViewerBattlePassRepository _viewerBp;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly SVSimDbContext _db;
|
||||
|
||||
public BattlePassService(
|
||||
IBattlePassRepository bp,
|
||||
IViewerBattlePassRepository viewerBp,
|
||||
TimeProvider time,
|
||||
SVSimDbContext db)
|
||||
{
|
||||
_bp = bp;
|
||||
_viewerBp = viewerBp;
|
||||
_time = time;
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyDictionary<string, BattlePassLevel>?> GetLevelCurveAsync(CancellationToken ct)
|
||||
@@ -18,11 +35,97 @@ public sealed class BattlePassService : IBattlePassService
|
||||
var rows = await _bp.GetLevelCurveAsync(ct);
|
||||
if (rows.Count == 0) return null;
|
||||
return rows.ToDictionary(
|
||||
r => r.Level.ToString(CultureInfo.InvariantCulture),
|
||||
r => new BattlePassLevel
|
||||
{
|
||||
Level = r.Level.ToString(CultureInfo.InvariantCulture),
|
||||
RequiredPoint = r.RequiredPoint.ToString(CultureInfo.InvariantCulture),
|
||||
});
|
||||
r => Inv(r.Level),
|
||||
r => new BattlePassLevel { Level = Inv(r.Level), RequiredPoint = Inv(r.RequiredPoint) });
|
||||
}
|
||||
|
||||
public async Task<BattlePassInfoResponse?> GetInfoAsync(long viewerId, CancellationToken ct)
|
||||
{
|
||||
var now = _time.GetUtcNow();
|
||||
var season = await _bp.GetActiveSeasonAsync(now, ct);
|
||||
if (season is null) return null;
|
||||
|
||||
var progress = await _viewerBp.GetOrCreateProgressAsync(viewerId, season.Id, ct);
|
||||
// Persist the lazy-created row so concurrent /info calls don't try to create twice.
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
var rewards = await _bp.GetSeasonRewardsAsync(season.Id, ct);
|
||||
var claims = await _viewerBp.GetClaimsAsync(viewerId, season.Id, ct);
|
||||
var claimSet = claims.Select(c => (c.Track, c.Level)).ToHashSet();
|
||||
|
||||
var curve = await _bp.GetLevelCurveAsync(ct);
|
||||
int currentLevel = ComputeLevel(curve, progress.CurrentPoint);
|
||||
|
||||
return new BattlePassInfoResponse
|
||||
{
|
||||
SeasonInfo = new BattlePassSeasonInfoDto
|
||||
{
|
||||
Id = Inv(season.Id),
|
||||
SeasonName = season.Name,
|
||||
MaxLevel = Inv(season.MaxLevel),
|
||||
StartDate = FormatWireDate(season.StartDate),
|
||||
EndDate = FormatWireDate(season.EndDate),
|
||||
CanPurchase = season.CanPurchase,
|
||||
},
|
||||
RewardInfo = new BattlePassRewardInfoDto
|
||||
{
|
||||
Normal = new BattlePassRewardListDto
|
||||
{
|
||||
Reward = rewards.Where(r => r.Track == BattlePassTrack.Normal)
|
||||
.Select(r => ToRewardDto(r, claimSet))
|
||||
.ToList(),
|
||||
},
|
||||
Premium = new BattlePassRewardListDto
|
||||
{
|
||||
Reward = rewards.Where(r => r.Track == BattlePassTrack.Premium)
|
||||
.Select(r => ToRewardDto(r, claimSet))
|
||||
.ToList(),
|
||||
},
|
||||
},
|
||||
GaugeInfo = new BattlePassGaugeInfoDto
|
||||
{
|
||||
CurrentPoint = Inv(progress.CurrentPoint),
|
||||
CurrentLevel = Inv(currentLevel),
|
||||
WeeklyBattlePassPoint = progress.WeeklyPoints,
|
||||
WeeklyLimitPoint = WeeklyLimitPointDefault,
|
||||
},
|
||||
PremiumAppealLevel = null, // populated when premium_appeal config is wired (future)
|
||||
};
|
||||
}
|
||||
|
||||
internal static int ComputeLevel(IReadOnlyList<BattlePassLevelEntry> curve, int point)
|
||||
{
|
||||
if (curve.Count == 0) return 1;
|
||||
int level = curve[0].Level;
|
||||
foreach (var row in curve)
|
||||
{
|
||||
if (point >= row.RequiredPoint) level = row.Level;
|
||||
else break;
|
||||
}
|
||||
return level;
|
||||
}
|
||||
|
||||
private static BattlePassRewardDto ToRewardDto(BattlePassRewardEntry r, HashSet<(BattlePassTrack, int)> claimSet)
|
||||
{
|
||||
return new BattlePassRewardDto
|
||||
{
|
||||
RewardLevel = Inv(r.Level),
|
||||
RewardType = Inv(r.RewardType),
|
||||
RewardDetailId = Inv(r.RewardDetailId),
|
||||
RewardNumber = Inv(r.RewardNumber),
|
||||
IsReceived = claimSet.Contains((r.Track, r.Level)),
|
||||
IsAppealExclusion = r.Track == BattlePassTrack.Premium
|
||||
? (r.IsAppealExclusion ? "1" : "0")
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
private static string FormatWireDate(DateTimeOffset dt) =>
|
||||
// Capture format is "2026-04-01 02:00:00" (JST, space-separated). Emit in same shape
|
||||
// in JST so the client gets back what it gave.
|
||||
dt.ToOffset(TimeSpan.FromHours(9))
|
||||
.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture);
|
||||
|
||||
private static string Inv(long v) => v.ToString(CultureInfo.InvariantCulture);
|
||||
private static string Inv(int v) => v.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.BattlePass;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Services;
|
||||
|
||||
@@ -7,5 +8,9 @@ public interface IBattlePassService
|
||||
/// <summary>Global level curve as wire-string dictionary; null if no levels seeded.</summary>
|
||||
Task<IReadOnlyDictionary<string, BattlePassLevel>?> GetLevelCurveAsync(CancellationToken ct);
|
||||
|
||||
// The Info / ItemList / Buy / AddPoints methods are added in later tasks (11, 12, 13, 14).
|
||||
/// <summary>
|
||||
/// /battle_pass/info payload. Returns null when no active season window covers <c>now</c>
|
||||
/// (controller emits empty body in that case).
|
||||
/// </summary>
|
||||
Task<BattlePassInfoResponse?> GetInfoAsync(long viewerId, CancellationToken ct);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user