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);