mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
feat: Implement base room management; add API endpoints for retrieving and setting curated base rooms
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user