fix: room-level DataBlob must be the blob sub-room 0 actually loads

Regression from adding the room-level DataBlob key: it was filled from the
per-ROOM name while sub-room 0 resolves its own, override included. A dorm's
data is per-PLAYER (dorm_p{id}_v*.dat) and reaches the builder as that
override, so the room level advertised a room_{id}_* file that was never
written. The client fetched it and rejected 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 payload that disagrees with itself.

Both now come from one resolved value, so whichever key the client reads
first it gets the same file. The no-scenes fallback sub-room honours the
override too, instead of falling back to the per-room name.

Adds the invariant as a test: room-level DataBlob == SubRooms[0].DataBlob.
The DTO parity tests could not have caught this — the key was present and
correctly named, it just pointed somewhere else.
This commit is contained in:
Alexa
2026-07-28 11:09:58 +03:00
parent f43ce3f368
commit 79a2b9844e
2 changed files with 85 additions and 2 deletions
@@ -91,6 +91,65 @@ public sealed class ClientDtoParityTests : IClassFixture<DorkNetServerFactory>
AssertKeyParity(ChatThreadReader, body, "POST thread/withmembers");
}
/// <summary>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
/// (<c>dorm_p{id}_v*.dat</c>) and reaches the builder as an override, while
/// the room-level name is per-ROOM (<c>room_116_*</c>). Reporting the
/// room-level one hands the client another player's dorm.</summary>
[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<DorkNetDbContext>();
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}
""");
}
/// <summary>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
@@ -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