From ef440bedaa080d7985d6f09907bfa644fa3b2276 Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Tue, 2 Jun 2026 17:14:39 +0300 Subject: [PATCH] Add game configuration management: implement endpoints for retrieving and updating discovered game configurations; enhance database schema and UI integration --- .../Controllers/Admin/AdminController.cs | 13 + DorkNet.Server/Data/DorkNetDbContext.cs | 1 + .../Data/Entities/ServerSettingsEntity.cs | 6 + .../Services/ServerSettingsService.cs | 142 ++++++++- DorkNet.Server/Startup/DatabaseBootstrap.cs | 14 + .../admin-ui/src/pages/Settings.tsx | 294 +++++++++++++++++- 6 files changed, 457 insertions(+), 13 deletions(-) diff --git a/DorkNet.Server/Controllers/Admin/AdminController.cs b/DorkNet.Server/Controllers/Admin/AdminController.cs index b4d8d5c..d1cd777 100644 --- a/DorkNet.Server/Controllers/Admin/AdminController.cs +++ b/DorkNet.Server/Controllers/Admin/AdminController.cs @@ -1531,6 +1531,19 @@ public class AdminController( return Ok(doors); } + [HttpGet("settings/gameconfigs")] + public async Task GetDiscoveredGameConfigs() + => Ok(await serverSettings.GetDiscoveredGameConfigsAsync()); + + [HttpPost("settings/gameconfigs")] + public async Task SetDiscoveredGameConfigs([FromBody] DiscoveredGameConfigSettings body) + { + var settings = await serverSettings.SetDiscoveredGameConfigsAsync(body); + await LogAsync("gameconfigs_updated", "system", 0, "discovered"); + await db.SaveChangesAsync(); + return Ok(settings); + } + // ── Signup codes ───────────────────────────────────────────────────── /// GET api/admin/v1/signup-codes — every issued code diff --git a/DorkNet.Server/Data/DorkNetDbContext.cs b/DorkNet.Server/Data/DorkNetDbContext.cs index 7f9b87d..aa9f864 100644 --- a/DorkNet.Server/Data/DorkNetDbContext.cs +++ b/DorkNet.Server/Data/DorkNetDbContext.cs @@ -611,6 +611,7 @@ public class DorkNetDbContext(DbContextOptions options) : DbCo e.Property(s => s.WeeklyChallengeRewardJson).HasDefaultValue(string.Empty); e.Property(s => s.PlayMenuTagsJson).HasDefaultValue(string.Empty); e.Property(s => s.RecCenterDoorsJson).HasDefaultValue(string.Empty); + e.Property(s => s.DiscoveredGameConfigsJson).HasDefaultValue(string.Empty); }); modelBuilder.Entity(e => diff --git a/DorkNet.Server/Data/Entities/ServerSettingsEntity.cs b/DorkNet.Server/Data/Entities/ServerSettingsEntity.cs index 0404a4c..d090142 100644 --- a/DorkNet.Server/Data/Entities/ServerSettingsEntity.cs +++ b/DorkNet.Server/Data/Entities/ServerSettingsEntity.cs @@ -48,5 +48,11 @@ public class ServerSettingsEntity /// Door.{Category}.Title and Door.{Category}.Query entries. public string RecCenterDoorsJson { get; set; } = string.Empty; + /// Admin-managed values for GameConfig keys confirmed in + /// the 2020.12 decomp. Kept typed in the admin API; stored as JSON + /// here so newly discovered built-in keys do not require a table + /// migration each time. + public string DiscoveredGameConfigsJson { get; set; } = string.Empty; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; } diff --git a/DorkNet.Server/Services/ServerSettingsService.cs b/DorkNet.Server/Services/ServerSettingsService.cs index e01839b..2ac0b86 100644 --- a/DorkNet.Server/Services/ServerSettingsService.cs +++ b/DorkNet.Server/Services/ServerSettingsService.cs @@ -66,10 +66,18 @@ public class ServerSettingsService(DorkNetDbContext db) return ToRecCenterDoors(row); } + public async Task GetDiscoveredGameConfigsAsync() + { + var row = await GetAsync(); + return ToDiscoveredGameConfigs(row); + } + public async Task> GetGameConfigurationsAsync() { - var doors = await GetRecCenterDoorsAsync(); - return ToGameConfigurations(doors); + var row = await GetAsync(); + return ToGameConfigurations( + ToRecCenterDoors(row), + ToDiscoveredGameConfigs(row)); } public async Task SetPlayMenuTagsAsync( @@ -95,6 +103,17 @@ public class ServerSettingsService(DorkNetDbContext db) return normalized with { UpdatedAt = existing.UpdatedAt }; } + public async Task SetDiscoveredGameConfigsAsync( + DiscoveredGameConfigSettings settings) + { + var normalized = NormalizeDiscoveredGameConfigs(settings); + var existing = await GetTrackedRowAsync(); + existing.DiscoveredGameConfigsJson = 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, @@ -209,6 +228,28 @@ public class ServerSettingsService(DorkNetDbContext db) return DefaultRecCenterDoors() with { UpdatedAt = row.UpdatedAt }; } + private static DiscoveredGameConfigSettings ToDiscoveredGameConfigs(ServerSettingsEntity row) + { + if (!string.IsNullOrWhiteSpace(row.DiscoveredGameConfigsJson)) + { + try + { + var parsed = JsonSerializer.Deserialize( + row.DiscoveredGameConfigsJson, + JsonOptions); + if (parsed is not null) + return NormalizeDiscoveredGameConfigs(parsed) + with { UpdatedAt = row.UpdatedAt }; + } + catch + { + // Bad settings should not break gameconfig bootstrap. + } + } + + return DefaultDiscoveredGameConfigs() with { UpdatedAt = row.UpdatedAt }; + } + private static List NormalizeWeeklyChallenges( IReadOnlyList challenges) { @@ -323,8 +364,27 @@ public class ServerSettingsService(DorkNetDbContext db) ], UpdatedAt: DateTime.UtcNow); + public static DiscoveredGameConfigSettings DefaultDiscoveredGameConfigs() => + new( + FriendsPostGamePromptUnderFriendCount: 3, + FriendsSuggestFriendCodeOnFriendsScreenCount: 5, + ScreensForceVerification: false, + VrForceVerification: false, + RewardsUseRewardSelection: false, + RewardsSelectionTimeout: 30, + RoomDetailsPhotoRollEnabled: false, + LoadingNetworkTimeout: 30, + RunningNetworkTimeout: 30, + SynchronizedFieldRemoveDefaultEntries: false, + RenderingDisableSrpBatcher: false, + SplitTestSoftOverrides: "{}", + SplitTestHardOverrides: "{}", + SplitTestSegmentProbabilities: "{}", + UpdatedAt: DateTime.UtcNow); + public static IReadOnlyList ToGameConfigurations( - RecCenterDoorSettings doors) + RecCenterDoorSettings doors, + DiscoveredGameConfigSettings discovered) { var rows = new List(); foreach (var door in NormalizeRecCenterDoors(doors.Doors).Doors) @@ -333,16 +393,22 @@ public class ServerSettingsService(DorkNetDbContext db) rows.Add(new($"Door.{door.Key}.Query", door.Query)); } + var gameConfig = NormalizeDiscoveredGameConfigs(discovered); 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"), + new("Friends.PostGamePromptUnderFriendCount", gameConfig.FriendsPostGamePromptUnderFriendCount.ToString()), + new("Friends.SuggestFriendCodeOnFriendsScreenCount", gameConfig.FriendsSuggestFriendCodeOnFriendsScreenCount.ToString()), + new("Screens.ForceVerification", gameConfig.ScreensForceVerification ? "1" : "0"), + new("VR.ForceVerification", gameConfig.VrForceVerification ? "1" : "0"), + new("Rewards.UseRewardSelection", gameConfig.RewardsUseRewardSelection.ToString().ToLowerInvariant()), + new("Rewards.SelectionTimeout", gameConfig.RewardsSelectionTimeout.ToString()), + new("RoomDetails.PhotoRollEnabled", gameConfig.RoomDetailsPhotoRollEnabled.ToString().ToLowerInvariant()), + new("loadingNetworkTimeout", gameConfig.LoadingNetworkTimeout.ToString(System.Globalization.CultureInfo.InvariantCulture)), + new("runningNetworkTimeout", gameConfig.RunningNetworkTimeout.ToString(System.Globalization.CultureInfo.InvariantCulture)), + new("SynchronizedField.RemoveDefaultEntries", gameConfig.SynchronizedFieldRemoveDefaultEntries.ToString().ToLowerInvariant()), + new("Rendering.DisableSrpBatcher", gameConfig.RenderingDisableSrpBatcher.ToString().ToLowerInvariant()), + new("splitTestSoftOverrides", gameConfig.SplitTestSoftOverrides), + new("splitTestHardOverrides", gameConfig.SplitTestHardOverrides), + new("splitTestSegmentProbabilities", gameConfig.SplitTestSegmentProbabilities), ]); return rows; } @@ -415,6 +481,41 @@ public class ServerSettingsService(DorkNetDbContext db) normalized.Count > 0 ? normalized : DefaultRecCenterDoors().Doors, DateTime.UtcNow); } + + private static DiscoveredGameConfigSettings NormalizeDiscoveredGameConfigs( + DiscoveredGameConfigSettings settings) + { + static string JsonObjectOrDefault(string value) + { + value = string.IsNullOrWhiteSpace(value) ? "{}" : value.Trim(); + try + { + using var doc = JsonDocument.Parse(value); + return doc.RootElement.ValueKind == JsonValueKind.Object ? value : "{}"; + } + catch + { + return "{}"; + } + } + + return new DiscoveredGameConfigSettings( + FriendsPostGamePromptUnderFriendCount: Math.Clamp(settings.FriendsPostGamePromptUnderFriendCount, 0, 100), + FriendsSuggestFriendCodeOnFriendsScreenCount: Math.Clamp(settings.FriendsSuggestFriendCodeOnFriendsScreenCount, 0, 100), + ScreensForceVerification: settings.ScreensForceVerification, + VrForceVerification: settings.VrForceVerification, + RewardsUseRewardSelection: settings.RewardsUseRewardSelection, + RewardsSelectionTimeout: Math.Clamp(settings.RewardsSelectionTimeout, 0, 300), + RoomDetailsPhotoRollEnabled: settings.RoomDetailsPhotoRollEnabled, + LoadingNetworkTimeout: Math.Clamp(settings.LoadingNetworkTimeout, 1, 300), + RunningNetworkTimeout: Math.Clamp(settings.RunningNetworkTimeout, 1, 300), + SynchronizedFieldRemoveDefaultEntries: settings.SynchronizedFieldRemoveDefaultEntries, + RenderingDisableSrpBatcher: settings.RenderingDisableSrpBatcher, + SplitTestSoftOverrides: JsonObjectOrDefault(settings.SplitTestSoftOverrides), + SplitTestHardOverrides: JsonObjectOrDefault(settings.SplitTestHardOverrides), + SplitTestSegmentProbabilities: JsonObjectOrDefault(settings.SplitTestSegmentProbabilities), + UpdatedAt: DateTime.UtcNow); + } } public sealed record WeeklyChallengeSettings( @@ -461,3 +562,20 @@ public sealed record GameConfigurationSetting( string Value, DateTime? StartTime = null, DateTime? EndTime = null); + +public sealed record DiscoveredGameConfigSettings( + int FriendsPostGamePromptUnderFriendCount, + int FriendsSuggestFriendCodeOnFriendsScreenCount, + bool ScreensForceVerification, + bool VrForceVerification, + bool RewardsUseRewardSelection, + int RewardsSelectionTimeout, + bool RoomDetailsPhotoRollEnabled, + float LoadingNetworkTimeout, + float RunningNetworkTimeout, + bool SynchronizedFieldRemoveDefaultEntries, + bool RenderingDisableSrpBatcher, + string SplitTestSoftOverrides, + string SplitTestHardOverrides, + string SplitTestSegmentProbabilities, + DateTime UpdatedAt); diff --git a/DorkNet.Server/Startup/DatabaseBootstrap.cs b/DorkNet.Server/Startup/DatabaseBootstrap.cs index 5172866..67cde5a 100644 --- a/DorkNet.Server/Startup/DatabaseBootstrap.cs +++ b/DorkNet.Server/Startup/DatabaseBootstrap.cs @@ -246,6 +246,8 @@ public static class DatabaseBootstrap @"ALTER TABLE ""ServerSettings"" ADD COLUMN IF NOT EXISTS ""PlayMenuTagsJson"" text NOT NULL DEFAULT '';"); await db.Database.ExecuteSqlRawAsync( @"ALTER TABLE ""ServerSettings"" ADD COLUMN IF NOT EXISTS ""RecCenterDoorsJson"" text NOT NULL DEFAULT '';"); + await db.Database.ExecuteSqlRawAsync( + @"ALTER TABLE ""ServerSettings"" ADD COLUMN IF NOT EXISTS ""DiscoveredGameConfigsJson"" text NOT NULL DEFAULT '';"); return; } @@ -272,6 +274,18 @@ public static class DatabaseBootstrap // ignore the duplicate-column error; any other schema problem // will surface when EF reads ServerSettings. } + + try + { + await db.Database.ExecuteSqlRawAsync( + @"ALTER TABLE ""ServerSettings"" ADD COLUMN ""DiscoveredGameConfigsJson"" TEXT NOT NULL DEFAULT '';"); + } + catch + { + // SQLite has no ADD COLUMN IF NOT EXISTS. If it already exists, + // ignore the duplicate-column error; any other schema problem + // will surface when EF reads ServerSettings. + } } /// SQLite migration-history reconciliation. Handles two diff --git a/DorkNet.Server/admin-ui/src/pages/Settings.tsx b/DorkNet.Server/admin-ui/src/pages/Settings.tsx index 8d24c67..aec305d 100644 --- a/DorkNet.Server/admin-ui/src/pages/Settings.tsx +++ b/DorkNet.Server/admin-ui/src/pages/Settings.tsx @@ -54,9 +54,28 @@ interface RewardOptions { equipment: RewardOption[]; } +interface DiscoveredGameConfigSettings { + friendsPostGamePromptUnderFriendCount: number; + friendsSuggestFriendCodeOnFriendsScreenCount: number; + screensForceVerification: boolean; + vrForceVerification: boolean; + rewardsUseRewardSelection: boolean; + rewardsSelectionTimeout: number; + roomDetailsPhotoRollEnabled: boolean; + loadingNetworkTimeout: number; + runningNetworkTimeout: number; + synchronizedFieldRemoveDefaultEntries: boolean; + renderingDisableSrpBatcher: boolean; + splitTestSoftOverrides: string; + splitTestHardOverrides: string; + splitTestSegmentProbabilities: string; + updatedAt: string; +} + export function Settings({ embedded }: { embedded?: boolean } = {}) { const [settings, setSettings] = useState(null); const [weekly, setWeekly] = useState(null); + const [gameConfigs, setGameConfigs] = useState(null); const [rewardOptions, setRewardOptions] = useState({ avatarItems: [], consumables: [], @@ -65,18 +84,21 @@ export function Settings({ embedded }: { embedded?: boolean } = {}) { const [err, setErr] = useState(null); const [busy, setBusy] = useState(false); const [weeklyBusy, setWeeklyBusy] = useState(false); + const [gameConfigBusy, setGameConfigBusy] = useState(false); const toast = useToast(); const load = async () => { try { - const [s, w, rewards] = await Promise.all([ + const [s, w, rewards, configs] = await Promise.all([ get('/settings'), get('/settings/weekly-challenges'), get('/settings/weekly-challenges/reward-options'), + get('/settings/gameconfigs'), ]); setSettings(s); setWeekly(w); setRewardOptions(rewards); + setGameConfigs(configs); setErr(null); } catch (e) { setErr((e as Error).message); @@ -252,6 +274,28 @@ export function Settings({ embedded }: { embedded?: boolean } = {}) { } }; + const updateGameConfigs = (patch: Partial) => { + setGameConfigs((current) => current ? { ...current, ...patch } : current); + }; + + const saveGameConfigs = async (reset = false) => { + if (!gameConfigs && !reset) return; + setGameConfigBusy(true); + try { + const body = reset ? defaultGameConfigs() : gameConfigs; + const updated = await api('/settings/gameconfigs', { + method: 'POST', + body, + }); + setGameConfigs(updated); + toast.push(reset ? 'GameConfig settings reset.' : 'GameConfig settings saved.', 'success'); + } catch (e) { + toast.push((e as Error).message, 'error'); + } finally { + setGameConfigBusy(false); + } + }; + const refreshBtn = ( + + + + +
+
+

Social

+
+ number('friendsPostGamePromptUnderFriendCount', v)} + /> + number('friendsSuggestFriendCodeOnFriendsScreenCount', v)} + /> +
+
+ +
+

Account gates

+
+ onChange({ screensForceVerification: v })} + /> + onChange({ vrForceVerification: v })} + /> +
+
+ +
+

Rewards

+
+ onChange({ rewardsUseRewardSelection: v })} + /> + number('rewardsSelectionTimeout', v)} + /> +
+
+ +
+

Room details

+
+ onChange({ roomDetailsPhotoRollEnabled: v })} + /> +
+
+ +
+

Networking

+
+ number('loadingNetworkTimeout', v)} + /> + number('runningNetworkTimeout', v)} + /> +
+
+ +
+

Rendering / sync

+
+ onChange({ synchronizedFieldRemoveDefaultEntries: v })} + /> + onChange({ renderingDisableSrpBatcher: v })} + /> +
+
+ +
+

Split tests

+
+ onChange({ splitTestSoftOverrides: v })} + /> + onChange({ splitTestHardOverrides: v })} + /> + onChange({ splitTestSegmentProbabilities: v })} + /> +
+
+
+ + ); +} + +function NumberConfig({ + label, + value, + onChange, +}: { + label: string; + value: number; + onChange: (value: string) => void; +}) { + return ( + + ); +} + +function BoolConfig({ + label, + checked, + onChange, +}: { + label: string; + checked: boolean; + onChange: (value: boolean) => void; +}) { + return ( + + ); +} + +function JsonConfig({ + label, + value, + onChange, +}: { + label: string; + value: string; + onChange: (value: string) => void; +}) { + return ( +