using LANCommander.Server.Data; using LANCommander.Server.Data.Models; using LANCommander.Server.Services.Extensions; using Microsoft.AspNetCore.Http; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using ZiggyCreatures.Caching.Fusion; namespace LANCommander.Server.Services { public class GameVersionService( ILogger logger, SettingsProvider settingsProvider, IFusionCache cache, IHttpContextAccessor httpContextAccessor, IDbContextFactory contextFactory) : BaseDatabaseService(logger, settingsProvider, cache, httpContextAccessor, contextFactory) { public override async Task AddAsync(GameVersion entity) { await cache.ExpireGameCacheAsync(entity.GameId); return await base.AddAsync(entity, async context => { await context.UpdateRelationshipAsync(v => v.Game); }); } public override async Task UpdateAsync(GameVersion entity) { await cache.ExpireGameCacheAsync(entity.GameId); return await base.UpdateAsync(entity, async context => { await context.UpdateRelationshipAsync(v => v.Archive); await context.UpdateRelationshipAsync(v => v.Scripts); await context.UpdateRelationshipAsync(v => v.Actions); await context.UpdateRelationshipAsync(v => v.SavePaths); }); } /// /// Returns the newest version for a game using the canonical resolver /// (highest SortOrder, with CreatedOn as a tiebreaker). /// public async Task GetLatestAsync(Guid gameId) { return await Query(q => q .Where(v => v.GameId == gameId) .OrderByDescending(v => v.SortOrder) .ThenByDescending(v => v.CreatedOn)) .Include(v => v.Archive) .Include(v => v.Scripts) .Include(v => v.Actions) .Include(v => v.SavePaths) .FirstOrDefaultAsync(v => true); } /// /// Returns the newest version for a game, creating an empty initial version if the game /// has none yet. Used by the config editors so version-scoped config (Scripts, Actions, /// SavePaths) always has a version to attach to, even before the first archive is uploaded. /// public async Task GetOrCreateLatestAsync(Guid gameId) { var latest = await GetLatestAsync(gameId); if (latest != null) return latest; return await CreateAsync(gameId, string.Empty); } /// /// Returns the id of the newest version for a game, or null if it has none. Lightweight /// companion to for callers that only need to associate an /// entity with the current version and don't need the full version graph loaded. /// public async Task GetLatestIdAsync(Guid gameId) { using var context = await contextFactory.CreateDbContextAsync(); return await context.GameVersions .Where(v => v.GameId == gameId) .OrderByDescending(v => v.SortOrder) .ThenByDescending(v => v.CreatedOn) .Select(v => (Guid?)v.Id) .FirstOrDefaultAsync(); } /// /// Returns the version number of the newest version for a game, or null if it has none. /// Lightweight projection for callers that only need the version string (e.g. to display /// it or to prepopulate an upload form). /// public async Task GetLatestVersionNumberAsync(Guid gameId) { using var context = await contextFactory.CreateDbContextAsync(); return await context.GameVersions .Where(v => v.GameId == gameId) .OrderByDescending(v => v.SortOrder) .ThenByDescending(v => v.CreatedOn) .Select(v => v.Version) .FirstOrDefaultAsync(); } /// /// Returns the id of the newest version for a game, creating an empty initial version if /// none exists yet. Lightweight companion to . /// public async Task GetOrCreateLatestIdAsync(Guid gameId) { var latestId = await GetLatestIdAsync(gameId); if (latestId.HasValue) return latestId.Value; return (await CreateAsync(gameId, string.Empty)).Id; } /// /// Returns the versions newer than the supplied version string, ordered oldest-to-newest. /// If the version is unknown or blank, only the latest version is returned. /// public async Task> GetNewerThanAsync(Guid gameId, string version) { var versions = (await Query(q => q.Where(v => v.GameId == gameId)) .Include(v => v.Archive) .GetAsync(v => v.GameId == gameId)) .OrderBy(v => v.SortOrder) .ThenBy(v => v.CreatedOn) .ToList(); if (versions.Count == 0) return []; if (string.IsNullOrWhiteSpace(version)) return [versions.Last()]; var installed = versions.LastOrDefault(v => v.Version == version); if (installed == null) return [versions.Last()]; return versions .SkipWhile(v => v.Id != installed.Id) .Skip(1) .ToList(); } /// /// Returns every version for a game, newest first (highest SortOrder, CreatedOn tiebreaker), /// with each version's Archive loaded. Used to present the full version list to clients. /// public async Task> GetAllAsync(Guid gameId) { return (await Query(q => q.Where(v => v.GameId == gameId)) .Include(v => v.Archive) .GetAsync(v => v.GameId == gameId)) .OrderByDescending(v => v.SortOrder) .ThenByDescending(v => v.CreatedOn) .ToList(); } /// /// Returns a single version with its full config graph (Archive, Scripts, Actions, /// SavePaths) loaded. Used to build a version-scoped manifest for a specific version. /// public async Task GetWithConfigAsync(Guid versionId) { return await Include(v => v.Archive) .Include(v => v.Scripts) .Include(v => v.Actions) .Include(v => v.SavePaths) .GetAsync(versionId); } /// /// Creates a new version for a game. The new version copies the version-scoped /// configuration (Scripts, Actions, SavePaths) from the current latest version /// into fresh rows so each version owns an independent snapshot of its config. /// public async Task CreateAsync(Guid gameId, string version, string? changelog = null) { using var context = await contextFactory.CreateDbContextAsync(); var nextSortOrder = await context.GameVersions .Where(v => v.GameId == gameId) .Select(v => (int?)v.SortOrder) .MaxAsync() ?? -1; var previous = await context.GameVersions .AsNoTracking() .Include(v => v.Scripts) .Include(v => v.Actions) .Include(v => v.SavePaths) .Where(v => v.GameId == gameId) .OrderByDescending(v => v.SortOrder) .ThenByDescending(v => v.CreatedOn) .FirstOrDefaultAsync(); var gameVersion = new GameVersion { GameId = gameId, Version = version, Changelog = changelog, SortOrder = nextSortOrder + 1, CreatedOn = DateTime.UtcNow, Scripts = previous?.Scripts?.Select(s => CopyScript(s, gameId)).ToList() ?? new List