feat: Implement base room management; add API endpoints for retrieving and setting curated base rooms

This commit is contained in:
Alexa
2026-07-13 00:40:35 +03:00
parent 2795d2ed7d
commit 2c749c2677
7 changed files with 291 additions and 4 deletions
@@ -293,14 +293,30 @@ public class RoomsController(
=> Ok((await rooms.HotAsync(null)).Select(RoomService.ToWireRoom).ToList());
/// <summary>
/// `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
/// <c>BaseRoomNamesJson</c>, default <see
/// cref="ServerSettingsService.DefaultBaseRoomNames"/>), resolved to rooms
/// in the configured order. Names are looked up via
/// <see cref="RoomService.GetByNameAsync"/>, which also finds
/// HiddenFromBrowse rooms like MakerRoom. Edit the list from the admin SPA
/// (<c>api/admin/v1/settings/base-rooms</c>).
/// </summary>
[HttpGet("api/rooms/v2/baserooms")]
[HttpGet("api/rooms/v3/baserooms")]
[HttpGet("rooms/base")]
public async Task<IActionResult> BaseRooms()
=> Ok((await rooms.HotAsync("#recroomoriginal")).Select(RoomService.ToWireRoom).ToList());
{
var names = await serverSettings.GetBaseRoomNamesAsync();
var result = new List<object>();
foreach (var name in names)
{
var room = await rooms.GetByNameAsync(name);
if (room is not null)
result.Add(RoomService.ToWireRoom(room));
}
return Ok(result);
}
/// <summary>
/// `Rooms.GetFilters` — pinned/popular tag chips shown above the room
@@ -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);
@@ -1810,6 +1810,32 @@ public class AdminController(
return Ok(doors);
}
/// <summary>GET <c>api/admin/v1/settings/base-rooms</c> — 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.</summary>
[HttpGet("settings/base-rooms")]
public async Task<ActionResult> 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<string>? Names);
[HttpPost("settings/base-rooms")]
public async Task<ActionResult> 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<ActionResult> GetDiscoveredGameConfigs()
=> Ok(await serverSettings.GetDiscoveredGameConfigsAsync());
@@ -92,5 +92,11 @@ public class ServerSettingsEntity
/// redeploy.</summary>
public string CharadesSlotBindingsJson { get; set; } = string.Empty;
/// <summary>Ordered list of room NAMES shown in the room-creation "base
/// room" picker (<c>rooms/base</c> / <c>api/rooms/v*/baserooms</c>), 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.</summary>
public string BaseRoomNamesJson { get; set; } = string.Empty;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
@@ -106,6 +106,64 @@ public class ServerSettingsService(DorkNetDbContext db)
return ToRecCenterDoors(row);
}
/// <summary>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.</summary>
public static readonly IReadOnlyList<string> DefaultBaseRoomNames = new[]
{
"MakerRoom", "RecCenter", "3DCharades", "Dodgeball",
"DiscGolfLake", "DiscGolfPropulsion", "Paddleball",
"Paintball", "Soccer", "LaserTag", "RecRoyaleSquads",
"RecRoyaleSolos", "Park", "BowlingAlley", "RecRally",
};
public async Task<IReadOnlyList<string>> GetBaseRoomNamesAsync()
{
var row = await GetAsync();
return ToBaseRoomNames(row);
}
public async Task<IReadOnlyList<string>> SetBaseRoomNamesAsync(IReadOnlyList<string> 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<string> ToBaseRoomNames(ServerSettingsEntity row)
{
if (!string.IsNullOrWhiteSpace(row.BaseRoomNamesJson))
{
try
{
var parsed = JsonSerializer.Deserialize<List<string>>(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<string> NormalizeBaseRoomNames(IReadOnlyList<string> names)
{
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var result = new List<string>();
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<DiscoveredGameConfigSettings> GetDiscoveredGameConfigsAsync()
{
var row = await GetAsync();
@@ -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)
+150
View File
@@ -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<Room[]>('/rooms');
const {
@@ -42,6 +53,12 @@ export function Rooms() {
error: doorsError,
refresh: refreshRecCenterDoors,
} = useApi<RecCenterDoorSettings>('/settings/rec-center-doors');
const {
data: baseRooms,
loading: baseRoomsLoading,
error: baseRoomsError,
refresh: refreshBaseRooms,
} = useApi<BaseRoomsResponse>('/settings/base-rooms');
const [filter, setFilter] = useState('');
const [filterKind, setFilterKind] = useState<'all' | 'original' | 'custom' | 'dorm'>('all');
const [pendingDelete, setPendingDelete] = useState<Room | null>(null);
@@ -118,6 +135,13 @@ export function Rooms() {
onSaved={refreshRecCenterDoors}
/>
<BaseRoomsPanel
data={baseRooms}
loading={baseRoomsLoading}
error={baseRoomsError}
onSaved={refreshBaseRooms}
/>
<div className="card overflow-hidden">
<div className="flex flex-wrap items-center gap-2 border-b border-ink-800 px-3 py-2.5">
<input value={filter} onChange={e => 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<string[]>([]);
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 (
<div className="card !p-4 mb-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="text-sm font-semibold text-ink-50">Base rooms</h2>
<p className="mt-1 text-xs text-ink-400">
Ordered rooms shown in the room-creation "base room" picker (<code className="font-mono text-[11px]">rooms/base</code>).
MakerRoom is the blank canvas; quests are excluded by default.
</p>
</div>
<div className="flex gap-2">
<button onClick={() => save(true)} disabled={busy || loading} className="btn-secondary text-xs">
Defaults
</button>
<button onClick={() => save(false)} disabled={busy || loading} className="btn-primary text-xs">
{busy ? 'Saving...' : 'Save base rooms'}
</button>
</div>
</div>
{error && <div className="mt-3 text-sm text-danger">{error}</div>}
{loading && !data && <div className="mt-3 text-xs text-ink-400">Loading base rooms...</div>}
{data && (
<div className="mt-4 space-y-3">
<div className="flex flex-wrap items-center gap-2">
<select
value={toAdd}
onChange={e => setToAdd(e.target.value)}
className="input max-w-xs text-xs"
>
<option value="">Add a room…</option>
{notAdded.map(a => (
<option key={a.name} value={a.name}>{a.name}</option>
))}
</select>
<button onClick={() => add(toAdd)} disabled={!toAdd} className="btn-secondary text-xs">
<Plus /> Add
</button>
<span className="ml-auto text-[11px] text-ink-500">{names.length} rooms</span>
</div>
<ol className="space-y-1">
{names.map((name, i) => {
const missing = !available.some(a => a.name.toLowerCase() === name.toLowerCase());
return (
<li
key={`${name}-${i}`}
className="flex items-center gap-2 rounded-md border border-ink-800 bg-ink-950/30 px-2 py-1.5"
>
<span className="w-6 text-right text-[11px] tabular-nums text-ink-500">{i + 1}</span>
<span className={`font-mono text-xs ${missing ? 'text-danger' : 'text-ink-100'}`}>{name}</span>
{missing && <span className="badge-neutral text-[10px]">not found</span>}
<div className="ml-auto flex items-center gap-1">
<button onClick={() => move(i, -1)} disabled={i === 0} className="btn-ghost text-xs" title="Move up">↑</button>
<button onClick={() => move(i, 1)} disabled={i === names.length - 1} className="btn-ghost text-xs" title="Move down">↓</button>
<button onClick={() => remove(i)} className="btn-ghost text-xs text-danger" title="Remove"><Trash /></button>
</div>
</li>
);
})}
{names.length === 0 && (
<li className="text-xs text-ink-500">No base rooms set. Defaults will be used on save.</li>
)}
</ol>
</div>
)}
</div>
);
}
function TagListEditor({
label,
tags,