From 6fbf7cbc9458f2f162fa3ffaa7b8bd88bd6152b5 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Wed, 27 May 2026 10:21:07 -0400 Subject: [PATCH] feat(missions): mission catalog + viewer mission repositories Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Mission/IMissionCatalogRepository.cs | 24 +++++++ .../Mission/IViewerMissionRepository.cs | 34 ++++++++++ .../Mission/MissionCatalogRepository.cs | 68 +++++++++++++++++++ .../Mission/ViewerMissionRepository.cs | 67 ++++++++++++++++++ 4 files changed, 193 insertions(+) create mode 100644 SVSim.Database/Repositories/Mission/IMissionCatalogRepository.cs create mode 100644 SVSim.Database/Repositories/Mission/IViewerMissionRepository.cs create mode 100644 SVSim.Database/Repositories/Mission/MissionCatalogRepository.cs create mode 100644 SVSim.Database/Repositories/Mission/ViewerMissionRepository.cs diff --git a/SVSim.Database/Repositories/Mission/IMissionCatalogRepository.cs b/SVSim.Database/Repositories/Mission/IMissionCatalogRepository.cs new file mode 100644 index 0000000..cbba699 --- /dev/null +++ b/SVSim.Database/Repositories/Mission/IMissionCatalogRepository.cs @@ -0,0 +1,24 @@ +using SVSim.Database.Models; + +namespace SVSim.Database.Repositories.Mission; + +public interface IMissionCatalogRepository +{ + Task> GetByLotTypeAsync(int lotType, CancellationToken ct); + Task> GetByIdsAsync(IReadOnlyCollection ids, CancellationToken ct); + Task GetByIdAsync(int id, CancellationToken ct); + + Task> GetByEventTypesAsync(IReadOnlyCollection eventTypes, CancellationToken ct); + Task> GetAchievementsByEventTypesAsync(IReadOnlyCollection eventTypes, CancellationToken ct); + + /// All distinct achievement_type values present in the catalog. Used by /load/index materialization. + Task> GetAllAchievementTypesAsync(CancellationToken ct); + + /// MAX(Level) per achievement_type — cached. Used to compute wire max_level. + Task> GetMaxLevelByAchievementTypeAsync(CancellationToken ct); + + /// Catalog row at (type, level), or null if no such tier has been captured. + Task GetAchievementAsync(int achievementType, int level, CancellationToken ct); + + Task> GetMonthlyMissionsAsync(int year, int month, CancellationToken ct); +} diff --git a/SVSim.Database/Repositories/Mission/IViewerMissionRepository.cs b/SVSim.Database/Repositories/Mission/IViewerMissionRepository.cs new file mode 100644 index 0000000..436e022 --- /dev/null +++ b/SVSim.Database/Repositories/Mission/IViewerMissionRepository.cs @@ -0,0 +1,34 @@ +using SVSim.Database.Models; + +namespace SVSim.Database.Repositories.Mission; + +public interface IViewerMissionRepository +{ + Task> GetMissionsAsync(long viewerId, CancellationToken ct); + Task GetMissionByIdAsync(long viewerId, long missionId, CancellationToken ct); + + Task> GetAchievementsAsync(long viewerId, CancellationToken ct); + Task GetAchievementAsync(long viewerId, int achievementType, CancellationToken ct); + + /// Reads counter rows for (viewerId, eventKey IN list, period IN list). Empty inputs return []. + Task> GetCountersAsync( + long viewerId, + IReadOnlyCollection eventKeys, + IReadOnlyCollection periods, + CancellationToken ct); + + /// Single-row counter read. Returns 0 if no row exists. + Task GetCounterAsync(long viewerId, string eventKey, string period, CancellationToken ct); + + /// Add a viewer mission row (in-memory; caller saves). + void AddMission(ViewerMission row); + + /// Remove a viewer mission row (in-memory; caller saves). + void RemoveMission(ViewerMission row); + + /// Add a viewer achievement row (in-memory; caller saves). + void AddAchievement(ViewerAchievement row); + + /// Upsert a counter delta (in-memory; caller saves). Creates the row if missing. + Task UpsertCounterAsync(long viewerId, string eventKey, string period, int delta, CancellationToken ct); +} diff --git a/SVSim.Database/Repositories/Mission/MissionCatalogRepository.cs b/SVSim.Database/Repositories/Mission/MissionCatalogRepository.cs new file mode 100644 index 0000000..68d91b2 --- /dev/null +++ b/SVSim.Database/Repositories/Mission/MissionCatalogRepository.cs @@ -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? _maxLevelCache; + private static readonly SemaphoreSlim _maxLevelLock = new(1, 1); + + public MissionCatalogRepository(SVSimDbContext db) { _db = db; } + + public Task> GetByLotTypeAsync(int lotType, CancellationToken ct) => + _db.MissionCatalog.AsNoTracking().Where(e => e.LotType == lotType).ToListAsync(ct); + + public Task> GetByIdsAsync(IReadOnlyCollection ids, CancellationToken ct) => + _db.MissionCatalog.AsNoTracking().Where(e => ids.Contains(e.Id)).ToListAsync(ct); + + public Task GetByIdAsync(int id, CancellationToken ct) => + _db.MissionCatalog.AsNoTracking().FirstOrDefaultAsync(e => e.Id == id, ct); + + public Task> GetByEventTypesAsync(IReadOnlyCollection eventTypes, CancellationToken ct) => + _db.MissionCatalog.AsNoTracking() + .Where(e => e.EventType != null && eventTypes.Contains(e.EventType)) + .ToListAsync(ct); + + public Task> GetAchievementsByEventTypesAsync(IReadOnlyCollection eventTypes, CancellationToken ct) => + _db.AchievementCatalog.AsNoTracking() + .Where(e => e.EventType != null && eventTypes.Contains(e.EventType)) + .ToListAsync(ct); + + public Task> GetAllAchievementTypesAsync(CancellationToken ct) => + _db.AchievementCatalog.AsNoTracking() + .Select(e => e.AchievementType).Distinct() + .ToListAsync(ct); + + public async Task> 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 GetAchievementAsync(int achievementType, int level, CancellationToken ct) => + _db.AchievementCatalog.AsNoTracking() + .FirstOrDefaultAsync(e => e.AchievementType == achievementType && e.Level == level, ct); + + public Task> GetMonthlyMissionsAsync(int year, int month, CancellationToken ct) => + _db.BattlePassMonthlyMissions.AsNoTracking() + .Where(e => e.Year == year && e.Month == month) + .OrderBy(e => e.OrderNum).ToListAsync(ct); +} diff --git a/SVSim.Database/Repositories/Mission/ViewerMissionRepository.cs b/SVSim.Database/Repositories/Mission/ViewerMissionRepository.cs new file mode 100644 index 0000000..bbbec25 --- /dev/null +++ b/SVSim.Database/Repositories/Mission/ViewerMissionRepository.cs @@ -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> GetMissionsAsync(long viewerId, CancellationToken ct) => + _db.ViewerMissions.Where(e => e.ViewerId == viewerId).OrderBy(e => e.Slot).ToListAsync(ct); + + public Task GetMissionByIdAsync(long viewerId, long missionId, CancellationToken ct) => + _db.ViewerMissions.FirstOrDefaultAsync(e => e.ViewerId == viewerId && e.Id == missionId, ct); + + public Task> GetAchievementsAsync(long viewerId, CancellationToken ct) => + _db.ViewerAchievements.Where(e => e.ViewerId == viewerId).ToListAsync(ct); + + public Task GetAchievementAsync(long viewerId, int achievementType, CancellationToken ct) => + _db.ViewerAchievements.FirstOrDefaultAsync( + e => e.ViewerId == viewerId && e.AchievementType == achievementType, ct); + + public Task> GetCountersAsync( + long viewerId, + IReadOnlyCollection eventKeys, + IReadOnlyCollection periods, + CancellationToken ct) + { + if (eventKeys.Count == 0 || periods.Count == 0) return Task.FromResult(new List()); + return _db.ViewerEventCounters.AsNoTracking() + .Where(e => e.ViewerId == viewerId + && eventKeys.Contains(e.EventKey) + && periods.Contains(e.Period)) + .ToListAsync(ct); + } + + public async Task 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; + } + } +}