diff --git a/DorkNet.Server.Tests/RoomSave2023Tests.cs b/DorkNet.Server.Tests/RoomSave2023Tests.cs index 9b0391e..373f5f1 100644 --- a/DorkNet.Server.Tests/RoomSave2023Tests.cs +++ b/DorkNet.Server.Tests/RoomSave2023Tests.cs @@ -78,13 +78,20 @@ public sealed class RoomSave2023Tests : IClassFixture // 2023 commit-response contract (NEOPBOMGIOG): value must carry // BOTH a Room and a SubRoomDataSave or the client's post-parse - // validator NREs and the watch shows "Failed to save room". + // Dispose walk NREs and the watch shows "Failed to save room". var value = json.RootElement.GetProperty("value"); - Assert.Equal(JsonValueKind.Object, value.GetProperty("Room").ValueKind); + var roomDto = value.GetProperty("Room"); + Assert.Equal(JsonValueKind.Object, roomDto.ValueKind); + // Keys the FGCPNAACHIK mapper reads and its Dispose walk derefs. + foreach (var key in new[] { "RoomId", "DataBlob", "MaxPlayers", "ToxmodEnabled", "RankingContext", "SubRooms", "Roles", "Tags", "Stats" }) + Assert.True(roomDto.TryGetProperty(key, out _), $"Room missing '{key}': {responseText}"); + Assert.Equal(JsonValueKind.Object, roomDto.GetProperty("RankingContext").ValueKind); + var save = value.GetProperty("SubRoomDataSave"); Assert.Equal(blobName, save.GetProperty("DataBlob").GetString()); - Assert.True(save.TryGetProperty("SubRoomDataSaveId", out _), responseText); - Assert.True(save.TryGetProperty("CreatedAt", out _), responseText); + // Keys the JKIFFPPAJNK save mapper reads. + foreach (var key in new[] { "SubRoomDataSaveId", "SubRoomId", "UnityAssetId", "DataBlobHash", "SavedByAccountId", "Description", "CreatedAt" }) + Assert.True(save.TryGetProperty(key, out _), $"SubRoomDataSave missing '{key}': {responseText}"); await using (var scope = _factory.Services.CreateAsyncScope()) { diff --git a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs index 99584c5..6be4c55 100644 --- a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs +++ b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs @@ -700,6 +700,12 @@ public class RoomsController( ReferencedUnityAssetIds = Array.Empty(), DataBlob = dataBlob, DataBlobName = dataBlob, + // DataBlobHash + Description are read by the save mapper + // (JKIFFPPAJNK / PELEHJLMKJO) that the SaveRoom commit + // response deserializes; omitting them leaves nulls the + // response Dispose walk trips on. + DataBlobHash = (string?)null, + Description = string.Empty, PersistenceVersion = 41, OMVersion = 0, SavedByAccountId = room.CreatorPlayerId, @@ -1978,6 +1984,26 @@ public class RoomsController( }, PublishState = 0, MaxPlayerCalculationMode = room.MaxPlayerCalculationMode, + // 2023's room-details mapper (FGCPNAACHIK / GLEGPFFPDBE) reads + // these top-level keys and its Dispose walk (NEOPBOMGIOG. + // FKDDCNLJOLF, reached via the SaveRoom commit response) + // dereferences them — a missing RankingContext object or the + // DataBlob/MaxPlayers/ToxmodEnabled scalars leaves a null the + // walk NREs on ("Failed to save room" toast despite a + // persisted save). Emit them so the room DTO round-trips + // through the strict save-response path, not just the + // include-masked GET rooms/{id} path. + DataBlob = dataBlobName, + DataBlobHash = (string?)null, + MaxPlayers = sceneRows is { Count: > 0 } ? sceneRows[0].MaxPlayers : room.MaxCapacity, + ToxmodEnabled = false, + RankingContext = new + { + RoomId = room.Id, + Score = 0.0, + Rank = 0, + RankingType = 0, + }, SubRooms = subRooms, Roles = wireRoles, LoadScreens = JsonArrayOrEmpty(room.LoadScreensJson), diff --git a/docs/recroom-2023-room-save.md b/docs/recroom-2023-room-save.md index 3c37549..68c6612 100644 --- a/docs/recroom-2023-room-save.md +++ b/docs/recroom-2023-room-save.md @@ -52,12 +52,20 @@ on 2026-07-07. Diagnosed while fixing "Failed to save room" / } ``` - The client's post-parse validator NREs if `Room` or - `SubRoomDataSave` is missing — the save persists server-side but the - watch shows "Failed to save room". `SubRoomDataSave` keys per the - `JKIFFPPAJNK` mapper (`PELEHJLMKJO`): SubRoomDataSaveId, SubRoomId, - UnityAssetId, DataBlob, DataBlobHash, SavedByAccountId, - SavedOnPlatform, SavedOnDeviceClass, Description, CreatedAt. + The client parses each field then runs a **Dispose walk** + (`NEOPBOMGIOG.FKDDCNLJOLF`, reached from the SaveRoom commit + continuation) that dereferences the mapped children — a missing + nested object or scalar leaves a null it NREs on, and the save + persists server-side but the watch shows "Failed to save room". So + the `Room` DTO must carry every key the `FGCPNAACHIK` mapper + (`GLEGPFFPDBE`) reads, including the ones the include-masked + `GET rooms/{id}` path gets away without: **DataBlob**, + **DataBlobHash**, **MaxPlayers**, **ToxmodEnabled**, and a non-null + **RankingContext** object, plus SubRooms/Roles/Tags/Stats. + `SubRoomDataSave` keys per the `JKIFFPPAJNK` mapper (`PELEHJLMKJO`): + SubRoomDataSaveId, SubRoomId, UnityAssetId, DataBlob, **DataBlobHash**, + SavedByAccountId, SavedOnPlatform, SavedOnDeviceClass, **Description**, + CreatedAt. Server handling: `StorageController.Upload` (FileType 6 → `UploadGenericAsync("roommeta")`, CDN-servable) and