diff --git a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs index 843ae4f..66e9456 100644 --- a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs +++ b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs @@ -397,6 +397,7 @@ public class RoomsController( if (room is null) return NotFound(new { error = "room_not_found" }); var newBlob = body.RoomDataFilename?.Trim() ?? string.Empty; + var savedAt = DateTime.UtcNow; // Multi-scene rooms have a RoomScenes row per scene. The 2020 // client usually sends the scene's OrderIndex, but some flows @@ -426,27 +427,33 @@ public class RoomsController( db.DormStates.Add(dormState); } dormState.CurrentDataBlobName = newBlob; - dormState.UpdatedAt = DateTime.UtcNow; + dormState.UpdatedAt = savedAt; } else { - // Owned rooms: only the creator can save. Admin override - // intentionally not included — admins should use /restore - // when they need to roll a room back. - if (room.CreatorPlayerId != pid) return Forbid(); + var canSave = room.CreatorPlayerId == pid + || await db.Players.AnyAsync(p => p.Id == pid && p.IsAdmin) + || await db.RoomRoles.AnyAsync(r => + r.RoomId == room.Id && + r.PlayerId == pid && + r.Accepted && + r.Role == 0); + if (!canSave) return Forbid(); } if (sceneRow is not null) { sceneRow.DataBlobName = newBlob; - sceneRow.DataModifiedAt = DateTime.UtcNow; + sceneRow.DataModifiedAt = savedAt; } await db.Rooms .Where(r => r.Id == roomId) .ExecuteUpdateAsync(s => s .SetProperty(r => r.CurrentDataBlobName, newBlob) - .SetProperty(r => r.UpdatedAt, DateTime.UtcNow)); + .SetProperty(r => r.UpdatedAt, savedAt)); await db.SaveChangesAsync(); + room.CurrentDataBlobName = newBlob; + room.UpdatedAt = savedAt; // The watch's MasterUploadRoomDataBlobCoroutine waits for BOTH // the /upload + /saveData responses AND a SubscriptionUpdateRoom @@ -470,7 +477,7 @@ public class RoomsController( if (sceneRow is not null && scene.Id == sceneRow.Id) { scene.DataBlobName = newBlob; - scene.DataModifiedAt = DateTime.UtcNow; + scene.DataModifiedAt = savedAt; } } var savedSceneId = sceneRow?.OrderIndex ?? body.RoomSceneId; @@ -577,7 +584,7 @@ public class RoomsController( DataBlobName = newBlob, MaxPlayers = sceneRow?.MaxPlayers ?? 8, CanMatchmakeInto = sceneRow?.CanMatchmakeInto ?? true, - DataModifiedAt = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ"), + DataModifiedAt = savedAt.ToString("yyyy-MM-ddTHH:mm:ssZ"), }); } diff --git a/DorkNet.Server/Protos/dorknet_room_data.proto b/DorkNet.Server/Protos/dorknet_room_data.proto index eda6118..c1656f3 100644 --- a/DorkNet.Server/Protos/dorknet_room_data.proto +++ b/DorkNet.Server/Protos/dorknet_room_data.proto @@ -82,13 +82,14 @@ message RoomRoleCollectionData { // ── PersistedRoomData top-level (rec_room/persistence/persisted_room_data.proto) // -// The 2026 schema has 30+ fields — we only set field 23 (room_role_data). -// Everything else stays at proto3 defaults, which the client treats as -// "no saved state for this room" — exactly what we want for a fresh dorm -// /clone with the role config injected. +// The full schema has many more fields — we only set the fields the +// December 2020 client needs for an editable minimal blob. Its +// RoomPersistenceManager checks field 1 against a minimum editable +// persistence version of 20, so generated blobs must carry a current +// version or room editors see the native "save data is out-of-date" prompt. message PersistedRoomData { - // field 1 (DEPRECATED_version) — reserved, do not set. + int32 DEPRECATED_version = 1; // field 2 (persistence_views) — repeated, default empty list. // fields 3..22 — left at defaults. RoomRoleCollectionData room_role_data = 23; diff --git a/DorkNet.Server/Services/RoomDataBlobService.cs b/DorkNet.Server/Services/RoomDataBlobService.cs index 2e0d7d3..15ca63e 100644 --- a/DorkNet.Server/Services/RoomDataBlobService.cs +++ b/DorkNet.Server/Services/RoomDataBlobService.cs @@ -24,6 +24,8 @@ namespace DorkNet.Server.Services; /// public class RoomDataBlobService { + private const int December2020PersistenceVersion = 26; + /// /// Cached bytes of the "all-permissions-on" blob. Identical for every /// room — there's no per-room state we vary yet, so building once at @@ -40,6 +42,7 @@ public class RoomDataBlobService public byte[] OverlayAllPermsRoleData(byte[] existingBlob) { var msg = PersistedRoomData.Parser.ParseFrom(existingBlob); + EnsureEditablePersistenceVersion(msg); msg.RoomRoleData = _allPermsRoleData.Clone(); return msg.ToByteArray(); } @@ -47,6 +50,7 @@ public class RoomDataBlobService public byte[] OverlayRroEditableRoleData(byte[] existingBlob) { var msg = PersistedRoomData.Parser.ParseFrom(existingBlob); + EnsureEditablePersistenceVersion(msg); msg.RoomRoleData = _rroEditableRoleData.Clone(); return msg.ToByteArray(); } @@ -55,6 +59,7 @@ public class RoomDataBlobService { var msg = new PersistedRoomData { + DEPRECATEDVersion = December2020PersistenceVersion, RoomRoleData = BuildAllPermsRoleData(), }; @@ -73,6 +78,7 @@ public class RoomDataBlobService { var msg = new PersistedRoomData { + DEPRECATEDVersion = December2020PersistenceVersion, RoomRoleData = BuildRroEditableRoleData(), }; @@ -153,6 +159,14 @@ public class RoomDataBlobService }; } + private static void EnsureEditablePersistenceVersion(PersistedRoomData msg) + { + if (msg.DEPRECATEDVersion < 20) + { + msg.DEPRECATEDVersion = December2020PersistenceVersion; + } + } + private static string StableRoleGuid(int roleId) => GuidUtility.Create(GuidUtility.UrlNamespace, $"dorknet-room-role:{roleId}").ToString(); }