feat(missions): mission catalog + viewer mission repositories

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-05-27 10:21:07 -04:00
parent 8fd6bc10c1
commit 6fbf7cbc94
4 changed files with 193 additions and 0 deletions

View File

@@ -0,0 +1,24 @@
using SVSim.Database.Models;
namespace SVSim.Database.Repositories.Mission;
public interface IMissionCatalogRepository
{
Task<List<MissionCatalogEntry>> GetByLotTypeAsync(int lotType, CancellationToken ct);
Task<List<MissionCatalogEntry>> GetByIdsAsync(IReadOnlyCollection<int> ids, CancellationToken ct);
Task<MissionCatalogEntry?> GetByIdAsync(int id, CancellationToken ct);
Task<List<MissionCatalogEntry>> GetByEventTypesAsync(IReadOnlyCollection<string> eventTypes, CancellationToken ct);
Task<List<AchievementCatalogEntry>> GetAchievementsByEventTypesAsync(IReadOnlyCollection<string> eventTypes, CancellationToken ct);
/// <summary>All distinct achievement_type values present in the catalog. Used by /load/index materialization.</summary>
Task<List<int>> GetAllAchievementTypesAsync(CancellationToken ct);
/// <summary>MAX(Level) per achievement_type — cached. Used to compute wire max_level.</summary>
Task<IReadOnlyDictionary<int, int>> GetMaxLevelByAchievementTypeAsync(CancellationToken ct);
/// <summary>Catalog row at (type, level), or null if no such tier has been captured.</summary>
Task<AchievementCatalogEntry?> GetAchievementAsync(int achievementType, int level, CancellationToken ct);
Task<List<BattlePassMonthlyMissionEntry>> GetMonthlyMissionsAsync(int year, int month, CancellationToken ct);
}

View File

@@ -0,0 +1,34 @@
using SVSim.Database.Models;
namespace SVSim.Database.Repositories.Mission;
public interface IViewerMissionRepository
{
Task<List<ViewerMission>> GetMissionsAsync(long viewerId, CancellationToken ct);
Task<ViewerMission?> GetMissionByIdAsync(long viewerId, long missionId, CancellationToken ct);
Task<List<ViewerAchievement>> GetAchievementsAsync(long viewerId, CancellationToken ct);
Task<ViewerAchievement?> GetAchievementAsync(long viewerId, int achievementType, CancellationToken ct);
/// <summary>Reads counter rows for (viewerId, eventKey IN list, period IN list). Empty inputs return [].</summary>
Task<List<ViewerEventCounter>> GetCountersAsync(
long viewerId,
IReadOnlyCollection<string> eventKeys,
IReadOnlyCollection<string> periods,
CancellationToken ct);
/// <summary>Single-row counter read. Returns 0 if no row exists.</summary>
Task<int> GetCounterAsync(long viewerId, string eventKey, string period, CancellationToken ct);
/// <summary>Add a viewer mission row (in-memory; caller saves).</summary>
void AddMission(ViewerMission row);
/// <summary>Remove a viewer mission row (in-memory; caller saves).</summary>
void RemoveMission(ViewerMission row);
/// <summary>Add a viewer achievement row (in-memory; caller saves).</summary>
void AddAchievement(ViewerAchievement row);
/// <summary>Upsert a counter delta (in-memory; caller saves). Creates the row if missing.</summary>
Task UpsertCounterAsync(long viewerId, string eventKey, string period, int delta, CancellationToken ct);
}

View File

@@ -0,0 +1,68 @@
using Microsoft.EntityFrameworkCore;
using SVSim.Database.Models;
namespace SVSim.Database.Repositories.Mission;
public sealed class MissionCatalogRepository : IMissionCatalogRepository
{
private readonly SVSimDbContext _db;
// Process-level cache for the derived MAX(Level) lookup. Cleared on host restart
// (re-bootstrap is the only legitimate way to mutate the catalog at runtime).
private static IReadOnlyDictionary<int, int>? _maxLevelCache;
private static readonly SemaphoreSlim _maxLevelLock = new(1, 1);
public MissionCatalogRepository(SVSimDbContext db) { _db = db; }
public Task<List<MissionCatalogEntry>> GetByLotTypeAsync(int lotType, CancellationToken ct) =>
_db.MissionCatalog.AsNoTracking().Where(e => e.LotType == lotType).ToListAsync(ct);
public Task<List<MissionCatalogEntry>> GetByIdsAsync(IReadOnlyCollection<int> ids, CancellationToken ct) =>
_db.MissionCatalog.AsNoTracking().Where(e => ids.Contains(e.Id)).ToListAsync(ct);
public Task<MissionCatalogEntry?> GetByIdAsync(int id, CancellationToken ct) =>
_db.MissionCatalog.AsNoTracking().FirstOrDefaultAsync(e => e.Id == id, ct);
public Task<List<MissionCatalogEntry>> GetByEventTypesAsync(IReadOnlyCollection<string> eventTypes, CancellationToken ct) =>
_db.MissionCatalog.AsNoTracking()
.Where(e => e.EventType != null && eventTypes.Contains(e.EventType))
.ToListAsync(ct);
public Task<List<AchievementCatalogEntry>> GetAchievementsByEventTypesAsync(IReadOnlyCollection<string> eventTypes, CancellationToken ct) =>
_db.AchievementCatalog.AsNoTracking()
.Where(e => e.EventType != null && eventTypes.Contains(e.EventType))
.ToListAsync(ct);
public Task<List<int>> GetAllAchievementTypesAsync(CancellationToken ct) =>
_db.AchievementCatalog.AsNoTracking()
.Select(e => e.AchievementType).Distinct()
.ToListAsync(ct);
public async Task<IReadOnlyDictionary<int, int>> GetMaxLevelByAchievementTypeAsync(CancellationToken ct)
{
if (_maxLevelCache is not null) return _maxLevelCache;
await _maxLevelLock.WaitAsync(ct);
try
{
if (_maxLevelCache is null)
{
var pairs = await _db.AchievementCatalog.AsNoTracking()
.GroupBy(e => e.AchievementType)
.Select(g => new { Type = g.Key, Max = g.Max(e => e.Level) })
.ToListAsync(ct);
_maxLevelCache = pairs.ToDictionary(p => p.Type, p => p.Max);
}
return _maxLevelCache;
}
finally { _maxLevelLock.Release(); }
}
public Task<AchievementCatalogEntry?> GetAchievementAsync(int achievementType, int level, CancellationToken ct) =>
_db.AchievementCatalog.AsNoTracking()
.FirstOrDefaultAsync(e => e.AchievementType == achievementType && e.Level == level, ct);
public Task<List<BattlePassMonthlyMissionEntry>> GetMonthlyMissionsAsync(int year, int month, CancellationToken ct) =>
_db.BattlePassMonthlyMissions.AsNoTracking()
.Where(e => e.Year == year && e.Month == month)
.OrderBy(e => e.OrderNum).ToListAsync(ct);
}

View File

@@ -0,0 +1,67 @@
using Microsoft.EntityFrameworkCore;
using SVSim.Database.Models;
namespace SVSim.Database.Repositories.Mission;
public sealed class ViewerMissionRepository : IViewerMissionRepository
{
private readonly SVSimDbContext _db;
public ViewerMissionRepository(SVSimDbContext db) { _db = db; }
public Task<List<ViewerMission>> GetMissionsAsync(long viewerId, CancellationToken ct) =>
_db.ViewerMissions.Where(e => e.ViewerId == viewerId).OrderBy(e => e.Slot).ToListAsync(ct);
public Task<ViewerMission?> GetMissionByIdAsync(long viewerId, long missionId, CancellationToken ct) =>
_db.ViewerMissions.FirstOrDefaultAsync(e => e.ViewerId == viewerId && e.Id == missionId, ct);
public Task<List<ViewerAchievement>> GetAchievementsAsync(long viewerId, CancellationToken ct) =>
_db.ViewerAchievements.Where(e => e.ViewerId == viewerId).ToListAsync(ct);
public Task<ViewerAchievement?> GetAchievementAsync(long viewerId, int achievementType, CancellationToken ct) =>
_db.ViewerAchievements.FirstOrDefaultAsync(
e => e.ViewerId == viewerId && e.AchievementType == achievementType, ct);
public Task<List<ViewerEventCounter>> GetCountersAsync(
long viewerId,
IReadOnlyCollection<string> eventKeys,
IReadOnlyCollection<string> periods,
CancellationToken ct)
{
if (eventKeys.Count == 0 || periods.Count == 0) return Task.FromResult(new List<ViewerEventCounter>());
return _db.ViewerEventCounters.AsNoTracking()
.Where(e => e.ViewerId == viewerId
&& eventKeys.Contains(e.EventKey)
&& periods.Contains(e.Period))
.ToListAsync(ct);
}
public async Task<int> GetCounterAsync(long viewerId, string eventKey, string period, CancellationToken ct)
{
var row = await _db.ViewerEventCounters.AsNoTracking()
.FirstOrDefaultAsync(
e => e.ViewerId == viewerId && e.EventKey == eventKey && e.Period == period, ct);
return row?.Count ?? 0;
}
public void AddMission(ViewerMission row) => _db.ViewerMissions.Add(row);
public void RemoveMission(ViewerMission row) => _db.ViewerMissions.Remove(row);
public void AddAchievement(ViewerAchievement row) => _db.ViewerAchievements.Add(row);
public async Task UpsertCounterAsync(long viewerId, string eventKey, string period, int delta, CancellationToken ct)
{
var row = await _db.ViewerEventCounters.FirstOrDefaultAsync(
e => e.ViewerId == viewerId && e.EventKey == eventKey && e.Period == period, ct);
if (row is null)
{
_db.ViewerEventCounters.Add(new ViewerEventCounter
{
ViewerId = viewerId, EventKey = eventKey, Period = period, Count = delta,
});
}
else
{
row.Count += delta;
}
}
}