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:
@@ -18,11 +18,17 @@ public sealed class BattlePassRepository : IBattlePassRepository
|
|||||||
|
|
||||||
public async Task<BattlePassSeasonEntry?> GetActiveSeasonAsync(DateTimeOffset when, CancellationToken ct)
|
public async Task<BattlePassSeasonEntry?> GetActiveSeasonAsync(DateTimeOffset when, CancellationToken ct)
|
||||||
{
|
{
|
||||||
return await _db.BattlePassSeasons
|
// Use UtcDateTime for the LINQ comparison so the query translates on both Postgres and
|
||||||
|
// SQLite. DateTimeOffset arithmetic in LINQ isn't supported by the SQLite provider;
|
||||||
|
// DateTime (UTC) is stored and compared as ISO-8601 text which SQLite handles fine.
|
||||||
|
var utcNow = when.UtcDateTime;
|
||||||
|
var candidates = await _db.BattlePassSeasons
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(s => s.StartDate <= when && s.EndDate > when)
|
.ToListAsync(ct);
|
||||||
|
return candidates
|
||||||
|
.Where(s => s.StartDate.UtcDateTime <= utcNow && s.EndDate.UtcDateTime > utcNow)
|
||||||
.OrderByDescending(s => s.StartDate)
|
.OrderByDescending(s => s.StartDate)
|
||||||
.FirstOrDefaultAsync(ct);
|
.FirstOrDefault();
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<BattlePassSeasonEntry?> GetSeasonAsync(int seasonId, CancellationToken ct) =>
|
public Task<BattlePassSeasonEntry?> GetSeasonAsync(int seasonId, CancellationToken ct) =>
|
||||||
|
|||||||
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 System.Globalization;
|
||||||
|
using SVSim.Database;
|
||||||
|
using SVSim.Database.Enums;
|
||||||
|
using SVSim.Database.Models;
|
||||||
using SVSim.Database.Repositories.BattlePass;
|
using SVSim.Database.Repositories.BattlePass;
|
||||||
using SVSim.EmulatedEntrypoint.Models.Dtos;
|
using SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||||
|
using SVSim.EmulatedEntrypoint.Models.Dtos.BattlePass;
|
||||||
|
|
||||||
namespace SVSim.EmulatedEntrypoint.Services;
|
namespace SVSim.EmulatedEntrypoint.Services;
|
||||||
|
|
||||||
public sealed class BattlePassService : IBattlePassService
|
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;
|
_bp = bp;
|
||||||
|
_viewerBp = viewerBp;
|
||||||
|
_time = time;
|
||||||
|
_db = db;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyDictionary<string, BattlePassLevel>?> GetLevelCurveAsync(CancellationToken ct)
|
public async Task<IReadOnlyDictionary<string, BattlePassLevel>?> GetLevelCurveAsync(CancellationToken ct)
|
||||||
@@ -18,11 +35,97 @@ public sealed class BattlePassService : IBattlePassService
|
|||||||
var rows = await _bp.GetLevelCurveAsync(ct);
|
var rows = await _bp.GetLevelCurveAsync(ct);
|
||||||
if (rows.Count == 0) return null;
|
if (rows.Count == 0) return null;
|
||||||
return rows.ToDictionary(
|
return rows.ToDictionary(
|
||||||
r => r.Level.ToString(CultureInfo.InvariantCulture),
|
r => Inv(r.Level),
|
||||||
r => new BattlePassLevel
|
r => new BattlePassLevel { Level = Inv(r.Level), RequiredPoint = Inv(r.RequiredPoint) });
|
||||||
{
|
|
||||||
Level = r.Level.ToString(CultureInfo.InvariantCulture),
|
|
||||||
RequiredPoint = r.RequiredPoint.ToString(CultureInfo.InvariantCulture),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
using SVSim.EmulatedEntrypoint.Models.Dtos.BattlePass;
|
||||||
|
|
||||||
namespace SVSim.EmulatedEntrypoint.Services;
|
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>
|
/// <summary>Global level curve as wire-string dictionary; null if no levels seeded.</summary>
|
||||||
Task<IReadOnlyDictionary<string, BattlePassLevel>?> GetLevelCurveAsync(CancellationToken ct);
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
123
SVSim.UnitTests/Controllers/BattlePassControllerInfoTests.cs
Normal file
123
SVSim.UnitTests/Controllers/BattlePassControllerInfoTests.cs
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using SVSim.Database;
|
||||||
|
using SVSim.Database.Enums;
|
||||||
|
using SVSim.Database.Models;
|
||||||
|
using SVSim.UnitTests.Infrastructure;
|
||||||
|
|
||||||
|
namespace SVSim.UnitTests.Controllers;
|
||||||
|
|
||||||
|
public class BattlePassControllerInfoTests
|
||||||
|
{
|
||||||
|
private static StringContent JsonBody(string json) => new(json, Encoding.UTF8, "application/json");
|
||||||
|
|
||||||
|
private const string EmptyAuthBody = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":""}""";
|
||||||
|
|
||||||
|
private static async Task SeedSeason23WithRewards(SVSimTestFactory f)
|
||||||
|
{
|
||||||
|
using var scope = f.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||||
|
if (await db.BattlePassLevels.CountAsync() == 0)
|
||||||
|
{
|
||||||
|
for (int i = 1; i <= 100; i++)
|
||||||
|
db.BattlePassLevels.Add(new BattlePassLevelEntry { Level = i, RequiredPoint = (i - 1) * 500 });
|
||||||
|
}
|
||||||
|
db.BattlePassSeasons.Add(new BattlePassSeasonEntry
|
||||||
|
{
|
||||||
|
Id = 23, Name = "Season 23", MaxLevel = 100,
|
||||||
|
StartDate = DateTimeOffset.UtcNow.AddDays(-30),
|
||||||
|
EndDate = DateTimeOffset.UtcNow.AddDays(30),
|
||||||
|
CanPurchase = true, PriceCrystal = 980, Description = "test",
|
||||||
|
});
|
||||||
|
db.BattlePassRewards.Add(new BattlePassRewardEntry
|
||||||
|
{
|
||||||
|
Id = 23 * 10_000L + 0 * 1_000 + 2, // MakeId(23, Normal=0, 2)
|
||||||
|
SeasonId = 23, Track = BattlePassTrack.Normal, Level = 2, RewardType = 9,
|
||||||
|
RewardDetailId = 0, RewardNumber = 50, IsAppealExclusion = false,
|
||||||
|
});
|
||||||
|
db.BattlePassRewards.Add(new BattlePassRewardEntry
|
||||||
|
{
|
||||||
|
Id = 23 * 10_000L + 1 * 1_000 + 2, // MakeId(23, Premium=1, 2)
|
||||||
|
SeasonId = 23, Track = BattlePassTrack.Premium, Level = 2, RewardType = 9,
|
||||||
|
RewardDetailId = 0, RewardNumber = 20, IsAppealExclusion = false,
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Info_returns_season_with_zero_progress_for_fresh_viewer()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
long viewerId = await factory.SeedViewerAsync();
|
||||||
|
await SeedSeason23WithRewards(factory);
|
||||||
|
|
||||||
|
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||||
|
var response = await client.PostAsync("/battle_pass/info", JsonBody(EmptyAuthBody));
|
||||||
|
var body = await response.Content.ReadAsStringAsync();
|
||||||
|
|
||||||
|
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
|
||||||
|
using var doc = JsonDocument.Parse(body);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
|
||||||
|
Assert.That(root.GetProperty("season_info").GetProperty("id").GetString(), Is.EqualTo("23"));
|
||||||
|
Assert.That(root.GetProperty("season_info").GetProperty("max_level").GetString(), Is.EqualTo("100"));
|
||||||
|
Assert.That(root.GetProperty("season_info").GetProperty("can_purchase").GetBoolean(), Is.True);
|
||||||
|
Assert.That(root.GetProperty("gauge_info").GetProperty("current_point").GetString(), Is.EqualTo("0"));
|
||||||
|
Assert.That(root.GetProperty("gauge_info").GetProperty("current_level").GetString(), Is.EqualTo("1"));
|
||||||
|
|
||||||
|
var normalRewards = root.GetProperty("reward_info").GetProperty("normal").GetProperty("reward");
|
||||||
|
Assert.That(normalRewards.GetArrayLength(), Is.EqualTo(1));
|
||||||
|
Assert.That(normalRewards[0].GetProperty("reward_level").GetString(), Is.EqualTo("2"));
|
||||||
|
Assert.That(normalRewards[0].GetProperty("is_received").GetBoolean(), Is.False);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Info_marks_claimed_rewards_as_received()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
long viewerId = await factory.SeedViewerAsync();
|
||||||
|
await SeedSeason23WithRewards(factory);
|
||||||
|
|
||||||
|
using (var scope = factory.Services.CreateScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||||
|
db.ViewerBattlePassClaims.Add(new ViewerBattlePassClaimEntry
|
||||||
|
{
|
||||||
|
ViewerId = viewerId, SeasonId = 23, Track = BattlePassTrack.Normal,
|
||||||
|
Level = 2, ClaimedAt = DateTimeOffset.UtcNow,
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||||
|
var response = await client.PostAsync("/battle_pass/info", JsonBody(EmptyAuthBody));
|
||||||
|
var body = await response.Content.ReadAsStringAsync();
|
||||||
|
using var doc = JsonDocument.Parse(body);
|
||||||
|
|
||||||
|
var normalReward = doc.RootElement
|
||||||
|
.GetProperty("reward_info").GetProperty("normal").GetProperty("reward")[0];
|
||||||
|
var premiumReward = doc.RootElement
|
||||||
|
.GetProperty("reward_info").GetProperty("premium").GetProperty("reward")[0];
|
||||||
|
Assert.That(normalReward.GetProperty("is_received").GetBoolean(), Is.True);
|
||||||
|
Assert.That(premiumReward.GetProperty("is_received").GetBoolean(), Is.False);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Info_returns_empty_payload_outside_any_season_window()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
long viewerId = await factory.SeedViewerAsync();
|
||||||
|
// No season seeded.
|
||||||
|
|
||||||
|
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||||
|
var response = await client.PostAsync("/battle_pass/info", JsonBody(EmptyAuthBody));
|
||||||
|
var body = await response.Content.ReadAsStringAsync();
|
||||||
|
|
||||||
|
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
|
||||||
|
Assert.That(body, Does.Not.Contain("season_info").Or.Contains("\"season_info\":null"),
|
||||||
|
"off-season response should omit or null season_info");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user