From a249a5095258702f6bc8cfb91b17d925aea23709 Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Mon, 15 Jun 2026 00:02:48 +0300 Subject: [PATCH] Fix personal dorm blob recovery --- .../API/Rooms/V2/RoomsController.cs | 51 ++--- .../Controllers/Match/GoToController.cs | 9 +- .../Match/MatchPlayerController.cs | 9 +- DorkNet.Server/Services/RoomService.cs | 187 ++++++++++++++++-- 4 files changed, 204 insertions(+), 52 deletions(-) diff --git a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs index e3b53db..53b99f2 100644 --- a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs +++ b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs @@ -897,12 +897,7 @@ public class RoomsController( // owner's DormStateEntity blob so they load the same room state. if (room.IsDormRoom) { - var dormBlobName = await db.DormStates - .Where(d => d.PlayerId == room.CreatorPlayerId) - .Select(d => d.CurrentDataBlobName) - .FirstOrDefaultAsync() - ?? string.Empty; - room = CloneWithCreator(room, room.CreatorPlayerId, dormBlobName); + room = await ShapeDormRoomAsync(room); } var scenes = await db.RoomScenes @@ -939,15 +934,9 @@ public class RoomsController( room ??= Synthetic(roomName); if (room.IsDormRoom) { - var dormBlobName = await db.DormStates - .Where(d => d.PlayerId == room.CreatorPlayerId) - .Select(d => d.CurrentDataBlobName) - .FirstOrDefaultAsync() - ?? string.Empty; // Stamp the wire Name with the requested name so the watch's // name-keyed room cache (OJMCBOKJFOF.NHBPIIGDAJP) can find it. - room = CloneWithCreator(room, room.CreatorPlayerId, dormBlobName, - overrideName: DormNameOverride(roomName)); + room = await ShapeDormRoomAsync(room, DormNameOverride(roomName)); } var scenes = await db.RoomScenes @@ -997,15 +986,9 @@ public class RoomsController( room ??= Synthetic(name); if (room.IsDormRoom) { - var dormBlobName = await db.DormStates - .Where(d => d.PlayerId == room.CreatorPlayerId) - .Select(d => d.CurrentDataBlobName) - .FirstOrDefaultAsync() - ?? string.Empty; // Stamp the wire Name with the requested name so the watch's // name-keyed room cache (OJMCBOKJFOF.NHBPIIGDAJP) finds it. - room = CloneWithCreator(room, room.CreatorPlayerId, dormBlobName, - overrideName: DormNameOverride(name)); + room = await ShapeDormRoomAsync(room, DormNameOverride(name)); } var scenes = await db.RoomScenes @@ -1028,12 +1011,7 @@ public class RoomsController( if (room.IsDormRoom) { - var dormBlobName = await db.DormStates - .Where(d => d.PlayerId == room.CreatorPlayerId) - .Select(d => d.CurrentDataBlobName) - .FirstOrDefaultAsync() - ?? string.Empty; - room = CloneWithCreator(room, room.CreatorPlayerId, dormBlobName); + room = await ShapeDormRoomAsync(room); } var scenes = await db.RoomScenes @@ -1065,6 +1043,13 @@ public class RoomsController( ? requestedName : null; + private async Task ShapeDormRoomAsync(RoomEntity room, string? overrideName = null) + { + var dormBlobName = await rooms.ResolveDormDataBlobNameAsync(room.CreatorPlayerId, room.Id); + var dormName = overrideName ?? await rooms.BuildPersonalDormDisplayNameAsync(room.CreatorPlayerId); + return CloneWithCreator(room, room.CreatorPlayerId, dormBlobName, dormName); + } + public static RoomEntity CloneWithCreator( RoomEntity src, long creatorId, string? overrideBlobName = null, string? overrideName = null) => new() @@ -1165,11 +1150,13 @@ public class RoomsController( // no DeserializeRoomDataBlobCoroutine, the master init // proceeds with default-zero permissions. // - // • Dorm / customisable rooms: DataBlobName is either the + // • Saved dorm / customisable rooms: DataBlobName is the // latest uploaded blob name (RoomEntity.CurrentDataBlobName, - // written by StorageController on /upload) or, before any - // save, "room__v1.dat" — which the catch-all serves - // from RoomDataBlobService (the all-perms default blob). + // written by StorageController on /upload). Unsaved dorms + // deliberately use "" so the baked DormRoom scene loads + // without deserializing the synthetic all-perms blob. The + // role-only default blob made December wait forever on + // StandaloneShapeContainer big-data during first-dorm entry. // • AG-Original rooms: DataBlobName="" → completed-promise // short-circuit → no download → no leaked stale-blob // behaviour → master flow proceeds → spawn fires. Maker @@ -1187,6 +1174,7 @@ public class RoomsController( var customisable = room.IsDormRoom || room.CreatorPlayerId != 1 || editableRro; var dataBlobName = !string.IsNullOrEmpty(room.CurrentDataBlobName) ? room.CurrentDataBlobName + : room.IsDormRoom ? "" : customisable ? $"room_{room.Id}_v1.dat" : ""; // Multi-scene rooms (imported via tools/import-room.ps1) carry one @@ -1209,6 +1197,7 @@ public class RoomsController( DataBlobName = !string.IsNullOrWhiteSpace(overrideDataBlobName) && (overrideSceneId == s.OrderIndex || sceneRows.Count == 1) ? overrideDataBlobName + : room.IsDormRoom ? dataBlobName : s.DataBlobName, MaxPlayers = s.MaxPlayers, CanMatchmakeInto = s.CanMatchmakeInto, @@ -1303,6 +1292,7 @@ public class RoomsController( var customisable = room.IsDormRoom || room.CreatorPlayerId != 1 || editableRro; var dataBlobName = !string.IsNullOrEmpty(room.CurrentDataBlobName) ? room.CurrentDataBlobName + : room.IsDormRoom ? "" : customisable ? $"room_{room.Id}_v1.dat" : ""; var updatedAt = (room.UpdatedAt == default ? DateTime.UtcNow : room.UpdatedAt) .ToString("yyyy-MM-ddTHH:mm:ssZ"); @@ -1315,6 +1305,7 @@ public class RoomsController( Name = s.Name, DataBlob = !string.IsNullOrWhiteSpace(overrideDataBlobName) && sceneRows.Count == 1 ? overrideDataBlobName + : room.IsDormRoom ? dataBlobName : s.DataBlobName, DataSavedAt = (s.DataModifiedAt == default ? DateTime.UtcNow : s.DataModifiedAt) .ToString("yyyy-MM-ddTHH:mm:ssZ"), diff --git a/DorkNet.Server/Controllers/Match/GoToController.cs b/DorkNet.Server/Controllers/Match/GoToController.cs index 74faff8..4bd58e5 100644 --- a/DorkNet.Server/Controllers/Match/GoToController.cs +++ b/DorkNet.Server/Controllers/Match/GoToController.cs @@ -1166,16 +1166,15 @@ public class GoToController( if (isDorm && dormOwnerId is long ownerId) { - var dormBlob = await db.DormStates.AsNoTracking() - .Where(d => d.PlayerId == ownerId) - .Select(d => d.CurrentDataBlobName) - .FirstOrDefaultAsync(); + var dormBlob = await rooms.ResolveDormDataBlobNameAsync(ownerId, room.Id); if (!string.IsNullOrWhiteSpace(dormBlob)) return dormBlob; } if (!string.IsNullOrWhiteSpace(room.CurrentDataBlobName)) return room.CurrentDataBlobName; - var customisable = room.IsDormRoom || room.CreatorPlayerId != 1; + if (room.IsDormRoom) return string.Empty; + + var customisable = room.CreatorPlayerId != 1; return customisable ? $"room_{room.Id}_v1.dat" : string.Empty; } diff --git a/DorkNet.Server/Controllers/Match/MatchPlayerController.cs b/DorkNet.Server/Controllers/Match/MatchPlayerController.cs index 5ce2a09..b986621 100644 --- a/DorkNet.Server/Controllers/Match/MatchPlayerController.cs +++ b/DorkNet.Server/Controllers/Match/MatchPlayerController.cs @@ -397,10 +397,7 @@ public class MatchPlayerController( if (room.IsDormRoom) { - var dormBlob = await db.DormStates.AsNoTracking() - .Where(d => d.PlayerId == playerId) - .Select(d => d.CurrentDataBlobName) - .FirstOrDefaultAsync(); + var dormBlob = await rooms.ResolveDormDataBlobNameAsync(playerId, room.Id); if (!string.IsNullOrWhiteSpace(dormBlob)) return dormBlob; } @@ -415,7 +412,9 @@ public class MatchPlayerController( if (!string.IsNullOrWhiteSpace(room.CurrentDataBlobName)) return room.CurrentDataBlobName; - var customisable = room.IsDormRoom || room.CreatorPlayerId != 1; + if (room.IsDormRoom) return string.Empty; + + var customisable = room.CreatorPlayerId != 1; return customisable ? $"room_{room.Id}_v1.dat" : string.Empty; } } diff --git a/DorkNet.Server/Services/RoomService.cs b/DorkNet.Server/Services/RoomService.cs index c0d4a49..0dbcfee 100644 --- a/DorkNet.Server/Services/RoomService.cs +++ b/DorkNet.Server/Services/RoomService.cs @@ -450,15 +450,17 @@ public class RoomService(DorkNetDbContext db) // RoomsController.Details substitutes into the dorm response so // each player loads THEIR save instead of whoever-saved-last's). // Both are idempotent: we no-op if either side already exists, - // and we always end with both rows present so the watch's - // GetRoomData → /room/{blobName} flow has a deterministic - // CurrentDataBlobName to fetch (empty string until the player - // saves; CdnController.Serve treats empty as "return the - // default dorm bytes"). + // and we always end with both rows present. Empty CurrentDataBlobName + // means "no saved dorm yet"; the details/goto paths keep the blob + // name empty so the watch uses the baked DormRoom scene. const string DormLocationId = "76d98498-60a1-430c-ab76-b54a29b7a163"; var existing = await db.Rooms - .FirstOrDefaultAsync(r => r.IsDormRoom && r.CreatorPlayerId == playerId); + .Where(r => r.IsDormRoom && r.CreatorPlayerId == playerId) + .OrderByDescending(r => r.CurrentDataBlobName != "") + .ThenByDescending(r => r.UpdatedAt) + .ThenByDescending(r => r.Id) + .FirstOrDefaultAsync(); var dormStateExists = await db.DormStates .AnyAsync(d => d.PlayerId == playerId); @@ -470,15 +472,24 @@ public class RoomService(DorkNetDbContext db) // infinite-reloading. Every dorm renders the same canonical scene, so // force any non-canonical dorm row (and its saved RoomScene rows) back // to it. Idempotent — runs on the next goto/heartbeat for that player. + var existingChanged = false; if (existing is not null && existing.LocationReplicationId != DormLocationId) { existing.LocationReplicationId = DormLocationId; await db.RoomScenes .Where(s => s.RoomId == existing.Id && s.RoomSceneLocationId != DormLocationId) .ExecuteUpdateAsync(u => u.SetProperty(s => s.RoomSceneLocationId, DormLocationId)); - await db.SaveChangesAsync(); + existingChanged = true; } + if (existing is not null && ShouldRepairDormName(existing.Name, playerId)) + { + existing.Name = await BuildUniquePersonalDormNameAsync(playerId, existing.Id); + existingChanged = true; + } + + if (existingChanged) await db.SaveChangesAsync(); + if (existing is not null && dormStateExists) return existing; @@ -520,11 +531,9 @@ public class RoomService(DorkNetDbContext db) { PlayerId = playerId, // Empty until the player saves their dorm via Maker - // Pen. CdnController.Serve sees the empty blob name in - // the Details response and serves - // RoomDataBlobService.GetDefaultBlob() back to the - // watch — boots into the stock dorm scene without a - // 500. + // Pen. Details/goto emit an empty blob name so the + // watch short-circuits persisted-room download and boots + // into the stock baked dorm scene. CurrentDataBlobName = string.Empty, }); } @@ -564,6 +573,160 @@ public class RoomService(DorkNetDbContext db) // EnsurePersonalDorm throws we still backfilled the rest.) } + /// + /// Resolve the saved room-data blob for a personal dorm and repair stale + /// pointers left by older builds. Some existing dorm rows only have the + /// real save in RoomDataBlobs or RoomScenes while DormStates/Rooms still + /// point at the synthetic room_{id}_v1.dat fallback. + /// + public async Task ResolveDormDataBlobNameAsync(long playerId, long dormRoomId) + { + var dormState = await db.DormStates + .FirstOrDefaultAsync(d => d.PlayerId == playerId); + if (await IsUsableDormBlobNameAsync(dormState?.CurrentDataBlobName, dormRoomId)) + return dormState!.CurrentDataBlobName; + + var room = await db.Rooms + .FirstOrDefaultAsync(r => r.Id == dormRoomId && r.IsDormRoom); + if (await IsUsableDormBlobNameAsync(room?.CurrentDataBlobName, dormRoomId)) + { + await RepairDormCurrentBlobAsync(playerId, dormRoomId, room!.CurrentDataBlobName); + return room.CurrentDataBlobName; + } + + var entrySceneBlob = await db.RoomScenes.AsNoTracking() + .Where(s => s.RoomId == dormRoomId && s.OrderIndex == 0) + .Select(s => s.DataBlobName) + .FirstOrDefaultAsync(); + if (await IsUsableDormBlobNameAsync(entrySceneBlob, dormRoomId)) + { + await RepairDormCurrentBlobAsync(playerId, dormRoomId, entrySceneBlob!); + return entrySceneBlob!; + } + + var blobPrefix = $"dorm_p{playerId}_v"; + var latestDormBlob = await db.RoomDataBlobs.AsNoTracking() + .Where(b => b.RoomId == dormRoomId + && b.UploadedByPlayerId == playerId + && b.BlobName.StartsWith(blobPrefix)) + .OrderByDescending(b => b.UploadedAt) + .ThenByDescending(b => b.Id) + .Select(b => b.BlobName) + .FirstOrDefaultAsync() + ?? await db.RoomDataBlobs.AsNoTracking() + .Where(b => b.UploadedByPlayerId == playerId + && b.BlobName.StartsWith(blobPrefix)) + .OrderByDescending(b => b.UploadedAt) + .ThenByDescending(b => b.Id) + .Select(b => b.BlobName) + .FirstOrDefaultAsync(); + + if (!string.IsNullOrWhiteSpace(latestDormBlob)) + { + await RepairDormCurrentBlobAsync(playerId, dormRoomId, latestDormBlob); + return latestDormBlob; + } + + return string.Empty; + } + + private async Task IsUsableDormBlobNameAsync(string? blobName, long dormRoomId) + { + if (string.IsNullOrWhiteSpace(blobName)) return false; + + if (!blobName.StartsWith($"room_{dormRoomId}_v", StringComparison.OrdinalIgnoreCase)) + return true; + + return await db.RoomDataBlobs.AsNoTracking().AnyAsync(b => b.BlobName == blobName); + } + + private async Task RepairDormCurrentBlobAsync(long playerId, long dormRoomId, string blobName) + { + var changed = false; + + var dormState = await db.DormStates.FirstOrDefaultAsync(d => d.PlayerId == playerId); + if (dormState is null) + { + dormState = new DormStateEntity { PlayerId = playerId }; + db.DormStates.Add(dormState); + changed = true; + } + + if (!string.Equals(dormState.CurrentDataBlobName, blobName, StringComparison.Ordinal)) + { + dormState.CurrentDataBlobName = blobName; + dormState.UpdatedAt = DateTime.UtcNow; + changed = true; + } + + var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == dormRoomId && r.IsDormRoom); + if (room is not null && !string.Equals(room.CurrentDataBlobName, blobName, StringComparison.Ordinal)) + { + room.CurrentDataBlobName = blobName; + room.UpdatedAt = DateTime.UtcNow; + changed = true; + } + + var entryScene = await db.RoomScenes + .FirstOrDefaultAsync(s => s.RoomId == dormRoomId && s.OrderIndex == 0); + if (entryScene is not null && !string.Equals(entryScene.DataBlobName, blobName, StringComparison.Ordinal)) + { + entryScene.DataBlobName = blobName; + entryScene.DataModifiedAt = DateTime.UtcNow; + changed = true; + } + + if (changed) await db.SaveChangesAsync(); + } + + private async Task BuildUniquePersonalDormNameAsync(long playerId, long roomId) + { + var baseName = await BuildPersonalDormDisplayNameAsync(playerId); + var candidate = baseName; + var suffix = 2; + while (await db.Rooms.AsNoTracking().AnyAsync(r => r.Id != roomId && r.Name == candidate)) + { + var suffixText = $"_{suffix++}"; + candidate = TrimRoomName(baseName, suffixText.Length) + suffixText; + } + + return candidate; + } + + public async Task BuildPersonalDormDisplayNameAsync(long playerId) + { + var player = await db.Players.AsNoTracking() + .Where(p => p.Id == playerId) + .Select(p => new { p.DisplayName, p.Username }) + .FirstOrDefaultAsync(); + var rawName = !string.IsNullOrWhiteSpace(player?.DisplayName) + ? player!.DisplayName + : !string.IsNullOrWhiteSpace(player?.Username) + ? player!.Username + : $"Player{playerId}"; + var cleaned = CleanRoomNameStem(rawName); + return TrimRoomName($"{cleaned}_dorm"); + } + + private static bool ShouldRepairDormName(string? name, long playerId) => + string.IsNullOrWhiteSpace(name) + || Guid.TryParse(name, out _) + || string.Equals(name, $"Dorm_{playerId}", StringComparison.OrdinalIgnoreCase); + + private static string CleanRoomNameStem(string raw) + { + var cleaned = new string(raw + .Select(ch => char.IsLetterOrDigit(ch) || ch is '_' or '-' ? ch : '_') + .ToArray()).Trim('_'); + return string.IsNullOrWhiteSpace(cleaned) ? "Player" : cleaned; + } + + private static string TrimRoomName(string name, int reservedSuffixLength = 0) + { + var max = Math.Max(1, 128 - reservedSuffixLength); + return name.Length <= max ? name : name[..max]; + } + /// /// Hot-list query — drives `api/rooms/v1/hot?roomScoreType=...&tags=...`. /// Filters by tag prefix-match and orders by HotScore desc.