From 6d78b7b115f12d9bc18a6d6ddc1b3ed3dec97739 Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:23:18 +0300 Subject: [PATCH] feat: Update API and documentation to ensure sub-room saves return a paged object; fix deserialization issues for the 2023 client --- DorkNet.Server.Tests/RoomSave2023Tests.cs | 59 +++++++++++++++++++ .../API/Rooms/V2/RoomsController.cs | 40 +++++++++++-- docs/recroom-2023-room-save.md | 12 ++++ 3 files changed, 107 insertions(+), 4 deletions(-) diff --git a/DorkNet.Server.Tests/RoomSave2023Tests.cs b/DorkNet.Server.Tests/RoomSave2023Tests.cs index bd41dff..a05892d 100644 --- a/DorkNet.Server.Tests/RoomSave2023Tests.cs +++ b/DorkNet.Server.Tests/RoomSave2023Tests.cs @@ -260,6 +260,65 @@ public sealed class RoomSave2023Tests : IClassFixture return root.GetProperty("RoomId").GetInt64(); } + /// + /// Regression: GET rooms/{id}/subrooms/{sub}/saves must return the + /// paged wrapper {"Results":[…],"TotalResults":N} + /// (PagedResultsDTO<SubRoomDataSaveDTO>), NOT a bare array. The 2023 + /// client's GetSubRoomDataSaves strict reader throws + /// expected:'{', actual:'[' 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. + /// + [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(); + 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") { var client = _factory.CreateClient(new() { AllowAutoRedirect = false }); diff --git a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs index 47b9f9f..e6ae0db 100644 --- a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs +++ b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs @@ -601,11 +601,30 @@ public class RoomsController( /// list of saves for a subroom /// (String.Format("rooms/{0}/subrooms/{1}/saves", roomId, subRoomId)). /// Returns the current save plus any historical blobs recorded for the - /// subroom, newest first, each in the SubRoomDataSave shape. + /// subroom, newest first, each in the SubRoomDataSave shape. + /// + /// Wire shape is a PAGED OBJECT, not a bare list. The 2023 + /// client's GetSubRoomDataSaves (RecNet.Runtime + /// NLDBPDCNNCF.LDLMCGBIJFH, state machine IOAKBKKDFEE, sends + /// skip/take) deserializes + /// PagedResultsDTO<SubRoomDataSaveDTO> — + /// {"Results":[…],"TotalResults":N} per the Studio decompile + /// (RRStudio-decomp PagedResultsDTO.cs, plain .NET names). A + /// bare array throws expected:'{', actual:'[' 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. [HttpGet("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] - public async Task SubRoomSaves(long roomId, long subRoomId) + public async Task SubRoomSaves( + long roomId, long subRoomId, + [FromQuery] int skip = 0, [FromQuery] int take = 50) { var room = await rooms.GetByIdAsync(roomId); if (room is null) return NotFound(new { error = "room_not_found", roomId }); @@ -650,12 +669,25 @@ public class RoomsController( SavedByAccountId = h.UploadedByPlayerId, SavedOnPlatform = 7, 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"), + 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, + }); } /// Build one subroom entry in the wire shape the watch's diff --git a/docs/recroom-2023-room-save.md b/docs/recroom-2023-room-save.md index d4dc5f9..2ec4075 100644 --- a/docs/recroom-2023-room-save.md +++ b/docs/recroom-2023-room-save.md @@ -107,6 +107,18 @@ Clones are created **private** (`Accessibility=0`) regardless of the source's visibility; the owner publishes via `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` 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 (`Accessibility=0`) allow exactly ONE instance per