diff --git a/DorkNet.Server.Tests/ClientDtoParityTests.cs b/DorkNet.Server.Tests/ClientDtoParityTests.cs index 71f11bb..5eea65f 100644 --- a/DorkNet.Server.Tests/ClientDtoParityTests.cs +++ b/DorkNet.Server.Tests/ClientDtoParityTests.cs @@ -91,6 +91,65 @@ public sealed class ClientDtoParityTests : IClassFixture AssertKeyParity(ChatThreadReader, body, "POST thread/withmembers"); } + /// The room-level DataBlob must be the SAME file sub-room 0 points + /// at. The client loads a room from one blob, and if the two disagree it + /// fetches a file that was never written and rejects the whole room with + /// "the data in the room you are trying to load is corrupt" — which reads + /// like a broken save rather than a mismatched payload. + /// + /// A dorm is where this bites: its data is per-PLAYER + /// (dorm_p{id}_v*.dat) and reaches the builder as an override, while + /// the room-level name is per-ROOM (room_116_*). Reporting the + /// room-level one hands the client another player's dorm. + [Fact] + public async Task Room_level_data_blob_matches_sub_room_zero() + { + using var client = RoomsClient(); + var session = await GameClientSessionFactory.CreateAsync(client, _factory.ApexDomain); + client.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", session.AccessToken); + + var roomId = 9_900_000 + Random.Shared.Next(1, 99_999); + await using (var scope = _factory.Services.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.Rooms.Add(new RoomEntity + { + Id = roomId, + Name = $"Blob{Guid.NewGuid():N}"[..18], + CreatorPlayerId = session.PlayerId, + CurrentDataBlobName = $"room_{roomId}_v1.dat", + }); + db.RoomScenes.Add(new RoomSceneEntity + { + RoomId = roomId, + Name = "Home", + OrderIndex = 0, + DataBlobName = $"room_{roomId}_home_v3.dat", + }); + await db.SaveChangesAsync(); + } + + using var response = await client.GetAsync($"/rooms/{roomId}"); + var body = await response.Content.ReadAsStringAsync(); + Assert.True(response.IsSuccessStatusCode, $"GET /rooms/{roomId} -> {(int)response.StatusCode}: {body}"); + + var details = JsonDocument.Parse(body).RootElement; + var roomLevel = details.GetProperty("DataBlob").GetString(); + var subRoomZero = details.GetProperty("SubRooms").EnumerateArray() + .First(e => e.GetProperty("SubRoomId").GetInt64() == 0) + .GetProperty("DataBlob").GetString(); + + Assert.True(roomLevel == subRoomZero, + $""" + The room reports one data blob and sub-room 0 reports another, so + which file the client loads depends on which key it reads first: + + room level : {roomLevel} + sub-room 0 : {subRoomZero} + """); + } + /// Compare one response object's keys against the reader's set. /// Matching is case-insensitive because the readers register three spellings /// per field (Pascal, camel, lower) and accept any of them — casing is the diff --git a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs index 222e6b4..18c9ec9 100644 --- a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs +++ b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs @@ -1955,6 +1955,20 @@ public class RoomsController( var updatedAt = (room.UpdatedAt == default ? DateTime.UtcNow : room.UpdatedAt) .ToString("yyyy-MM-ddTHH:mm:ssZ"); + // The blob the room actually loads from: sub-room 0's, override and + // all. `dataBlobName` above is the per-ROOM name, which is the wrong + // answer whenever a caller passes an override — a dorm's data is + // per-PLAYER (dorm_p{id}_v*.dat) and arrives that way. + var primarySubRoom = sceneRows is { Count: > 0 } + ? sceneRows.FirstOrDefault(s => s.OrderIndex == 0) ?? sceneRows[0] + : null; + var primarySubRoomDataBlob = + !string.IsNullOrWhiteSpace(overrideDataBlobName) && (sceneRows?.Count ?? 0) <= 1 + ? overrideDataBlobName! + : primarySubRoom is not null + ? SceneOrSyntheticDataBlobName(room, primarySubRoom.DataBlobName, dataBlobName) + : dataBlobName; + object[] subRooms = sceneRows is { Count: > 0 } ? sceneRows.Select(s => { @@ -2052,7 +2066,10 @@ public class RoomsController( SubRoomUnityAssetId = string.Empty, UnityAsset = string.Empty, UnityAssetHash = string.Empty, - DataBlob = dataBlobName, + // Same rule as the populated branch: honour the override, + // so a dorm with no scene rows still points at that + // player's own save rather than a per-room name. + DataBlob = primarySubRoomDataBlob, DataBlobHash = (string?)null, DataSavedAt = updatedAt, IsSandbox = false, @@ -2141,7 +2158,14 @@ public class RoomsController( // already answered 200 to. Kept last so the additions are visible // against the reader's own list; key ORDER does not matter to a // name-keyed reader, presence does. - DataBlob = dataBlobName, + // Must be the SAME blob sub-room 0 resolves to, override included. + // A dorm's data is per-player (dorm_p{id}_v*.dat) and arrives here + // as overrideDataBlobName; the room-level name is per-ROOM + // (room_116_*), so reporting that instead hands the client another + // player's dorm — or one that was never written — and it rejects + // the load outright with "the data in the room you are trying to + // load is corrupt". + DataBlob = subRooms.Length > 0 ? primarySubRoomDataBlob : dataBlobName, DataBlobHash = (string?)null, // The room-level cap is the room's own advertised capacity, which is // what RoomEntity.MaxCapacity exists for and what matchmaking hands