Files
SVSimServer/SVSim.EmulatedEntrypoint/Services/BattlePassService.cs
gamer147 8a35f8c40b 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).
2026-05-26 23:14:26 -04:00

132 lines
5.0 KiB
C#

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
{
// Default cap mirrors the captured /battle_pass/info.gauge_info.weekly_limit_point.
public const int WeeklyLimitPointDefault = 3000;
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)
{
var rows = await _bp.GetLevelCurveAsync(ct);
if (rows.Count == 0) return null;
return rows.ToDictionary(
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);
}