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"));
+ }
+}