diff --git a/DorkNet.Server/Controllers/API/GameConfigurations/GameConfigurationsController.cs b/DorkNet.Server/Controllers/API/GameConfigurations/GameConfigurationsController.cs index 156acbe..cfd6d97 100644 --- a/DorkNet.Server/Controllers/API/GameConfigurations/GameConfigurationsController.cs +++ b/DorkNet.Server/Controllers/API/GameConfigurations/GameConfigurationsController.cs @@ -1,9 +1,10 @@ using Microsoft.AspNetCore.Mvc; +using DorkNet.Server.Services; namespace DorkNet.Server.Controllers.API.GameConfigurations; [ApiController] -public class GameConfigurationsController : ControllerBase +public class GameConfigurationsController(ServerSettingsService serverSettings) : ControllerBase { // Actual path used by the 2020 build [HttpGet("api/gameconfigs/v1/all")] @@ -11,5 +12,6 @@ public class GameConfigurationsController : ControllerBase // Legacy path we originally assumed [HttpGet("api/gameconfigurations/v1/all")] [HttpGet("api/gameconfigurations/v1")] - public ActionResult> GetAll() => Ok(new List()); + public async Task>> GetAll() + => Ok(await serverSettings.GetGameConfigurationsAsync()); } diff --git a/DorkNet.Server/Controllers/Admin/AdminController.cs b/DorkNet.Server/Controllers/Admin/AdminController.cs index 8d42b15..b4d8d5c 100644 --- a/DorkNet.Server/Controllers/Admin/AdminController.cs +++ b/DorkNet.Server/Controllers/Admin/AdminController.cs @@ -1514,6 +1514,23 @@ public class AdminController( return Ok(tags); } + [HttpGet("settings/rec-center-doors")] + public async Task GetRecCenterDoors() + => Ok(await serverSettings.GetRecCenterDoorsAsync()); + + public sealed record RecCenterDoorsRequest( + List? Doors); + + [HttpPost("settings/rec-center-doors")] + public async Task SetRecCenterDoors([FromBody] RecCenterDoorsRequest body) + { + var doors = await serverSettings.SetRecCenterDoorsAsync(body.Doors ?? []); + await LogAsync("rec_center_doors_updated", "system", 0, + string.Join(";", doors.Doors.Select(d => $"{d.Key}:{d.Query}"))); + await db.SaveChangesAsync(); + return Ok(doors); + } + // ── 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 5a0eba0..7f9b87d 100644 --- a/DorkNet.Server/Data/DorkNetDbContext.cs +++ b/DorkNet.Server/Data/DorkNetDbContext.cs @@ -610,6 +610,7 @@ public class DorkNetDbContext(DbContextOptions options) : DbCo e.Property(s => s.WeeklyChallengesJson).HasDefaultValue(string.Empty); e.Property(s => s.WeeklyChallengeRewardJson).HasDefaultValue(string.Empty); e.Property(s => s.PlayMenuTagsJson).HasDefaultValue(string.Empty); + e.Property(s => s.RecCenterDoorsJson).HasDefaultValue(string.Empty); }); modelBuilder.Entity(e => diff --git a/DorkNet.Server/Data/Entities/ServerSettingsEntity.cs b/DorkNet.Server/Data/Entities/ServerSettingsEntity.cs index f54d257..0404a4c 100644 --- a/DorkNet.Server/Data/Entities/ServerSettingsEntity.cs +++ b/DorkNet.Server/Data/Entities/ServerSettingsEntity.cs @@ -43,5 +43,10 @@ public class ServerSettingsEntity /// the watch without leading # characters. public string PlayMenuTagsJson { get; set; } = string.Empty; + /// Admin-managed Rec Center category-door config. The + /// 2020.12 client reads this through api/gameconfigs/v1/all as + /// Door.{Category}.Title and Door.{Category}.Query entries. + public string RecCenterDoorsJson { get; set; } = string.Empty; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; } diff --git a/DorkNet.Server/Services/ConfigService.cs b/DorkNet.Server/Services/ConfigService.cs index e29e09d..e4f71d2 100644 --- a/DorkNet.Server/Services/ConfigService.cs +++ b/DorkNet.Server/Services/ConfigService.cs @@ -45,15 +45,15 @@ public class ConfigService(IConfiguration config, DomainConfig domain) // config lookups. Missing values leave the door browser // and door-specific return spawns without a category. ["Door.Shooters.Title"] = "Shooters", - ["Door.Shooters.Query"] = "#paintball,#lasertag,#recroyale", + ["Door.Shooters.Query"] = "#paintball|#lasertag|#recroyale", ["Door.Creative.Title"] = "Creative", - ["Door.Creative.Query"] = "#creative,#makerpen,#template", + ["Door.Creative.Query"] = "#creative|#makerpen|#template", ["Door.Quests.Title"] = "Quests", ["Door.Quests.Query"] = "#quest", ["Door.Sports.Title"] = "Sports", ["Door.Sports.Query"] = "#sport", ["Door.Featured.Title"] = "Featured", - ["Door.Featured.Query"] = "#featured", + ["Door.Featured.Query"] = "#featured|#recroomoriginal", }, }; } diff --git a/DorkNet.Server/Services/ServerSettingsService.cs b/DorkNet.Server/Services/ServerSettingsService.cs index dc211b7..e01839b 100644 --- a/DorkNet.Server/Services/ServerSettingsService.cs +++ b/DorkNet.Server/Services/ServerSettingsService.cs @@ -60,6 +60,18 @@ public class ServerSettingsService(DorkNetDbContext db) 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) @@ -72,6 +84,17 @@ public class ServerSettingsService(DorkNetDbContext db) 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, @@ -163,6 +186,29 @@ public class ServerSettingsService(DorkNetDbContext db) 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) { @@ -265,6 +311,42 @@ public class ServerSettingsService(DorkNetDbContext db) ], 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) @@ -289,6 +371,50 @@ public class ServerSettingsService(DorkNetDbContext db) 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( @@ -320,3 +446,18 @@ 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); diff --git a/DorkNet.Server/Startup/DatabaseBootstrap.cs b/DorkNet.Server/Startup/DatabaseBootstrap.cs index 52fea52..5172866 100644 --- a/DorkNet.Server/Startup/DatabaseBootstrap.cs +++ b/DorkNet.Server/Startup/DatabaseBootstrap.cs @@ -244,6 +244,8 @@ public static class DatabaseBootstrap { await db.Database.ExecuteSqlRawAsync( @"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 '';"); return; } @@ -258,6 +260,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 ""RecCenterDoorsJson"" 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/Rooms.tsx b/DorkNet.Server/admin-ui/src/pages/Rooms.tsx index 704f0ec..fc9c6d5 100644 --- a/DorkNet.Server/admin-ui/src/pages/Rooms.tsx +++ b/DorkNet.Server/admin-ui/src/pages/Rooms.tsx @@ -8,7 +8,7 @@ import { Empty } from '../components/Empty'; import { Modal } from '../components/Modal'; import { useToast } from '../components/Toast'; import { Confirm } from '../components/Confirm'; -import { RefreshCw, Trash, Upload } from '../components/Icons'; +import { Plus, RefreshCw, Trash, Upload } from '../components/Icons'; interface PlayMenuTagSettings { pinnedTags: string[]; @@ -16,6 +16,17 @@ interface PlayMenuTagSettings { updatedAt: string; } +interface RecCenterDoorConfig { + key: string; + title: string; + query: string; +} + +interface RecCenterDoorSettings { + doors: RecCenterDoorConfig[]; + updatedAt: string; +} + export function Rooms() { const { data, loading, error, refresh } = useApi('/rooms'); const { @@ -24,6 +35,12 @@ export function Rooms() { error: tagsError, refresh: refreshPlayMenuTags, } = useApi('/settings/play-menu-tags'); + const { + data: recCenterDoors, + loading: doorsLoading, + error: doorsError, + refresh: refreshRecCenterDoors, + } = useApi('/settings/rec-center-doors'); const [filter, setFilter] = useState(''); const [filterKind, setFilterKind] = useState<'all' | 'original' | 'custom' | 'dorm'>('all'); const [pendingDelete, setPendingDelete] = useState(null); @@ -83,6 +100,13 @@ export function Rooms() { onSaved={refreshPlayMenuTags} /> + +
setFilter(e.target.value)} placeholder="Filter by name…" className="input max-w-xs" /> @@ -186,23 +210,17 @@ function PlayMenuTagsPanel({ error: string | null; onSaved: () => void; }) { - const [pinnedText, setPinnedText] = useState(''); - const [popularText, setPopularText] = useState(''); + const [pinnedTags, setPinnedTags] = useState([]); + const [popularTags, setPopularTags] = useState([]); const [busy, setBusy] = useState(false); const toast = useToast(); useEffect(() => { if (!tags) return; - setPinnedText(tags.pinnedTags.join('\n')); - setPopularText(tags.popularTags.join('\n')); + setPinnedTags(tags.pinnedTags); + setPopularTags(tags.popularTags); }, [tags]); - const parseTags = (text: string) => - text - .split(/[\n,]+/) - .map(t => t.trim().replace(/^#+/, '').toLowerCase()) - .filter(Boolean); - const save = async (reset = false) => { setBusy(true); try { @@ -210,7 +228,7 @@ function PlayMenuTagsPanel({ method: 'POST', body: reset ? { PinnedTags: [], PopularTags: [] } - : { PinnedTags: parseTags(pinnedText), PopularTags: parseTags(popularText) }, + : { PinnedTags: pinnedTags, PopularTags: popularTags }, }); toast.push(reset ? 'Play menu tags reset.' : 'Play menu tags saved.', 'success'); onSaved(); @@ -227,7 +245,7 @@ function PlayMenuTagsPanel({

Play menu tags

- Filter chips shown in the watch Play menu. Use one tag per line or commas; leave off the #. + Filter chips shown in the watch Play menu.

{tags && (

@@ -249,28 +267,224 @@ function PlayMenuTagsPanel({ {loading && !tags &&

Loading tags...
} {tags && (
-