feat: Update API and documentation to ensure sub-room saves return a paged object; fix deserialization issues for the 2023 client

This commit is contained in:
Alexa
2026-07-13 12:23:18 +03:00
parent 6ee018b844
commit 6d78b7b115
3 changed files with 107 additions and 4 deletions
+59
View File
@@ -260,6 +260,65 @@ public sealed class RoomSave2023Tests : IClassFixture<DorkNetServerFactory>
return root.GetProperty("RoomId").GetInt64(); return root.GetProperty("RoomId").GetInt64();
} }
/// <summary>
/// Regression: <c>GET rooms/{id}/subrooms/{sub}/saves</c> must return the
/// paged wrapper <c>{"Results":[…],"TotalResults":N}</c>
/// (PagedResultsDTO&lt;SubRoomDataSaveDTO&gt;), NOT a bare array. The 2023
/// client's GetSubRoomDataSaves strict reader throws
/// <c>expected:'{', actual:'['</c> on an array, which aborts the whole
/// calling flow — room clone fails with the message-less "Failed to copy
/// room" and the private-instance button silently does nothing.
/// </summary>
[Fact]
public async Task SubRoom_saves_returns_paged_object_not_bare_array()
{
using var setupClient = ApiClient();
var owner = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
var roomId = 9_300_888L;
await using (var scope = _factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
if (!await db.Rooms.AnyAsync(r => r.Id == roomId))
{
db.Rooms.Add(new RoomEntity
{
Id = roomId,
Name = $"SavesShape_{Guid.NewGuid():N}"[..20],
CreatorPlayerId = owner.PlayerId,
ImageName = RoomService.DefaultRoomImageName,
Accessibility = 1,
CurrentDataBlobName = $"room_{roomId}_v1.dat",
});
db.RoomScenes.Add(new RoomSceneEntity
{
RoomId = roomId,
OrderIndex = 0,
Name = "Home",
DataBlobName = $"room_{roomId}_v1.dat",
});
await db.SaveChangesAsync();
}
}
using var roomsClient = ApiClient(owner, subdomain: "rooms");
foreach (var path in new[]
{
$"/roomserver/rooms/{roomId}/subrooms/0/saves?skip=0&take=10",
$"/rooms/{roomId}/subrooms/0/saves",
$"/roomserver/rooms/{roomId}/subrooms/0/saves/no_unity_assets?skip=0&take=10",
})
{
using var resp = await roomsClient.GetAsync(path);
var text = await resp.Content.ReadAsStringAsync();
Assert.True(resp.IsSuccessStatusCode, $"{path} -> {(int)resp.StatusCode}: {text}");
using var json = JsonDocument.Parse(text);
Assert.Equal(JsonValueKind.Object, json.RootElement.ValueKind);
Assert.Equal(JsonValueKind.Array, json.RootElement.GetProperty("Results").ValueKind);
Assert.True(json.RootElement.GetProperty("TotalResults").GetInt64() >= 1, $"TotalResults missing/zero: {text}");
}
}
private HttpClient ApiClient(GameClientSession? session = null, string subdomain = "api") private HttpClient ApiClient(GameClientSession? session = null, string subdomain = "api")
{ {
var client = _factory.CreateClient(new() { AllowAutoRedirect = false }); var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
@@ -601,11 +601,30 @@ public class RoomsController(
/// list of saves for a subroom /// list of saves for a subroom
/// (<c>String.Format("rooms/{0}/subrooms/{1}/saves", roomId, subRoomId)</c>). /// (<c>String.Format("rooms/{0}/subrooms/{1}/saves", roomId, subRoomId)</c>).
/// Returns the current save plus any historical blobs recorded for the /// Returns the current save plus any historical blobs recorded for the
/// subroom, newest first, each in the <c>SubRoomDataSave</c> shape.</summary> /// subroom, newest first, each in the <c>SubRoomDataSave</c> shape.
///
/// <para><b>Wire shape is a PAGED OBJECT, not a bare list.</b> The 2023
/// client's <c>GetSubRoomDataSaves</c> (RecNet.Runtime
/// NLDBPDCNNCF.LDLMCGBIJFH, state machine IOAKBKKDFEE, sends
/// <c>skip</c>/<c>take</c>) deserializes
/// <c>PagedResultsDTO&lt;SubRoomDataSaveDTO&gt;</c> —
/// <c>{"Results":[…],"TotalResults":N}</c> per the Studio decompile
/// (RRStudio-decomp <c>PagedResultsDTO.cs</c>, plain .NET names). A
/// bare array throws <c>expected:'{', actual:'['</c> in the strict
/// reader, which kills the whole calling flow: room clone dies with
/// the message-less "Failed to copy room" and the private-instance
/// button silently does nothing.</para></summary>
[HttpGet("rooms/{roomId:long}/subrooms/{subRoomId:long}/saves")] [HttpGet("rooms/{roomId:long}/subrooms/{subRoomId:long}/saves")]
[HttpGet("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/saves")] [HttpGet("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/saves")]
// Studio editor asks for the same page without the heavy Unity-asset
// payload (CLAJDMOFFOS.PGGIWOJWHOM). Our rows never carry inline asset
// bytes, so the same handler serves both.
[HttpGet("rooms/{roomId:long}/subrooms/{subRoomId:long}/saves/no_unity_assets")]
[HttpGet("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/saves/no_unity_assets")]
[Authorize] [Authorize]
public async Task<IActionResult> SubRoomSaves(long roomId, long subRoomId) public async Task<IActionResult> SubRoomSaves(
long roomId, long subRoomId,
[FromQuery] int skip = 0, [FromQuery] int take = 50)
{ {
var room = await rooms.GetByIdAsync(roomId); var room = await rooms.GetByIdAsync(roomId);
if (room is null) return NotFound(new { error = "room_not_found", roomId }); if (room is null) return NotFound(new { error = "room_not_found", roomId });
@@ -650,12 +669,25 @@ public class RoomsController(
SavedByAccountId = h.UploadedByPlayerId, SavedByAccountId = h.UploadedByPlayerId,
SavedOnPlatform = 7, SavedOnPlatform = 7,
SavedOnDeviceClass = 2, SavedOnDeviceClass = 2,
SavedAt = (h.UploadedAt == default ? DateTime.UtcNow : h.UploadedAt) // Match BuildStudioSaveWire's key set — the strict readers
// key on CreatedAt/CreatedByAccountId (SavedAt was our own
// invention and no reader consumes it).
CreatedAt = (h.UploadedAt == default ? DateTime.UtcNow : h.UploadedAt)
.ToString("yyyy-MM-ddTHH:mm:ssZ"), .ToString("yyyy-MM-ddTHH:mm:ssZ"),
CreatedByAccountId = h.UploadedByPlayerId,
}); });
} }
return Ok(saves); var total = saves.Count;
var page = saves
.Skip(Math.Max(0, skip))
.Take(take > 0 ? take : 50)
.ToList();
return Ok(new
{
Results = page,
TotalResults = total,
});
} }
/// <summary>Build one subroom entry in the wire shape the watch's /// <summary>Build one subroom entry in the wire shape the watch's
+12
View File
@@ -107,6 +107,18 @@ Clones are created **private** (`Accessibility=0`) regardless of the
source's visibility; the owner publishes via source's visibility; the owner publishes via
`rooms/{id}/accessibility` when ready. `rooms/{id}/accessibility` when ready.
### Sub-room saves list is a PAGED OBJECT
`GET rooms/{id}/subrooms/{sub}/saves` (and `…/saves/no_unity_assets`)
must return `{"Results":[…],"TotalResults":N}` —
`PagedResultsDTO<SubRoomDataSaveDTO>` per the Studio decompile
(`RRStudio-decomp/...RecNet/PagedResultsDTO.cs`); the 2023 client's
`GetSubRoomDataSaves` sends `skip`/`take` and its strict reader throws
`expected:'{', actual:'['` on a bare array. Because callers run inside
larger flows, the bare-array bug surfaced as "Failed to copy room:
Exception of type '…' was thrown" (clone) and a dead private-instance
button — not as a saves error.
### Private rooms: one instance per sub-room ### Private rooms: one instance per sub-room
Private rooms (`Accessibility=0`) allow exactly ONE instance per Private rooms (`Accessibility=0`) allow exactly ONE instance per