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) <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-05-27 10:35:40 -04:00
parent 574e9ca58b
commit b65a437102
7 changed files with 423 additions and 4 deletions

View File

@@ -14,6 +14,11 @@ public interface IMissionCatalogRepository
/// <summary>All distinct achievement_type values present in the catalog. Used by /load/index materialization.</summary>
Task<List<int>> GetAllAchievementTypesAsync(CancellationToken ct);
/// <summary>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.</summary>
Task<IReadOnlyDictionary<int, int>> GetMinLevelByAchievementTypeAsync(CancellationToken ct);
/// <summary>MAX(Level) per achievement_type — cached. Used to compute wire max_level.</summary>
Task<IReadOnlyDictionary<int, int>> GetMaxLevelByAchievementTypeAsync(CancellationToken ct);

View File

@@ -57,6 +57,15 @@ public sealed class MissionCatalogRepository : IMissionCatalogRepository
finally { _maxLevelLock.Release(); }
}
public async Task<IReadOnlyDictionary<int, int>> 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<AchievementCatalogEntry?> GetAchievementAsync(int achievementType, int level, CancellationToken ct) =>
_db.AchievementCatalog.AsNoTracking()
.FirstOrDefaultAsync(e => e.AchievementType == achievementType && e.Level == level, ct);

View File

@@ -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;
/// <summary>
/// /mission/* — daily/weekly mission slots + achievement claim flow. Wire shapes mirror
/// MissionInfoDetail.cs + Wizard/Mission*Task.cs.
/// </summary>
[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<IActionResult> 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<IActionResult> 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<IActionResult> 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<Viewer> LoadViewer(long viewerId, CancellationToken ct) =>
_db.Viewers
.Include(v => v.MissionData)
.AsSplitQuery()
.FirstAsync(v => v.Id == viewerId, ct);
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -38,17 +38,19 @@ public sealed class ViewerMissionStateService : IViewerMissionStateService
private async Task MaterializeAchievementsAsync(long viewerId, List<ViewerAchievement> 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,

View File

@@ -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<SVSimDbContext>();
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<SVSimDbContext>();
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<SVSimDbContext>();
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<SVSimDbContext>();
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<SVSimDbContext>();
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<SVSimDbContext>();
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"));
}
}