mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-27 07:58:45 -07:00
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.
This commit is contained in:
@@ -113,6 +113,67 @@ public sealed class RroRoomBlobTests : IClassFixture<DorkNetServerFactory>
|
|||||||
Assert.Equal(string.Empty, SubRoomZero(restored).GetProperty("DataBlob").GetString());
|
Assert.Equal(string.Empty, SubRoomZero(restored).GetProperty("DataBlob").GetString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
[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<DorkNetDbContext>();
|
||||||
|
(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]
|
[Fact]
|
||||||
public async Task Enable_on_a_saved_subroom_keeps_the_saved_blob()
|
public async Task Enable_on_a_saved_subroom_keeps_the_saved_blob()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1120,6 +1120,33 @@ public class AdminController(
|
|||||||
var room = await db.Rooms.AsNoTracking().FirstAsync(r => r.Id == id);
|
var room = await db.Rooms.AsNoTracking().FirstAsync(r => r.Id == id);
|
||||||
var bakedOriginal = RoomService.IsBakedOriginalRoom(room);
|
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
|
return Ok(scenes.Select(s => new
|
||||||
{
|
{
|
||||||
// Ids go out as strings: the admin SPA is JavaScript and silently
|
// Ids go out as strings: the admin SPA is JavaScript and silently
|
||||||
@@ -1142,9 +1169,9 @@ public class AdminController(
|
|||||||
}).ToList());
|
}).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 (!string.IsNullOrWhiteSpace(room.CurrentDataBlobName)) return room.CurrentDataBlobName;
|
||||||
if (bakedOriginal) return string.Empty;
|
if (bakedOriginal) return string.Empty;
|
||||||
return RoomService.SyntheticDefaultRoomDataBlobName(room.Id);
|
return RoomService.SyntheticDefaultRoomDataBlobName(room.Id);
|
||||||
@@ -1183,14 +1210,31 @@ public class AdminController(
|
|||||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == id);
|
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == id);
|
||||||
if (room is null) return NotFound();
|
if (room is null) return NotFound();
|
||||||
var scene = await db.RoomScenes.FirstOrDefaultAsync(s => s.RoomId == id && s.OrderIndex == subRoomId);
|
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 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));
|
return Ok(SubRoomBlobWire(room, scene, bakedOriginal, changed: false));
|
||||||
|
|
||||||
var blobName = SyntheticSubRoomBlobName(id, subRoomId);
|
var blobName = SyntheticSubRoomBlobName(id, subRoomId);
|
||||||
var now = DateTime.UtcNow;
|
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.DataBlobName = blobName;
|
||||||
scene.DataModifiedAt = now;
|
scene.DataModifiedAt = now;
|
||||||
if (subRoomId == 0 && string.IsNullOrWhiteSpace(room.CurrentDataBlobName))
|
if (subRoomId == 0 && string.IsNullOrWhiteSpace(room.CurrentDataBlobName))
|
||||||
@@ -1217,15 +1261,15 @@ public class AdminController(
|
|||||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == id);
|
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == id);
|
||||||
if (room is null) return NotFound();
|
if (room is null) return NotFound();
|
||||||
var scene = await db.RoomScenes.FirstOrDefaultAsync(s => s.RoomId == id && s.OrderIndex == subRoomId);
|
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 bakedOriginal = RoomService.IsBakedOriginalRoom(room);
|
||||||
var previousScene = scene.DataBlobName;
|
var previousScene = scene?.DataBlobName ?? string.Empty;
|
||||||
var previousRoom = room.CurrentDataBlobName;
|
var previousRoom = room.CurrentDataBlobName;
|
||||||
var changed = false;
|
var changed = false;
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(scene.DataBlobName))
|
if (scene is not null && !string.IsNullOrWhiteSpace(scene.DataBlobName))
|
||||||
{
|
{
|
||||||
scene.DataBlobName = string.Empty;
|
scene.DataBlobName = string.Empty;
|
||||||
scene.DataModifiedAt = now;
|
scene.DataModifiedAt = now;
|
||||||
@@ -1247,12 +1291,12 @@ public class AdminController(
|
|||||||
return Ok(SubRoomBlobWire(room, scene, bakedOriginal, changed));
|
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(),
|
id = (scene?.Id ?? 0).ToString(),
|
||||||
subRoomId = scene.OrderIndex,
|
subRoomId = scene?.OrderIndex ?? 0,
|
||||||
name = scene.Name,
|
name = scene?.Name ?? "Home",
|
||||||
dataBlobName = scene.DataBlobName,
|
dataBlobName = scene?.DataBlobName ?? string.Empty,
|
||||||
roomCurrentDataBlobName = room.CurrentDataBlobName,
|
roomCurrentDataBlobName = room.CurrentDataBlobName,
|
||||||
effectiveBlobName = EffectiveSubRoomBlobName(room, scene, bakedOriginal),
|
effectiveBlobName = EffectiveSubRoomBlobName(room, scene, bakedOriginal),
|
||||||
isBakedOriginal = bakedOriginal,
|
isBakedOriginal = bakedOriginal,
|
||||||
|
|||||||
@@ -568,6 +568,9 @@ interface SubRoom {
|
|||||||
* with no blob enabled (scene geometry only, no Maker Pen / chips). */
|
* with no blob enabled (scene geometry only, no Maker Pen / chips). */
|
||||||
effectiveBlobName: string;
|
effectiveBlobName: string;
|
||||||
isBakedOriginal: boolean;
|
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
|
// 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
|
|||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={() => save(sub)}
|
onClick={() => save(sub)}
|
||||||
disabled={!dirty || saving === sub.subRoomId}
|
disabled={!dirty || saving === sub.subRoomId || sub.isVirtual}
|
||||||
className="btn-primary text-xs"
|
className="btn-primary text-xs"
|
||||||
|
title={sub.isVirtual ? 'This room has no sub-room row yet — enable the blob first' : undefined}
|
||||||
>
|
>
|
||||||
{saving === sub.subRoomId ? 'Saving…' : 'Save'}
|
{saving === sub.subRoomId ? 'Saving…' : 'Save'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
+5
-1
@@ -208,7 +208,11 @@ actions:
|
|||||||
room roles, no objects) while nothing is saved. `IsRRO` is derived from
|
room roles, no objects) while nothing is saved. `IsRRO` is derived from
|
||||||
`IsAGRoom`, so the client keeps loading the baked map and only
|
`IsAGRoom`, so the client keeps loading the baked map and only
|
||||||
additionally fetches the blob. Idempotent; a no-op on a sub-room that
|
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`.
|
- **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
|
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
|
unplayable. Saved blobs stay in `RoomDataBlobs`, so the in-game restore
|
||||||
|
|||||||
Reference in New Issue
Block a user