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:
124
SVSim.EmulatedEntrypoint/Controllers/MissionController.cs
Normal file
124
SVSim.EmulatedEntrypoint/Controllers/MissionController.cs
Normal 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);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user