From 2c749c267703907184308fb91fbd728a4f7a3729 Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Mon, 13 Jul 2026 00:40:35 +0300 Subject: [PATCH] feat: Implement base room management; add API endpoints for retrieving and setting curated base rooms --- .../API/Rooms/V2/RoomsController.cs | 22 ++- .../API/Rooms/V2/RoomsModerationController.cs | 19 ++- .../Controllers/Admin/AdminController.cs | 26 +++ .../Data/Entities/ServerSettingsEntity.cs | 6 + .../Services/ServerSettingsService.cs | 58 +++++++ DorkNet.Server/Startup/DatabaseBootstrap.cs | 14 ++ DorkNet.Server/admin-ui/src/pages/Rooms.tsx | 150 ++++++++++++++++++ 7 files changed, 291 insertions(+), 4 deletions(-) diff --git a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs index 07b5cea..84ca2ab 100644 --- a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs +++ b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs @@ -293,14 +293,30 @@ public class RoomsController( => Ok((await rooms.HotAsync(null)).Select(RoomService.ToWireRoom).ToList()); /// - /// `Rooms.GetBaseRooms` — list of "base" rooms used by Rec Room Originals - /// to seed the room creation UI. Returns the same set as #recroomoriginal. + /// `Rooms.GetBaseRooms` — the room-creation "base room" picker. A fixed, + /// admin-curated list of room NAMES (server setting + /// BaseRoomNamesJson, default ), resolved to rooms + /// in the configured order. Names are looked up via + /// , which also finds + /// HiddenFromBrowse rooms like MakerRoom. Edit the list from the admin SPA + /// (api/admin/v1/settings/base-rooms). /// [HttpGet("api/rooms/v2/baserooms")] [HttpGet("api/rooms/v3/baserooms")] [HttpGet("rooms/base")] public async Task BaseRooms() - => Ok((await rooms.HotAsync("#recroomoriginal")).Select(RoomService.ToWireRoom).ToList()); + { + var names = await serverSettings.GetBaseRoomNamesAsync(); + var result = new List(); + foreach (var name in names) + { + var room = await rooms.GetByNameAsync(name); + if (room is not null) + result.Add(RoomService.ToWireRoom(room)); + } + return Ok(result); + } /// /// `Rooms.GetFilters` — pinned/popular tag chips shown above the room diff --git a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs index 081839a..b996b03 100644 --- a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs +++ b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs @@ -984,8 +984,20 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase newName = $"{baseName}{suffix}"; } + // Assign an explicit user-room id (> 1000), the same way + // RoomService.CloneAsync does. Relying on DB auto-increment is unsafe: + // seeded rooms were inserted with manual ids (100..1000) without + // advancing the Postgres identity sequence, so auto-increment collides + // with a seeded id → PK violation → the clone POST 500s → the client + // reports "Failed to clone room" and boots the player to their dorm. + // (SQLite in tests tracks max(id), which is why the probe passed.) + var nextId = await db.Rooms + .Where(r => r.Id > 1000) + .MaxAsync(r => (long?)r.Id) ?? 1000L; + var clone = new RoomEntity { + Id = nextId + 1, Name = newName, Description = source.Description, CreatorPlayerId = Me, @@ -1015,7 +1027,12 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase IsStudioRoom = source.IsStudioRoom, IsRoomLinkedToRecRoomStudio = source.IsRoomLinkedToRecRoomStudio, StudioSessionId = source.StudioSessionId, - TagsCsv = source.TagsCsv, + // A user clone is community content — NOT an RRO. Copying the + // source's tags verbatim would keep "recroomoriginal,quest,…" on + // the clone, making the client treat it as a quest room (wrong + // badge, and quest-instance logic can boot the player). Match + // RoomService.CloneAsync and tag it "community". + TagsCsv = "community", CurrentDataBlobName = source.CurrentDataBlobName, }; db.Rooms.Add(clone); diff --git a/DorkNet.Server/Controllers/Admin/AdminController.cs b/DorkNet.Server/Controllers/Admin/AdminController.cs index d1a3cbd..f5fc2fa 100644 --- a/DorkNet.Server/Controllers/Admin/AdminController.cs +++ b/DorkNet.Server/Controllers/Admin/AdminController.cs @@ -1810,6 +1810,32 @@ public class AdminController( return Ok(doors); } + /// GET api/admin/v1/settings/base-rooms — the curated + /// room-creation base-room list (ordered room names) plus the catalogue of + /// Rec Room Original rooms the SPA can offer as pickable candidates. + [HttpGet("settings/base-rooms")] + public async Task GetBaseRooms() + { + var names = await serverSettings.GetBaseRoomNamesAsync(); + var available = await db.Rooms + .Where(r => EF.Functions.Like(r.TagsCsv, "%recroomoriginal%")) + .OrderBy(r => r.Name) + .Select(r => new { r.Name, r.ImageName, r.TagsCsv }) + .ToListAsync(); + return Ok(new { Names = names, Available = available }); + } + + public sealed record BaseRoomsRequest(List? Names); + + [HttpPost("settings/base-rooms")] + public async Task SetBaseRooms([FromBody] BaseRoomsRequest body) + { + var names = await serverSettings.SetBaseRoomNamesAsync(body.Names ?? []); + await LogAsync("base_rooms_updated", "system", 0, string.Join(",", names)); + await db.SaveChangesAsync(); + return Ok(new { Names = names }); + } + [HttpGet("settings/gameconfigs")] public async Task GetDiscoveredGameConfigs() => Ok(await serverSettings.GetDiscoveredGameConfigsAsync()); diff --git a/DorkNet.Server/Data/Entities/ServerSettingsEntity.cs b/DorkNet.Server/Data/Entities/ServerSettingsEntity.cs index 2415396..173b8b7 100644 --- a/DorkNet.Server/Data/Entities/ServerSettingsEntity.cs +++ b/DorkNet.Server/Data/Entities/ServerSettingsEntity.cs @@ -92,5 +92,11 @@ public class ServerSettingsEntity /// redeploy. public string CharadesSlotBindingsJson { get; set; } = string.Empty; + /// Ordered list of room NAMES shown in the room-creation "base + /// room" picker (rooms/base / api/rooms/v*/baserooms), as a + /// JSON string array. Empty falls back to the built-in default set. Lets + /// admins curate the base-room list from the SPA without a redeploy. + public string BaseRoomNamesJson { 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 b6094fd..cc75c0b 100644 --- a/DorkNet.Server/Services/ServerSettingsService.cs +++ b/DorkNet.Server/Services/ServerSettingsService.cs @@ -106,6 +106,64 @@ public class ServerSettingsService(DorkNetDbContext db) return ToRecCenterDoors(row); } + /// Built-in default base-room picker (used when an admin hasn't + /// customised the list). RRO originals minus #quest rooms, MakerRoom + /// first as the canonical blank canvas. + public static readonly IReadOnlyList DefaultBaseRoomNames = new[] + { + "MakerRoom", "RecCenter", "3DCharades", "Dodgeball", + "DiscGolfLake", "DiscGolfPropulsion", "Paddleball", + "Paintball", "Soccer", "LaserTag", "RecRoyaleSquads", + "RecRoyaleSolos", "Park", "BowlingAlley", "RecRally", + }; + + public async Task> GetBaseRoomNamesAsync() + { + var row = await GetAsync(); + return ToBaseRoomNames(row); + } + + public async Task> SetBaseRoomNamesAsync(IReadOnlyList names) + { + var normalized = NormalizeBaseRoomNames(names); + var existing = await GetTrackedRowAsync(); + existing.BaseRoomNamesJson = JsonSerializer.Serialize(normalized, JsonOptions); + existing.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + return normalized; + } + + private static IReadOnlyList ToBaseRoomNames(ServerSettingsEntity row) + { + if (!string.IsNullOrWhiteSpace(row.BaseRoomNamesJson)) + { + try + { + var parsed = JsonSerializer.Deserialize>(row.BaseRoomNamesJson, JsonOptions); + if (parsed is { Count: > 0 }) + return NormalizeBaseRoomNames(parsed); + } + catch + { + // Bad settings must not break room creation; admins can + // overwrite from the SPA. + } + } + return DefaultBaseRoomNames.ToList(); + } + + private static IReadOnlyList NormalizeBaseRoomNames(IReadOnlyList names) + { + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + var result = new List(); + foreach (var n in names) + { + var t = (n ?? string.Empty).Trim(); + if (t.Length > 0 && seen.Add(t)) result.Add(t); + } + return result.Count > 0 ? result : DefaultBaseRoomNames.ToList(); + } + public async Task GetDiscoveredGameConfigsAsync() { var row = await GetAsync(); diff --git a/DorkNet.Server/Startup/DatabaseBootstrap.cs b/DorkNet.Server/Startup/DatabaseBootstrap.cs index f3665b6..73e473c 100644 --- a/DorkNet.Server/Startup/DatabaseBootstrap.cs +++ b/DorkNet.Server/Startup/DatabaseBootstrap.cs @@ -531,6 +531,8 @@ public static class DatabaseBootstrap @"ALTER TABLE ""ServerSettings"" ADD COLUMN IF NOT EXISTS ""CharadesSlotBindingsJson"" text NOT NULL DEFAULT '';"); await db.Database.ExecuteSqlRawAsync( @"ALTER TABLE ""ServerSettings"" ADD COLUMN IF NOT EXISTS ""AllAvatarItemsOwned"" boolean NOT NULL DEFAULT false;"); + await db.Database.ExecuteSqlRawAsync( + @"ALTER TABLE ""ServerSettings"" ADD COLUMN IF NOT EXISTS ""BaseRoomNamesJson"" text NOT NULL DEFAULT '';"); return; } @@ -617,6 +619,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 ""BaseRoomNamesJson"" 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. + } } private static async Task EnsureInventorySchemaAsync(DorkNetDbContext db) diff --git a/DorkNet.Server/admin-ui/src/pages/Rooms.tsx b/DorkNet.Server/admin-ui/src/pages/Rooms.tsx index 9387b43..98e414f 100644 --- a/DorkNet.Server/admin-ui/src/pages/Rooms.tsx +++ b/DorkNet.Server/admin-ui/src/pages/Rooms.tsx @@ -28,6 +28,17 @@ interface RecCenterDoorSettings { updatedAt: string; } +interface BaseRoomOption { + name: string; + imageName: string; + tagsCsv: string; +} + +interface BaseRoomsResponse { + names: string[]; + available: BaseRoomOption[]; +} + export function Rooms() { const { data, loading, error, refresh } = useApi('/rooms'); const { @@ -42,6 +53,12 @@ export function Rooms() { error: doorsError, refresh: refreshRecCenterDoors, } = useApi('/settings/rec-center-doors'); + const { + data: baseRooms, + loading: baseRoomsLoading, + error: baseRoomsError, + refresh: refreshBaseRooms, + } = useApi('/settings/base-rooms'); const [filter, setFilter] = useState(''); const [filterKind, setFilterKind] = useState<'all' | 'original' | 'custom' | 'dorm'>('all'); const [pendingDelete, setPendingDelete] = useState(null); @@ -118,6 +135,13 @@ export function Rooms() { onSaved={refreshRecCenterDoors} /> + +
setFilter(e.target.value)} placeholder="Filter by name…" className="input max-w-xs" /> @@ -485,6 +509,132 @@ function RecCenterDoorsPanel({ ); } +function BaseRoomsPanel({ + data, + loading, + error, + onSaved, +}: { + data: BaseRoomsResponse | null; + loading: boolean; + error: string | null; + onSaved: () => void; +}) { + const [names, setNames] = useState([]); + const [toAdd, setToAdd] = useState(''); + const [busy, setBusy] = useState(false); + const toast = useToast(); + + useEffect(() => { + if (!data) return; + setNames(data.names); + }, [data]); + + const available = data?.available ?? []; + const notAdded = available.filter(a => !names.some(n => n.toLowerCase() === a.name.toLowerCase())); + + const move = (i: number, dir: -1 | 1) => { + setNames(cur => { + const j = i + dir; + if (j < 0 || j >= cur.length) return cur; + const next = [...cur]; + [next[i], next[j]] = [next[j], next[i]]; + return next; + }); + }; + const remove = (i: number) => setNames(cur => cur.filter((_, idx) => idx !== i)); + const add = (name: string) => { + if (!name) return; + setNames(cur => (cur.some(n => n.toLowerCase() === name.toLowerCase()) ? cur : [...cur, name])); + setToAdd(''); + }; + + const save = async (reset = false) => { + setBusy(true); + try { + await api<{ names: string[] }>('/settings/base-rooms', { + method: 'POST', + body: reset ? { Names: [] } : { Names: names }, + }); + toast.push(reset ? 'Base rooms reset to defaults.' : 'Base rooms saved.', 'success'); + onSaved(); + } catch (e) { + toast.push((e as Error).message, 'error'); + } finally { + setBusy(false); + } + }; + + return ( +
+
+
+

Base rooms

+

+ Ordered rooms shown in the room-creation "base room" picker (rooms/base). + MakerRoom is the blank canvas; quests are excluded by default. +

+
+
+ + +
+
+ + {error &&
{error}
} + {loading && !data &&
Loading base rooms...
} + {data && ( +
+
+ + + {names.length} rooms +
+ +
    + {names.map((name, i) => { + const missing = !available.some(a => a.name.toLowerCase() === name.toLowerCase()); + return ( +
  1. + {i + 1} + {name} + {missing && not found} +
    + + + +
    +
  2. + ); + })} + {names.length === 0 && ( +
  3. No base rooms set. Defaults will be used on save.
  4. + )} +
+
+ )} +
+ ); +} + function TagListEditor({ label, tags,