using DorkNet.Server.Data; using DorkNet.Server.Data.Entities; using Microsoft.EntityFrameworkCore; using System.Text.Json; namespace DorkNet.Server.Services; /// /// Single-row settings store. Same Postgres-backed pattern as /// : every read is one SELECT, /// every write is one UPDATE/INSERT, and the row is visible to every /// replica immediately so admin toggles propagate without a cache /// invalidation dance. /// /// Scoped (not singleton) because it holds a DbContext reference. The /// toggles it backs (signups, etc.) are checked on rare paths — /// account creation hits this once per signup attempt, which is /// dwarfed by everything else the request does. /// public class ServerSettingsService(DorkNetDbContext db) { private const int RowId = 1; private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true, }; public async Task GetAsync() { var row = await db.ServerSettings.AsNoTracking() .FirstOrDefaultAsync(s => s.Id == RowId); return row ?? new ServerSettingsEntity { Id = RowId }; } public async Task AreSignupsDisabledAsync() { var row = await db.ServerSettings.AsNoTracking() .FirstOrDefaultAsync(s => s.Id == RowId); return row?.SignupsDisabled ?? false; } public async Task SetSignupsDisabledAsync(bool disabled) { var existing = await GetTrackedRowAsync(); existing.SignupsDisabled = disabled; existing.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); return existing; } public async Task GetWeeklyChallengesAsync() { var row = await GetAsync(); return ToWeeklySettings(row); } public async Task GetPlayMenuTagsAsync() { var row = await GetAsync(); return ToPlayMenuTags(row); } public async Task GetRecCenterDoorsAsync() { var row = await GetAsync(); return ToRecCenterDoors(row); } public async Task> GetGameConfigurationsAsync() { var doors = await GetRecCenterDoorsAsync(); return ToGameConfigurations(doors); } public async Task SetPlayMenuTagsAsync( IReadOnlyList pinned, IReadOnlyList popular) { var normalized = NormalizePlayMenuTags(pinned, popular); var existing = await GetTrackedRowAsync(); existing.PlayMenuTagsJson = JsonSerializer.Serialize(normalized, JsonOptions); existing.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); return normalized with { UpdatedAt = existing.UpdatedAt }; } public async Task SetRecCenterDoorsAsync( IReadOnlyList doors) { var normalized = NormalizeRecCenterDoors(doors); var existing = await GetTrackedRowAsync(); existing.RecCenterDoorsJson = JsonSerializer.Serialize(normalized, JsonOptions); existing.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); return normalized with { UpdatedAt = existing.UpdatedAt }; } public async Task SetWeeklyChallengesAsync( bool completedRequired, IReadOnlyList challenges, WeeklyChallengeReward? reward) { var normalized = NormalizeWeeklyChallenges(challenges); var normalizedReward = NormalizeWeeklyReward(reward); var existing = await GetTrackedRowAsync(); existing.WeeklyChallengesCompletedRequired = completedRequired; existing.WeeklyChallengesJson = JsonSerializer.Serialize(normalized, JsonOptions); existing.WeeklyChallengeRewardJson = JsonSerializer.Serialize(normalizedReward, JsonOptions); existing.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); return ToWeeklySettings(existing); } private async Task GetTrackedRowAsync() { var existing = await db.ServerSettings.FirstOrDefaultAsync(s => s.Id == RowId); if (existing is not null) return existing; existing = new ServerSettingsEntity { Id = RowId, UpdatedAt = DateTime.UtcNow }; db.ServerSettings.Add(existing); return existing; } private static WeeklyChallengeSettings ToWeeklySettings(ServerSettingsEntity row) { var challenges = DefaultWeeklyChallenges(); var reward = DefaultWeeklyReward(); if (!string.IsNullOrWhiteSpace(row.WeeklyChallengesJson)) { try { var parsed = JsonSerializer.Deserialize>( row.WeeklyChallengesJson, JsonOptions); if (parsed?.Count > 0) challenges = NormalizeWeeklyChallenges(parsed); } catch { challenges = DefaultWeeklyChallenges(); } } if (!string.IsNullOrWhiteSpace(row.WeeklyChallengeRewardJson)) { try { var parsed = JsonSerializer.Deserialize( row.WeeklyChallengeRewardJson, JsonOptions); reward = NormalizeWeeklyReward(parsed); } catch { reward = DefaultWeeklyReward(); } } return new WeeklyChallengeSettings( row.WeeklyChallengesCompletedRequired, challenges, reward, row.UpdatedAt); } private static PlayMenuTagSettings ToPlayMenuTags(ServerSettingsEntity row) { if (!string.IsNullOrWhiteSpace(row.PlayMenuTagsJson)) { try { var parsed = JsonSerializer.Deserialize( row.PlayMenuTagsJson, JsonOptions); if (parsed is not null) return NormalizePlayMenuTags(parsed.PinnedTags, parsed.PopularTags) with { UpdatedAt = row.UpdatedAt }; } catch { // Bad settings should not break the Play menu; admins can // overwrite them from the SPA. } } return DefaultPlayMenuTags() with { UpdatedAt = row.UpdatedAt }; } private static RecCenterDoorSettings ToRecCenterDoors(ServerSettingsEntity row) { if (!string.IsNullOrWhiteSpace(row.RecCenterDoorsJson)) { try { var parsed = JsonSerializer.Deserialize( row.RecCenterDoorsJson, JsonOptions); if (parsed is not null) return NormalizeRecCenterDoors(parsed.Doors) with { UpdatedAt = row.UpdatedAt }; } catch { // Bad settings should not break Rec Center startup; admins // can overwrite them from the SPA. } } return DefaultRecCenterDoors() with { UpdatedAt = row.UpdatedAt }; } private static List NormalizeWeeklyChallenges( IReadOnlyList challenges) { var normalized = challenges .Where(c => !string.IsNullOrWhiteSpace(c.Name)) .Take(10) .Select((c, i) => new WeeklyChallengeTemplate( i, c.Name.Trim(), string.IsNullOrWhiteSpace(c.Config) ? "{}" : c.Config.Trim(), c.Description?.Trim() ?? string.Empty, c.Tooltip?.Trim() ?? string.Empty)) .ToList(); return normalized.Count > 0 ? normalized : DefaultWeeklyChallenges(); } private static WeeklyChallengeReward NormalizeWeeklyReward(WeeklyChallengeReward? reward) { reward ??= DefaultWeeklyReward(); return new WeeklyChallengeReward( GiftDropId: Math.Max(0, reward.GiftDropId), Xp: Math.Max(0, reward.Xp), Tokens: Math.Max(0, reward.Tokens), Level: Math.Max(0, reward.Level), GiftContext: Math.Max(0, reward.GiftContext), GiftRarity: Math.Max(0, reward.GiftRarity), AvatarItemDesc: reward.AvatarItemDesc?.Trim() ?? string.Empty, ConsumableItemDesc: reward.ConsumableItemDesc?.Trim() ?? string.Empty, EquipmentPrefabName: reward.EquipmentPrefabName?.Trim() ?? string.Empty, EquipmentModificationGuid: reward.EquipmentModificationGuid?.Trim() ?? string.Empty); } public static List DefaultWeeklyChallenges() => [ new( Index: 0, Name: "Play 3 Rec Room Originals", Config: "{\"Type\":\"CompleteActivity\",\"Goal\":3}", Description: "Complete three Rec Room Original activities.", Tooltip: "Play any RRO activity to make progress."), new( Index: 1, Name: "Cheer 5 Players", Config: "{\"Type\":\"CheerPlayers\",\"Goal\":5}", Description: "Send cheers to five players.", Tooltip: "Use the watch to cheer players you meet."), new( Index: 2, Name: "Visit 5 Rooms", Config: "{\"Type\":\"VisitRooms\",\"Goal\":5}", Description: "Visit five rooms this week.", Tooltip: "Any room visit counts toward this challenge."), ]; public static WeeklyChallengeReward DefaultWeeklyReward() => new( GiftDropId: 0, Xp: 250, Tokens: 250, Level: 1, GiftContext: 0, GiftRarity: 0, AvatarItemDesc: string.Empty, ConsumableItemDesc: string.Empty, EquipmentPrefabName: string.Empty, EquipmentModificationGuid: string.Empty); public static PlayMenuTagSettings DefaultPlayMenuTags() => new( PinnedTags: [ "community", "recroomoriginal", "featured", "quest", "sport", "template", "hangout", "creative", ], PopularTags: [ "paintball", "dodgeball", "soccer", "lasertag", "recroyale", "discgolf", "charades", "bowling", "paddleball", "stuntrunner", "makerpen", "pvp", "co-op", "chill", "music", "parkour", ], UpdatedAt: DateTime.UtcNow); public static RecCenterDoorSettings DefaultRecCenterDoors() => new( Doors: [ new("Shooters", "Shooters", "#paintball|#lasertag|#recroyale"), new("Creative", "Creative", "#creative|#makerpen|#template"), new("Quests", "Quests", "#quest"), new("Sports", "Sports", "#sport"), new("Featured", "Featured", "#featured|#recroomoriginal"), ], UpdatedAt: DateTime.UtcNow); public static IReadOnlyList ToGameConfigurations( RecCenterDoorSettings doors) { var rows = new List(); foreach (var door in NormalizeRecCenterDoors(doors.Doors).Doors) { rows.Add(new($"Door.{door.Key}.Title", door.Title)); rows.Add(new($"Door.{door.Key}.Query", door.Query)); } rows.AddRange([ new("Rewards.UseRewardSelection", "false"), new("RoomDetails.PhotoRollEnabled", "false"), new("SynchronizedField.RemoveDefaultEntries", "false"), new("Rendering.DisableSrpBatcher", "false"), new("splitTestSoftOverrides", "{}"), new("splitTestHardOverrides", "{}"), new("splitTestSegmentProbabilities", "{}"), new("loadingNetworkTimeout", "30"), new("runningNetworkTimeout", "30"), ]); return rows; } private static PlayMenuTagSettings NormalizePlayMenuTags( IReadOnlyList pinned, IReadOnlyList popular) { static List Normalize(IReadOnlyList tags, int max) { var seen = new HashSet(StringComparer.OrdinalIgnoreCase); return tags .Select(t => (t ?? string.Empty).Trim().TrimStart('#').ToLowerInvariant()) .Where(t => t.Length > 0) .Where(t => t.All(c => char.IsLetterOrDigit(c) || c is '-' or '_')) .Where(seen.Add) .Take(max) .ToList(); } var defaults = DefaultPlayMenuTags(); var pinnedTags = Normalize(pinned, 16); var popularTags = Normalize(popular, 32); return new PlayMenuTagSettings( pinnedTags.Count > 0 ? pinnedTags : defaults.PinnedTags, popularTags.Count > 0 ? popularTags : defaults.PopularTags, DateTime.UtcNow); } private static RecCenterDoorSettings NormalizeRecCenterDoors( IReadOnlyList doors) { static string NormalizeKey(string value) { var clean = new string((value ?? string.Empty) .Where(char.IsLetterOrDigit) .ToArray()); return string.IsNullOrWhiteSpace(clean) ? string.Empty : clean; } static string NormalizeQuery(string value) { var tags = (value ?? string.Empty) .Split(['|', ',', '\n', '\r', ' ', '\t'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Select(t => t.Trim().TrimStart('#').ToLowerInvariant()) .Where(t => t.Length > 0) .Where(t => t.All(c => char.IsLetterOrDigit(c) || c is '-' or '_')) .Select(t => $"#{t}") .Distinct(StringComparer.OrdinalIgnoreCase) .Take(12) .ToList(); return string.Join("|", tags); } var normalized = doors .Select(d => { var key = NormalizeKey(d.Key); var title = string.IsNullOrWhiteSpace(d.Title) ? key : d.Title.Trim(); var query = NormalizeQuery(d.Query); return new RecCenterDoorConfig(key, title, query); }) .Where(d => d.Key.Length > 0 && d.Query.Length > 0) .GroupBy(d => d.Key, StringComparer.OrdinalIgnoreCase) .Select(g => g.First()) .Take(8) .ToList(); return new RecCenterDoorSettings( normalized.Count > 0 ? normalized : DefaultRecCenterDoors().Doors, DateTime.UtcNow); } } public sealed record WeeklyChallengeSettings( bool CompletedRequired, IReadOnlyList Challenges, WeeklyChallengeReward Reward, DateTime UpdatedAt); public sealed record WeeklyChallengeTemplate( int Index, string Name, string Config, string Description, string Tooltip); public sealed record WeeklyChallengeReward( int GiftDropId, int Xp, int Tokens, int Level, int GiftContext, int GiftRarity, string AvatarItemDesc, string ConsumableItemDesc, string EquipmentPrefabName, string EquipmentModificationGuid); public sealed record PlayMenuTagSettings( List PinnedTags, List PopularTags, DateTime UpdatedAt); public sealed record RecCenterDoorSettings( List Doors, DateTime UpdatedAt); public sealed record RecCenterDoorConfig( string Key, string Title, string Query); public sealed record GameConfigurationSetting( string Key, string Value, DateTime? StartTime = null, DateTime? EndTime = null);