Files
SVSimServer/SVSim.EmulatedEntrypoint/Services/BattlePassService.cs
gamer147 0ceab721e9 feat(bp): /battle_pass/item_list — derives product per active season
Adds BattlePassSalesPeriodInfoDto, BattlePassProductDto, BattlePassItemListResponse DTOs,
GetItemListAsync on BattlePassService (one product if not premium + CanPurchase, empty if
already premium or off-season), and the /battle_pass/item_list controller action.
2 new integration tests; all 408 pass.
2026-05-26 23:26:46 -04:00

171 lines
6.4 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;
/// <summary>JST = UTC+9. Capture format ("2026-04-01 02:00:00") is implicit JST.</summary>
private static readonly TimeSpan JstOffset = TimeSpan.FromHours(9);
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);
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)
};
}
public async Task<BattlePassItemListResponse?> GetItemListAsync(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);
var response = new BattlePassItemListResponse
{
PremiumPassDescription = season.Description,
SalesPeriodInfo = new BattlePassSalesPeriodInfoDto
{
SalesPeriodTime = FormatWireDate(season.EndDate),
},
Products = new List<BattlePassProductDto>(),
};
// One product per active season; empty if viewer is already premium.
if (!progress.IsPremium && season.CanPurchase)
{
response.Products.Add(new BattlePassProductDto
{
Id = season.Id * 1000,
SeasonId = season.Id,
Name = $"{season.Name} Premium Pass",
PriceCrystal = season.PriceCrystal,
Description = season.Description,
SalesPeriodInfo = new BattlePassSalesPeriodInfoDto
{
SalesPeriodTime = FormatWireDate(season.EndDate),
},
});
}
return response;
}
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(JstOffset)
.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);
}