From 36c5dd5ca948746919affdc30b7192efc43b94f5 Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Sun, 27 Sep 2026 23:02:40 +0300 Subject: [PATCH] fix(admin): RRO blob switch works for rooms with no RoomScenes row The seeded Rec Royale rows carry no RoomScenes at all ("Scenes 0"), so the Sub-rooms tab listed nothing and blob/enable would have 404'd. The admin list now synthesises the same virtual "Home" entry scene the game-client details builder does (isVirtual: true, max players = room capacity), blob/enable creates the real row with the game-side defaults (RoomsController.GetOrCreateSceneForMutationAsync), and blob/reset copes with a missing row. Max-players save is disabled on the virtual row. Test: Room_without_scene_rows_gets_a_virtual_entry_scene_and_enable_creates_it. --- DorkNet.Server.Tests/RroRoomBlobTests.cs | 61 +++++++++++++++++ .../Controllers/Admin/AdminController.cs | 68 +++++++++++++++---- DorkNet.Server/admin-ui/src/pages/Room.tsx | 6 +- docs/admin.md | 6 +- 4 files changed, 127 insertions(+), 14 deletions(-) diff --git a/DorkNet.Server.Tests/RroRoomBlobTests.cs b/DorkNet.Server.Tests/RroRoomBlobTests.cs index 510b2a1..e7141b6 100644 --- a/DorkNet.Server.Tests/RroRoomBlobTests.cs +++ b/DorkNet.Server.Tests/RroRoomBlobTests.cs @@ -113,6 +113,67 @@ public sealed class RroRoomBlobTests : IClassFixture Assert.Equal(string.Empty, SubRoomZero(restored).GetProperty("DataBlob").GetString()); } + /// The seeded Rec Royale rows have NO RoomScenes at all (the admin + /// page showed "Scenes 0 / No sub-rooms"). The admin list must synthesise + /// the entry scene the way the game-client details do, and enable must + /// create the row rather than 404. + [Fact] + public async Task Room_without_scene_rows_gets_a_virtual_entry_scene_and_enable_creates_it() + { + using var setup = Client("rooms"); + var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain); + var admin = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain); + + var roomId = 9_670_000 + Random.Shared.Next(1, 99_999); + await using (var scope = _factory.Services.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + (await db.Players.FirstAsync(p => p.Id == admin.PlayerId)).IsAdmin = true; + db.Rooms.Add(new RoomEntity + { + Id = roomId, + Name = $"NoScene{Guid.NewGuid():N}"[..18], + CreatorPlayerId = 1, + IsAGRoom = true, + TagsCsv = "recroomoriginal", + MaxCapacity = 24, + }); + await db.SaveChangesAsync(); + } + + var synthetic = RoomService.SyntheticDefaultRoomDataBlobName(roomId); + using var gameClient = Client("rooms", player); + using var adminClient = Client("admin", admin); + + var listed = await GetJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms"); + var virtualRow = listed.EnumerateArray().Single(); + Assert.True(virtualRow.GetProperty("isVirtual").GetBoolean()); + Assert.Equal(0, virtualRow.GetProperty("subRoomId").GetInt32()); + Assert.Equal(24, virtualRow.GetProperty("maxPlayers").GetInt32()); + Assert.Equal(string.Empty, virtualRow.GetProperty("effectiveBlobName").GetString()); + + var enabled = await PostJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms/0/blob/enable"); + Assert.True(enabled.GetProperty("changed").GetBoolean()); + Assert.Equal(synthetic, enabled.GetProperty("effectiveBlobName").GetString()); + + // The row now exists with the game-side defaults, and the game client + // sees the blob on its entry scene. + var relisted = await GetJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms"); + var real = relisted.EnumerateArray().Single(); + Assert.False(real.TryGetProperty("isVirtual", out _)); + Assert.Equal("Home", real.GetProperty("name").GetString()); + Assert.Equal(24, real.GetProperty("maxPlayers").GetInt32()); + Assert.Equal(synthetic, real.GetProperty("dataBlobName").GetString()); + + var details = await GetJsonAsync(gameClient, $"/rooms/{roomId}"); + Assert.True(details.GetProperty("IsRRO").GetBoolean()); + Assert.Equal(synthetic, SubRoomZero(details).GetProperty("DataBlob").GetString()); + + var reset = await PostJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms/0/blob/reset"); + Assert.True(reset.GetProperty("changed").GetBoolean()); + Assert.Equal(string.Empty, SubRoomZero(await GetJsonAsync(gameClient, $"/rooms/{roomId}")).GetProperty("DataBlob").GetString()); + } + [Fact] public async Task Enable_on_a_saved_subroom_keeps_the_saved_blob() { diff --git a/DorkNet.Server/Controllers/Admin/AdminController.cs b/DorkNet.Server/Controllers/Admin/AdminController.cs index 0ac5a0e..0c3f23d 100644 --- a/DorkNet.Server/Controllers/Admin/AdminController.cs +++ b/DorkNet.Server/Controllers/Admin/AdminController.cs @@ -1120,6 +1120,33 @@ public class AdminController( var room = await db.Rooms.AsNoTracking().FirstAsync(r => r.Id == id); var bakedOriginal = RoomService.IsBakedOriginalRoom(room); + // Seeded baked originals (Rec Royale, …) have NO RoomScenes rows at + // all; the game-client details builder synthesises a "Home" entry + // scene for them (RoomsController.BuildSubRoomWire with a null scene) + // and the mutation handlers create the row on first write. Mirror + // that here so the SPA has something to act on — the blob/enable + // action below creates the real row. + if (scenes.Count == 0) + { + return Ok(new[] + { + new + { + id = "0", + subRoomId = 0, + name = "Home", + maxPlayers = room.MaxCapacity, + canMatchmakeInto = true, + isSandbox = false, + dataBlobName = string.Empty, + effectiveBlobName = EffectiveSubRoomBlobName(room, null, bakedOriginal), + isBakedOriginal = bakedOriginal, + isVirtual = true, + dataModifiedAt = room.UpdatedAt, + }, + }); + } + return Ok(scenes.Select(s => new { // Ids go out as strings: the admin SPA is JavaScript and silently @@ -1142,9 +1169,9 @@ public class AdminController( }).ToList()); } - private static string EffectiveSubRoomBlobName(RoomEntity room, RoomSceneEntity scene, bool bakedOriginal) + private static string EffectiveSubRoomBlobName(RoomEntity room, RoomSceneEntity? scene, bool bakedOriginal) { - if (!string.IsNullOrWhiteSpace(scene.DataBlobName)) return scene.DataBlobName; + if (!string.IsNullOrWhiteSpace(scene?.DataBlobName)) return scene!.DataBlobName; if (!string.IsNullOrWhiteSpace(room.CurrentDataBlobName)) return room.CurrentDataBlobName; if (bakedOriginal) return string.Empty; return RoomService.SyntheticDefaultRoomDataBlobName(room.Id); @@ -1183,14 +1210,31 @@ public class AdminController( var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == id); if (room is null) return NotFound(); var scene = await db.RoomScenes.FirstOrDefaultAsync(s => s.RoomId == id && s.OrderIndex == subRoomId); - if (scene is null) return NotFound(); + if (scene is null && subRoomId != 0) return NotFound(); var bakedOriginal = RoomService.IsBakedOriginalRoom(room); - if (!string.IsNullOrWhiteSpace(scene.DataBlobName)) + if (scene is not null && !string.IsNullOrWhiteSpace(scene.DataBlobName)) return Ok(SubRoomBlobWire(room, scene, bakedOriginal, changed: false)); var blobName = SyntheticSubRoomBlobName(id, subRoomId); var now = DateTime.UtcNow; + if (scene is null) + { + // Seeded baked originals carry no RoomScenes row; create the + // entry scene with the same defaults the game-side mutation + // path uses (RoomsController.GetOrCreateSceneForMutationAsync). + scene = new RoomSceneEntity + { + RoomId = room.Id, + OrderIndex = 0, + Name = "Home", + RoomSceneLocationId = room.LocationReplicationId, + MaxPlayers = room.MaxCapacity, + IsSandbox = false, + CanMatchmakeInto = true, + }; + db.RoomScenes.Add(scene); + } scene.DataBlobName = blobName; scene.DataModifiedAt = now; if (subRoomId == 0 && string.IsNullOrWhiteSpace(room.CurrentDataBlobName)) @@ -1217,15 +1261,15 @@ public class AdminController( var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == id); if (room is null) return NotFound(); var scene = await db.RoomScenes.FirstOrDefaultAsync(s => s.RoomId == id && s.OrderIndex == subRoomId); - if (scene is null) return NotFound(); + if (scene is null && subRoomId != 0) return NotFound(); var bakedOriginal = RoomService.IsBakedOriginalRoom(room); - var previousScene = scene.DataBlobName; + var previousScene = scene?.DataBlobName ?? string.Empty; var previousRoom = room.CurrentDataBlobName; var changed = false; var now = DateTime.UtcNow; - if (!string.IsNullOrWhiteSpace(scene.DataBlobName)) + if (scene is not null && !string.IsNullOrWhiteSpace(scene.DataBlobName)) { scene.DataBlobName = string.Empty; scene.DataModifiedAt = now; @@ -1247,12 +1291,12 @@ public class AdminController( return Ok(SubRoomBlobWire(room, scene, bakedOriginal, changed)); } - private static object SubRoomBlobWire(RoomEntity room, RoomSceneEntity scene, bool bakedOriginal, bool changed) => new + private static object SubRoomBlobWire(RoomEntity room, RoomSceneEntity? scene, bool bakedOriginal, bool changed) => new { - id = scene.Id.ToString(), - subRoomId = scene.OrderIndex, - name = scene.Name, - dataBlobName = scene.DataBlobName, + id = (scene?.Id ?? 0).ToString(), + subRoomId = scene?.OrderIndex ?? 0, + name = scene?.Name ?? "Home", + dataBlobName = scene?.DataBlobName ?? string.Empty, roomCurrentDataBlobName = room.CurrentDataBlobName, effectiveBlobName = EffectiveSubRoomBlobName(room, scene, bakedOriginal), isBakedOriginal = bakedOriginal, diff --git a/DorkNet.Server/admin-ui/src/pages/Room.tsx b/DorkNet.Server/admin-ui/src/pages/Room.tsx index 5e300a9..5fc606c 100644 --- a/DorkNet.Server/admin-ui/src/pages/Room.tsx +++ b/DorkNet.Server/admin-ui/src/pages/Room.tsx @@ -568,6 +568,9 @@ interface SubRoom { * with no blob enabled (scene geometry only, no Maker Pen / chips). */ effectiveBlobName: string; isBakedOriginal: boolean; + /** No RoomScenes row exists yet (seeded baked originals); the entry + * scene is synthesised on read and created by "Enable Maker Pen blob". */ + isVirtual?: boolean; } // Per-sub-room player caps. Distinct from the room's own MaxCapacity, which is @@ -718,8 +721,9 @@ function SubRoomsTab({ roomId, roomCapacity }: { roomId: number; roomCapacity: n /> diff --git a/docs/admin.md b/docs/admin.md index 13866b9..4fb874c 100644 --- a/docs/admin.md +++ b/docs/admin.md @@ -208,7 +208,11 @@ actions: room roles, no objects) while nothing is saved. `IsRRO` is derived from `IsAGRoom`, so the client keeps loading the baked map and only additionally fetches the blob. Idempotent; a no-op on a sub-room that - already has a blob. + already has a blob. Seeded originals such as Rec Royale have **no + `RoomScenes` row at all** ("Scenes 0"); the list synthesises a virtual + "Home" entry (`isVirtual: true`, max players = room capacity) and enable + creates the real row with the game-side defaults + (`RoomsController.GetOrCreateSceneForMutationAsync`). - **Reset to baked scene** — `POST api/admin/v1/rooms/{id}/subrooms/{subRoomId}/blob/reset`. Clears both pointers. This is the escape hatch if a save left the room unplayable. Saved blobs stay in `RoomDataBlobs`, so the in-game restore