From b65a4371024133e30a81bec6ad24b0761edf2e97 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Wed, 27 May 2026 10:35:40 -0400 Subject: [PATCH] feat(missions): /mission/info, /mission/retire, /mission/change_receive_setting Three endpoints + 9 integration tests. Captured-data-is-catalog: viewer's achievement Level starts at MIN(Level) per type from the catalog (not 1), so the assembler always has a row to render against. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Mission/IMissionCatalogRepository.cs | 5 + .../Mission/MissionCatalogRepository.cs | 9 + .../Controllers/MissionController.cs | 124 +++++++++ .../MissionChangeReceiveSettingRequest.cs | 13 + .../Dtos/Mission/MissionRetireRequest.cs | 13 + .../Services/ViewerMissionStateService.cs | 10 +- .../Controllers/MissionControllerTests.cs | 253 ++++++++++++++++++ 7 files changed, 423 insertions(+), 4 deletions(-) create mode 100644 SVSim.EmulatedEntrypoint/Controllers/MissionController.cs create mode 100644 SVSim.EmulatedEntrypoint/Models/Dtos/Mission/MissionChangeReceiveSettingRequest.cs create mode 100644 SVSim.EmulatedEntrypoint/Models/Dtos/Mission/MissionRetireRequest.cs create mode 100644 SVSim.UnitTests/Controllers/MissionControllerTests.cs diff --git a/SVSim.Database/Repositories/Mission/IMissionCatalogRepository.cs b/SVSim.Database/Repositories/Mission/IMissionCatalogRepository.cs index cbba699..66c7155 100644 --- a/SVSim.Database/Repositories/Mission/IMissionCatalogRepository.cs +++ b/SVSim.Database/Repositories/Mission/IMissionCatalogRepository.cs @@ -14,6 +14,11 @@ public interface IMissionCatalogRepository /// All distinct achievement_type values present in the catalog. Used by /load/index materialization. Task> GetAllAchievementTypesAsync(CancellationToken ct); + /// MIN(Level) per achievement_type — the "starting tier" for new viewers when the + /// catalog doesn't contain a level-1 row. With our captured-data-is-catalog model, a fresh + /// viewer starts at whatever the lowest captured tier is for that type. + Task> GetMinLevelByAchievementTypeAsync(CancellationToken ct); + /// MAX(Level) per achievement_type — cached. Used to compute wire max_level. Task> GetMaxLevelByAchievementTypeAsync(CancellationToken ct); diff --git a/SVSim.Database/Repositories/Mission/MissionCatalogRepository.cs b/SVSim.Database/Repositories/Mission/MissionCatalogRepository.cs index 68d91b2..90f5538 100644 --- a/SVSim.Database/Repositories/Mission/MissionCatalogRepository.cs +++ b/SVSim.Database/Repositories/Mission/MissionCatalogRepository.cs @@ -57,6 +57,15 @@ public sealed class MissionCatalogRepository : IMissionCatalogRepository finally { _maxLevelLock.Release(); } } + public async Task> GetMinLevelByAchievementTypeAsync(CancellationToken ct) + { + var pairs = await _db.AchievementCatalog.AsNoTracking() + .GroupBy(e => e.AchievementType) + .Select(g => new { Type = g.Key, Min = g.Min(e => e.Level) }) + .ToListAsync(ct); + return pairs.ToDictionary(p => p.Type, p => p.Min); + } + public Task GetAchievementAsync(int achievementType, int level, CancellationToken ct) => _db.AchievementCatalog.AsNoTracking() .FirstOrDefaultAsync(e => e.AchievementType == achievementType && e.Level == level, ct); diff --git a/SVSim.EmulatedEntrypoint/Controllers/MissionController.cs b/SVSim.EmulatedEntrypoint/Controllers/MissionController.cs new file mode 100644 index 0000000..e45fed2 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Controllers/MissionController.cs @@ -0,0 +1,124 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using SVSim.Database; +using SVSim.Database.Models; +using SVSim.Database.Repositories.Mission; +using SVSim.EmulatedEntrypoint.Models.Dtos.Common.Mission; +using SVSim.EmulatedEntrypoint.Models.Dtos.Mission; +using SVSim.EmulatedEntrypoint.Models.Dtos.Requests; +using SVSim.EmulatedEntrypoint.Services; + +namespace SVSim.EmulatedEntrypoint.Controllers; + +/// +/// /mission/* — daily/weekly mission slots + achievement claim flow. Wire shapes mirror +/// MissionInfoDetail.cs + Wizard/Mission*Task.cs. +/// +[Route("mission")] +public class MissionController : SVSimController +{ + private const int RetireCooldownSeconds = 75600; // 21h per capture + private const int FailureResultCode = 2; + + private readonly SVSimDbContext _db; + private readonly IViewerMissionStateService _state; + private readonly IMissionAssembler _assembler; + private readonly IMissionCatalogRepository _catalog; + private readonly IViewerMissionRepository _viewerRepo; + private readonly TimeProvider _time; + + public MissionController( + SVSimDbContext db, + IViewerMissionStateService state, + IMissionAssembler assembler, + IMissionCatalogRepository catalog, + IViewerMissionRepository viewerRepo, + TimeProvider time) + { + _db = db; + _state = state; + _assembler = assembler; + _catalog = catalog; + _viewerRepo = viewerRepo; + _time = time; + } + + [HttpPost("info")] + public async Task Info(BaseRequest request, CancellationToken ct) + { + if (!TryGetViewerId(out long viewerId)) return Unauthorized(); + var viewer = await LoadViewer(viewerId, ct); + + await _state.EnsureCurrentAsync(viewer, ct); + await _db.SaveChangesAsync(ct); + + var dto = await _assembler.BuildAsync(viewer, ct); + return Ok(dto); + } + + [HttpPost("retire")] + public async Task Retire(MissionRetireRequest request, CancellationToken ct) + { + if (!TryGetViewerId(out long viewerId)) return Unauthorized(); + var viewer = await LoadViewer(viewerId, ct); + + var missions = await _viewerRepo.GetMissionsAsync(viewerId, ct); + var target = missions.FirstOrDefault(m => m.Id == request.Id); + if (target is null) + { + return Ok(new { result_code = FailureResultCode }); + } + + var catalogRow = await _catalog.GetByIdAsync(target.MissionCatalogId, ct); + if (catalogRow is null || catalogRow.LotType != 2) + { + return Ok(new { result_code = FailureResultCode }); + } + + var pool = await _catalog.GetByLotTypeAsync(2, ct); + var assignedIds = missions + .Where(m => m.Slot != target.Slot) + .Select(m => m.MissionCatalogId).ToHashSet(); + var candidates = pool.Where(p => p.Id != target.MissionCatalogId && !assignedIds.Contains(p.Id)).ToList(); + if (candidates.Count == 0) + { + return Ok(new { result_code = FailureResultCode }); + } + var pick = candidates[Random.Shared.Next(candidates.Count)]; + + var now = _time.GetUtcNow(); + _viewerRepo.RemoveMission(target); + _viewerRepo.AddMission(new ViewerMission + { + ViewerId = viewerId, + MissionCatalogId = pick.Id, + Slot = target.Slot, + AssignedAt = now.ToUnixTimeSeconds(), + MissionStatus = 1, + }); + viewer.MissionData.MissionChangeTime = now.AddSeconds(RetireCooldownSeconds).UtcDateTime; + await _db.SaveChangesAsync(ct); + + var dto = await _assembler.BuildAsync(viewer, ct); + return Ok(dto); + } + + [HttpPost("change_receive_setting")] + public async Task ChangeReceiveSetting(MissionChangeReceiveSettingRequest request, CancellationToken ct) + { + if (!TryGetViewerId(out long viewerId)) return Unauthorized(); + var viewer = await LoadViewer(viewerId, ct); + + viewer.MissionData.MissionReceiveType = request.MissionReceiveType; + await _db.SaveChangesAsync(ct); + + var dto = await _assembler.BuildAsync(viewer, ct); + return Ok(dto); + } + + private Task LoadViewer(long viewerId, CancellationToken ct) => + _db.Viewers + .Include(v => v.MissionData) + .AsSplitQuery() + .FirstAsync(v => v.Id == viewerId, ct); +} diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Mission/MissionChangeReceiveSettingRequest.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Mission/MissionChangeReceiveSettingRequest.cs new file mode 100644 index 0000000..677a102 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Mission/MissionChangeReceiveSettingRequest.cs @@ -0,0 +1,13 @@ +using System.Text.Json.Serialization; +using MessagePack; +using SVSim.EmulatedEntrypoint.Models.Dtos.Requests; + +namespace SVSim.EmulatedEntrypoint.Models.Dtos.Mission; + +[MessagePackObject] +public class MissionChangeReceiveSettingRequest : BaseRequest +{ + [Key("mission_receive_type")] + [JsonPropertyName("mission_receive_type")] + public int MissionReceiveType { get; set; } +} diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Mission/MissionRetireRequest.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Mission/MissionRetireRequest.cs new file mode 100644 index 0000000..672e1a5 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Mission/MissionRetireRequest.cs @@ -0,0 +1,13 @@ +using System.Text.Json.Serialization; +using MessagePack; +using SVSim.EmulatedEntrypoint.Models.Dtos.Requests; + +namespace SVSim.EmulatedEntrypoint.Models.Dtos.Mission; + +[MessagePackObject] +public class MissionRetireRequest : BaseRequest +{ + [Key("id")] + [JsonPropertyName("id")] + public long Id { get; set; } +} diff --git a/SVSim.EmulatedEntrypoint/Services/ViewerMissionStateService.cs b/SVSim.EmulatedEntrypoint/Services/ViewerMissionStateService.cs index 26c27bd..9e0059d 100644 --- a/SVSim.EmulatedEntrypoint/Services/ViewerMissionStateService.cs +++ b/SVSim.EmulatedEntrypoint/Services/ViewerMissionStateService.cs @@ -38,17 +38,19 @@ public sealed class ViewerMissionStateService : IViewerMissionStateService private async Task MaterializeAchievementsAsync(long viewerId, List existing, CancellationToken ct) { - var catalogTypes = await _catalog.GetAllAchievementTypesAsync(ct); - if (catalogTypes.Count == 0) return; + var minLevelByType = await _catalog.GetMinLevelByAchievementTypeAsync(ct); + if (minLevelByType.Count == 0) return; var existingTypes = existing.Select(a => a.AchievementType).ToHashSet(); - foreach (var type in catalogTypes) + foreach (var (type, minLevel) in minLevelByType) { if (existingTypes.Contains(type)) continue; + // Start at the lowest captured tier — with captured-data-is-catalog, a "fresh" viewer + // is conceptually at whichever tier we first know about for that type. _viewerRepo.AddAchievement(new ViewerAchievement { ViewerId = viewerId, AchievementType = type, - Level = 1, + Level = minLevel, AchievementStatus = 0, NowAchievedLevel = 0, ResultAnnounceSawLevel = 0, diff --git a/SVSim.UnitTests/Controllers/MissionControllerTests.cs b/SVSim.UnitTests/Controllers/MissionControllerTests.cs new file mode 100644 index 0000000..5810b66 --- /dev/null +++ b/SVSim.UnitTests/Controllers/MissionControllerTests.cs @@ -0,0 +1,253 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SVSim.Bootstrap.Importers; +using SVSim.Database; +using SVSim.UnitTests.Infrastructure; + +namespace SVSim.UnitTests.Controllers; + +public class MissionControllerTests +{ + private static string SeedDir => Path.Combine(AppContext.BaseDirectory, "Data", "seeds"); + 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 ImportCatalogs(SVSimTestFactory f) + { + using var scope = f.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await new MissionCatalogImporter().ImportAsync(db, SeedDir); + await new AchievementCatalogImporter().ImportAsync(db, SeedDir); + await new BattlePassMonthlyMissionImporter().ImportAsync(db, SeedDir); + } + + [Test] + public async Task Info_returns_assigned_missions_with_string_typed_lot_type() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + await ImportCatalogs(factory); + + using var client = factory.CreateAuthenticatedClient(viewerId); + var resp = await client.PostAsync("/mission/info", JsonBody(EmptyAuthBody)); + var body = await resp.Content.ReadAsStringAsync(); + Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.OK), body); + + using var doc = JsonDocument.Parse(body); + var root = doc.RootElement; + + var missions = root.GetProperty("user_mission_list"); + Assert.That(missions.GetArrayLength(), Is.EqualTo(4), "expect 1 daily + 3 weekly slots"); + // lot_type must be a JSON STRING per wire shape + Assert.That(missions[0].GetProperty("lot_type").ValueKind, Is.EqualTo(JsonValueKind.String)); + + // Daily mission (id 332) must have default_flag=true + bool foundDaily = false; + foreach (var m in missions.EnumerateArray()) + { + if (m.GetProperty("mission_id").GetInt32() == 332) + { + foundDaily = true; + Assert.That(m.GetProperty("lot_type").GetString(), Is.EqualTo("6")); + Assert.That(m.GetProperty("default_flag").GetBoolean(), Is.True); + } + } + Assert.That(foundDaily, Is.True, "daily mission 332 must be assigned to slot 0"); + } + + [Test] + public async Task Info_returns_achievement_rows_with_derived_max_level() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + await ImportCatalogs(factory); + + using var client = factory.CreateAuthenticatedClient(viewerId); + var resp = await client.PostAsync("/mission/info", JsonBody(EmptyAuthBody)); + resp.EnsureSuccessStatusCode(); + var body = await resp.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(body); + + var achievements = doc.RootElement.GetProperty("user_achievement_list"); + Assert.That(achievements.GetArrayLength(), Is.GreaterThan(0)); + // Type 12 has 2 captured tiers (levels 6, 7) → max_level should be 7 + foreach (var a in achievements.EnumerateArray()) + { + if (a.GetProperty("achievement_type").GetInt32() == 12) + { + Assert.That(a.GetProperty("max_level").GetInt32(), Is.EqualTo(7)); + return; + } + } + Assert.Fail("achievement type 12 not found in response"); + } + + [Test] + public async Task Info_includes_bp_monthly_for_may_2026_when_seeded() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + await ImportCatalogs(factory); + + using var client = factory.CreateAuthenticatedClient(viewerId); + var resp = await client.PostAsync("/mission/info", JsonBody(EmptyAuthBody)); + resp.EnsureSuccessStatusCode(); + var body = await resp.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(body); + + // The seed only has May 2026 BP monthly missions. If the test runs in a different month, + // the block will be omitted. This test asserts that EITHER the block is absent OR has 5 + // entries — both are valid behaviors depending on calendar date. + if (doc.RootElement.TryGetProperty("battle_pass_monthly_mission", out var bp)) + { + Assert.That(bp.GetProperty("mission_list").GetArrayLength(), Is.EqualTo(5)); + } + // If not present, that's also valid: current month isn't in the seed. + } + + [Test] + public async Task Info_can_change_mission_time_is_null_when_change_allowed() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + await ImportCatalogs(factory); + + using var client = factory.CreateAuthenticatedClient(viewerId); + var resp = await client.PostAsync("/mission/info", JsonBody(EmptyAuthBody)); + var body = await resp.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(body); + var root = doc.RootElement; + + Assert.That(root.GetProperty("is_change_mission").GetBoolean(), Is.True); + var cct = root.GetProperty("can_change_mission_time"); + Assert.That(cct.ValueKind, Is.EqualTo(JsonValueKind.Null), + "can_change_mission_time must serialize as explicit JSON null"); + } + + [Test] + public async Task Retire_swaps_weekly_slot_for_new_pool_member() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + await ImportCatalogs(factory); + + using var client = factory.CreateAuthenticatedClient(viewerId); + // First /mission/info to materialize slots + await client.PostAsync("/mission/info", JsonBody(EmptyAuthBody)); + + // Find a weekly mission to retire + long retireId; int originalCatalogId; + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var weekly = await db.ViewerMissions.FirstAsync(m => m.ViewerId == viewerId && m.Slot == 1); + retireId = weekly.Id; + originalCatalogId = weekly.MissionCatalogId; + } + + var retireBody = $$"""{"id":{{retireId}},"viewer_id":"0","steam_id":0,"steam_session_ticket":""}"""; + var resp = await client.PostAsync("/mission/retire", JsonBody(retireBody)); + var body = await resp.Content.ReadAsStringAsync(); + Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.OK), body); + + await using var verifyScope = factory.Services.CreateAsyncScope(); + var db2 = verifyScope.ServiceProvider.GetRequiredService(); + var slot1Now = await db2.ViewerMissions.FirstAsync(m => m.ViewerId == viewerId && m.Slot == 1); + Assert.That(slot1Now.MissionCatalogId, Is.Not.EqualTo(originalCatalogId), + "retire must replace the catalog id with a different one from the pool"); + } + + [Test] + public async Task Retire_sets_can_change_mission_time() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + await ImportCatalogs(factory); + + using var client = factory.CreateAuthenticatedClient(viewerId); + await client.PostAsync("/mission/info", JsonBody(EmptyAuthBody)); + + long retireId; + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var weekly = await db.ViewerMissions.FirstAsync(m => m.ViewerId == viewerId && m.Slot == 1); + retireId = weekly.Id; + } + + var retireBody = $$"""{"id":{{retireId}},"viewer_id":"0","steam_id":0,"steam_session_ticket":""}"""; + var resp = await client.PostAsync("/mission/retire", JsonBody(retireBody)); + var body = await resp.Content.ReadAsStringAsync(); + + using var doc = JsonDocument.Parse(body); + var root = doc.RootElement; + Assert.That(root.GetProperty("is_change_mission").GetBoolean(), Is.False); + Assert.That(root.GetProperty("can_change_mission_time").ValueKind, Is.EqualTo(JsonValueKind.Number)); + } + + [Test] + public async Task Retire_rejects_daily_slot() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + await ImportCatalogs(factory); + + using var client = factory.CreateAuthenticatedClient(viewerId); + await client.PostAsync("/mission/info", JsonBody(EmptyAuthBody)); + + long dailyId; + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var daily = await db.ViewerMissions.FirstAsync(m => m.ViewerId == viewerId && m.Slot == 0); + dailyId = daily.Id; + } + + var retireBody = $$"""{"id":{{dailyId}},"viewer_id":"0","steam_id":0,"steam_session_ticket":""}"""; + var resp = await client.PostAsync("/mission/retire", JsonBody(retireBody)); + var body = await resp.Content.ReadAsStringAsync(); + Assert.That(body, Does.Contain("\"result_code\":2"), + "daily slot retire must fail with result_code = 2"); + } + + [Test] + public async Task Retire_rejects_id_not_owned_by_viewer() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + await ImportCatalogs(factory); + + using var client = factory.CreateAuthenticatedClient(viewerId); + var retireBody = """{"id":999999999,"viewer_id":"0","steam_id":0,"steam_session_ticket":""}"""; + var resp = await client.PostAsync("/mission/retire", JsonBody(retireBody)); + var body = await resp.Content.ReadAsStringAsync(); + Assert.That(body, Does.Contain("\"result_code\":2")); + } + + [Test] + public async Task ChangeReceiveSetting_persists_to_viewer_mission_data() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + await ImportCatalogs(factory); + + using var client = factory.CreateAuthenticatedClient(viewerId); + var changeBody = """{"mission_receive_type":1,"viewer_id":"0","steam_id":0,"steam_session_ticket":""}"""; + var resp = await client.PostAsync("/mission/change_receive_setting", JsonBody(changeBody)); + resp.EnsureSuccessStatusCode(); + + await using var scope = factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var refreshed = await db.Viewers.Include(v => v.MissionData) + .FirstAsync(v => v.Id == viewerId); + Assert.That(refreshed.MissionData.MissionReceiveType, Is.EqualTo(1)); + + var body = await resp.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(body); + Assert.That(doc.RootElement.GetProperty("mission_receive_type").GetString(), Is.EqualTo("1")); + } +}