using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using System.Text.Json; using DorkNet.Models.Notification; using DorkNet.Server.Auth; using DorkNet.Server.Controllers.Match; using DorkNet.Server.Data; using DorkNet.Server.Data.Entities; using DorkNet.Server.Services; namespace DorkNet.Server.Controllers.API.Rooms.V2; /// /// api.rec.net/api/rooms/* — DB-backed implementation of the watch's /// Rooms tab. /// /// Replaces the previous stubs in MiscStubsController for: /// v1/hot, v1/filters, v1/tags, v1/search, v2/baserooms, v2/myrooms, /// v2/mybookmarks, v2/mymoderated, v2/mysubscribed, v2/myrecent, /// v2/name/{name}, v2/{id}, v3/{id}, v3/details/{id}, v4/details/{id}, /// roomserver/rooms/{id}, v1/personaldetails/{id}, v2/personaldetails/{id}. /// /// All endpoints serialize Rooms via RoomService.ToWireRoom which matches /// Room.Deserialize at RVA 0x114E430 (PascalCase keys, all 16 required /// fields plus optional VR-low / mobile / mic-mute flags). /// [ApiController] public class RoomsController( RoomService rooms, PlaylistService playlists, DorkNetDbContext db, PlayerPresenceService presence, OnlinePresenceService onlinePresence, DomainConfig domain, NotificationService notifications, ServerSettingsService serverSettings, ILogger logger) : ControllerBase { private long? CurrentPlayerId => ControllerBaseExtensions.CurrentPlayerId(this); // ── Browse / Hot / Search ──────────────────────────────────────────── /// /// `Rooms.GetHotRooms(roomScoreType, tags)` — the watch's "Trending" tab. /// `roomScoreType` is ignored (we always sort by HotScore). `tags` is /// passed through as a substring filter against the comma-separated /// TagsCsv column (e.g. `#community`, `#recroomoriginal`). /// [HttpGet("api/rooms/v1/hot")] [HttpGet("rooms/hot")] public async Task Hot( [FromQuery] string? roomScoreType, [FromQuery] string? tags) => Ok((await rooms.HotAsync(tags)).Select(RoomService.ToWireRoom).ToList()); /// /// GET /roomserver/rooms/recommendations — the home-tab /// "Recommended" carousel. Wire shape is NOT a flat /// List<Room>; the watch's /// EJDCNGBEICB.JDKLBNBIEAH at EJDCNGBEICB.txt:1349,1430 /// returns IPromise<List<ANNKHNFLMNP>> and /// at /// ANNKHNFLMNP.txt:43-97 reads SeedRoom (sub-object, /// KLCOGEIGEBJ i.e. full Room) and Rooms (list of /// Rooms) as required keys. Returning [{room}, …] raised /// KeyNotFoundException: Failed to find key 'Rooms' when /// deserializing object of type KLCOGEIGEBJ. /// /// We group the hot list into one "card" per room — that gives the /// watch one row per featured room with that room as its own /// SeedRoom and a single-element Rooms list. Cheap, semantically /// correct, and avoids the deserializer-crashing empty-Rooms /// edge case the watch hits when a group has no rooms. /// [HttpGet("roomserver/rooms/recommendations")] [HttpGet("rooms/recommendations")] public async Task Recommendations( [FromQuery(Name = "splitTestId")] int? splitTestId, [FromQuery(Name = "splitTestValue")] int? splitTestValue) { var hot = await rooms.HotAsync(null, take: 12); if (hot.Count == 0) return Ok(Array.Empty()); var roomIds = hot.Select(r => r.Id).ToList(); var sceneRows = await db.RoomScenes .Where(s => roomIds.Contains(s.RoomId)) .OrderBy(s => s.OrderIndex) .ToListAsync(); var scenesByRoom = sceneRows .GroupBy(s => s.RoomId) .ToDictionary(g => g.Key, g => (IReadOnlyList)g.ToList()); var wireRooms = hot .Select(r => BuildRoomServerDetails(r, scenesByRoom.GetValueOrDefault(r.Id))) .ToList(); // One ANNKHNFLMNP entry per room: SeedRoom = the room itself, // Rooms = [that room]. The watch enumerates the outer list to // build the row list; each row shows its SeedRoom as headline. var result = wireRooms.Select(room => new Dictionary(StringComparer.Ordinal) { ["SeedRoom"] = room, ["Rooms"] = new List { room }, }).ToList(); return Ok(result); } /// /// `Rooms.SearchForRooms(query)` — free-text room search. /// [HttpGet("api/rooms/v1/search")] [HttpGet("api/rooms/v2/search")] [HttpGet("rooms/search")] public async Task Search( [FromQuery(Name = "query")] string? query, [FromQuery(Name = "value")] string? value) => Ok((await rooms.SearchAsync(query ?? value ?? string.Empty)) .Select(RoomService.ToWireRoom).ToList()); /// GET api/rooms/v2/live — currently-active rooms /// (rooms with at least one player presence). Same shape as Hot /// — order by HotScore as a proxy for "live activity" since we /// don't track instance population separately yet. [HttpGet("api/rooms/v2/live")] public async Task Live([FromQuery] string? roomScoreType) => Ok((await rooms.HotAsync(null)).Select(RoomService.ToWireRoom).ToList()); /// /// `Rooms.GetBaseRooms` — list of "base" rooms used by Rec Room Originals /// to seed the room creation UI. Returns the same set as #recroomoriginal. /// [HttpGet("api/rooms/v2/baserooms")] [HttpGet("api/rooms/v3/baserooms")] [HttpGet("rooms/base")] public async Task BaseRooms() => Ok((await rooms.HotAsync("#recroomoriginal")).Select(RoomService.ToWireRoom).ToList()); /// /// `Rooms.GetFilters` — pinned/popular tag chips shown above the room /// list in the watch. /// // Tag wire format: NO leading '#'. The watch's RoomFilterChip prepends // '#' at render time, so sending "#community" produces "##community" in // the UI. Names stored in the DB also drop the prefix. [HttpGet("api/rooms/v1/filters")] public async Task Filters() { var tags = await serverSettings.GetPlayMenuTagsAsync(); return Ok(new { PinnedFilters = tags.PinnedTags, PopularFilters = tags.PopularTags, }); } [HttpGet("api/rooms/v1/tags")] public async Task Tags() { var tags = await serverSettings.GetPlayMenuTagsAsync(); return Ok(tags.PinnedTags.Concat(tags.PopularTags).Distinct(StringComparer.OrdinalIgnoreCase).ToArray()); } [HttpGet("api/rooms/v1/pinnedtags")] public async Task PinnedTags() => Ok((await serverSettings.GetPlayMenuTagsAsync()).PinnedTags); [HttpGet("api/rooms/v1/populartags")] public async Task PopularTags() => Ok((await serverSettings.GetPlayMenuTagsAsync()).PopularTags); // ── Single room lookups ────────────────────────────────────────────── /// /// `Rooms.GetByName(roomName)` — used when the client constructs the /// share-link target or when /goto/room/{name} runs. Falls back to a /// synthesised "Room_" for unknown names so the deserializer /// doesn't crash on 404. /// [HttpGet("api/rooms/v2/name/{roomName}")] [HttpGet("api/rooms/v3/name/{roomName}")] public async Task ByName(string roomName) { var r = await rooms.GetByNameAsync(roomName); return Ok(RoomService.ToWireRoom(r ?? Synthetic(roomName))); } /// /// `Rooms.GetById(roomId)` — same shape as ByName. /// [HttpGet("api/rooms/v2/{roomId:long}")] [HttpGet("api/rooms/v3/{roomId:long}")] [HttpGet("rooms/{roomId:long}")] public async Task ById(long roomId) { var r = await rooms.GetByIdAsync(roomId); return Ok(RoomService.ToWireRoom(r ?? Synthetic($"Room_{roomId}", roomId))); } // ── RoomDetails (the boot-critical one) ───────────────────────────── // // /v4/details/{roomId} is hit *during* the matchmaking goto flow. // RoomDetails.Deserialize at RVA 0x114C580 expects PascalCase keys: // Room (object), Scenes (list), CoOwners/InvitedCoOwners, // Moderators/InvitedModerators, Hosts/InvitedHosts (all int lists), // CheerCount, FavoriteCount, VisitCount, Tags. // // Scenes is populated for every room. The persistence coroutine // (`RoomPersistenceManager+`) runs // unconditionally on scene load and dereferences // `Rooms.LocalRoomScene.DataBlobName` on its first MoveNext — so // returning Scenes=[] makes LocalRoomScene null and NPEs the boot. // // The historical "Scenes=[] short-circuits the persistence manager" // comment that used to live here was the reverse of what actually // happens in this build. Confirmed empirically with the [req] log: // /v4/details/1 fires before scene load for the dorm too, so // Rooms.LocalRoom is populated and the coroutine just needs a // populated Scenes[0] to get a non-null LocalRoomScene. /// GET roomserver/rooms/{roomId}/subrooms/{subRoomId}/datahistory /// — drives the watch's "Restore to old version" UI in the room- /// settings tab. Per-subroom (not per-room) — the watch builds the /// URL from the template /// String.Format("rooms/{0}/subrooms/{1}/datahistory", roomId, subRoomId) /// (verified at EJDCNGBEICB.txt:1190); the roomserver/ /// prefix is added by RecNet's URL builder for everything on the /// rooms host (api.localhost proxies it through the same prefix as the /// rest of the rooms API — see all the other roomserver/rooms/... /// routes in this controller). /// /// SubRoomId semantics: the watch's 2020.12 build treats /// "SubRoomId" as the , NOT /// rec.net's long random Guid-like SubRoomId. The heartbeat reports /// subRoom=11 for the 12th scene in RoomDetails.SubRooms[]; /// this endpoint must filter by OrderIndex to match. The zip-importer /// stamps each SubRooms/<scene>/History/ blob's /// with the scene's /// OrderIndex at insert time — the rec.net SubRoomId is captured in /// the sidecar JSON for provenance but isn't what gets indexed. /// /// Wire shape: List<RoomDataHistoryDTO>. The /// watch's DHOCPFIOKHD.PPGFHEDFBEA deserializer reads EXACTLY /// four PascalCase keys (DHOCPFIOKHD.txt:136-150): /// SubRoomId (long, strict GetKey), DataBlob (string), /// SavedByAccountId (long, strict GetKey), CreatedAt /// (DateTime, GetKeyOrDefault). Missing strict keys throw /// KeyNotFoundException in LitJson and the watch shows the /// "Could not retrieve save history from server :-(" toast from /// UIRestoreRoomDialog.txt:1970. Extra keys are ignored. /// /// Pre-2026-05 rows (and any rows the live save flow inserts, /// which doesn't populate SubRoomId yet) have a null SubRoomId and /// surface under every sub-room's history list — back-compat. [HttpGet("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/datahistory")] [HttpGet("rooms/{roomId:long}/subrooms/{subRoomId:long}/datahistory")] public async Task SubRoomDataHistory(long roomId, long subRoomId) { var blobs = await db.RoomDataBlobs .Where(b => b.RoomId == roomId && (b.SubRoomId == subRoomId || b.SubRoomId == null)) // Skip the canonical per-scene blob from the import — the watch // is asking for HISTORY, and serving the current `room___.room` // back to it as an option would make `restore to current` a // confusing no-op entry in the list. .Where(b => !b.BlobName.StartsWith($"room_{roomId}_")) .OrderByDescending(b => b.UploadedAt) .Take(50) .Select(b => new { SubRoomId = subRoomId, DataBlob = b.BlobName, SavedByAccountId = b.UploadedByPlayerId, CreatedAt = b.UploadedAt, }) .ToListAsync(); return Ok(blobs); } /// POST roomserver/rooms/{roomId}/subrooms/{subRoomId}/restoredata /// — the watch's "Go" button in the Restore Room dialog. Body is /// form-urlencoded with a single filename field carrying the /// historical blob name from the datahistory list. /// is RoomSceneEntity.OrderIndex (same convention as the GET above). /// /// Effect: stamp the chosen scene's DataBlobName to the historical /// blob and, when restoring the room's current entry scene, also /// update the room-level CurrentDataBlobName so a fresh /v4/details /// returns the restored bytes too. Then push SubscriptionUpdateRoom /// so any clients already cached on the room reload the scene. /// /// Returns CreateModifyRoomSceneResponse — same shape as /// /api/rooms/v1/datahistory/restore — so the watch's /// ProcessCreateModifyRoomSceneResponse chain can deserialise and /// flip the Restore dialog out of its "applying" state. [HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/restoredata")] [HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/restoredata")] [Authorize] public async Task SubRoomRestoreData( long roomId, long subRoomId, [FromForm] string? filename) { if (string.IsNullOrWhiteSpace(filename)) return BadRequest(new { error = "missing_filename" }); var pid = this.RequireCurrentPlayerId(); var room = await rooms.GetByIdAsync(roomId); if (room is null) return NotFound(new { error = "room_not_found" }); if (room.CreatorPlayerId != pid) return Forbid(); var blob = await db.RoomDataBlobs .FirstOrDefaultAsync(b => b.BlobName == filename && b.RoomId == roomId); if (blob is null) return NotFound(new { error = "blob_not_found", filename }); var sceneRows = await db.RoomScenes .Where(s => s.RoomId == roomId) .OrderBy(s => s.OrderIndex) .ToListAsync(); var sceneRow = sceneRows.FirstOrDefault(s => s.OrderIndex == (int)subRoomId); if (sceneRow is null) return NotFound(new { error = "subroom_not_found", subRoomId }); sceneRow.DataBlobName = filename; sceneRow.DataModifiedAt = DateTime.UtcNow; // Only stamp the room-level CurrentDataBlobName when restoring the // entry scene (OrderIndex 0). Restoring a non-entry sub-room // shouldn't rewrite what the room loads at the front door. if (sceneRow.OrderIndex == 0) { await db.Rooms .Where(r => r.Id == roomId) .ExecuteUpdateAsync(s => s .SetProperty(r => r.CurrentDataBlobName, filename) .SetProperty(r => r.UpdatedAt, DateTime.UtcNow)); } await db.SaveChangesAsync(); var pushRoom = room.IsDormRoom ? CloneWithCreator(room, pid, room.CurrentDataBlobName) : CloneWithCreator(room, room.CreatorPlayerId, room.CurrentDataBlobName); var roomDetailsPayload = BuildRoomDetails(pushRoom, sceneRows, sceneRow.OrderIndex, filename); await notifications.NotifyAsync(pid, PushNotificationId.SubscriptionUpdateRoom, roomDetailsPayload); logger.LogInformation( "[rooms-restore] player={PlayerId} room={RoomId} subRoom={SubRoomId} scene={Scene} blob={Blob}", pid, roomId, subRoomId, sceneRow.Name, filename); // Watch's response deserializer is PPENFJMFPNE — the full Room DTO // (verified at EJDCNGBEICB:6029 in the 2020.12.18 dump). Returning // a bare RoomScene wrapper makes its dict-based decoder throw // KeyNotFoundException ("Failed to restore subroom save: Malformed // Response"). Mirror the /roomserver/rooms/{id} shape — same // payload BuildRoomDetails generates for /v4/details. return Ok(roomDetailsPayload); } /// POST api/rooms/v4/saveData — the watch's /// RecNet.Rooms.UploadLocalRoomSceneData save flow. /// Body shape mirrors RecNet.Rooms+SaveRoomSceneRequest /// (Cpp2IL_CS RecNet/Rooms.cs:858): RoomSceneId, RoomDataFilename, /// InventionUsages[], CreatorActionContext, RequestPlayerId. /// /// Persists the new CurrentDataBlobName on the room /// (and, for dorms, the per-player DormStateEntity) so the next /// /v4/details + cdn fetch loads what just got uploaded. Returns /// a populated RoomScene JSON — that's what the watch's /// continuation callback (b__1 in DisplayClass124_0) expects; /// returning an empty body trips a "HTTP Error 404" path in /// BestHTTP's Future framework even though the HTTP status was /// fine. [HttpPost("api/rooms/v4/saveData")] [Authorize] public async Task SaveData([FromBody] SaveRoomSceneRequest body) => await SaveDataCore(body); private async Task SaveDataCore(SaveRoomSceneRequest body, long? routeRoomId = null) { var pid = this.RequireCurrentPlayerId(); // Resolve the room. The watch sends RoomSceneId (the scene's // OrderIndex per BuildRoomDetails above — 0 for single-scene // rooms). To find the parent room we cross-reference the // player's current presence (which RecordResponseAsync stamps // on every /goto). This is the only signal we have without a // RoomId on the request body. var current = presence.GetRoom(pid); if (current is null && routeRoomId is null) { // No active room — without it we can't pick a target row. // Return a degenerate RoomScene so the deserializer doesn't // throw, but flag CanMatchmakeInto=false so the watch // doesn't think the save succeeded. return Ok(new { RoomSceneId = body.RoomSceneId, RoomId = 0L, RoomSceneLocationId = "76d98498-60a1-430c-ab76-b54a29b7a163", Name = "Home", IsSandbox = false, DataBlobName = body.RoomDataFilename ?? string.Empty, MaxPlayers = 8, CanMatchmakeInto = false, DataModifiedAt = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ"), }); } var roomId = routeRoomId ?? current!.RoomId; var room = await rooms.GetByIdAsync(roomId); if (room is null) return NotFound(new { error = "room_not_found" }); var newBlob = body.EffectiveRoomDataFilename.Trim(); if (string.IsNullOrWhiteSpace(newBlob)) { logger.LogWarning( "[rooms-save] missing blob filename player={PlayerId} room={RoomId} requestedScene={RequestedSceneId} routeRoom={RouteRoomId}", pid, roomId, body.RoomSceneId, routeRoomId); return BadRequest(new { error = "missing_room_data_filename" }); } 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 // send a stale/non-index id while the local room is still a // single-scene dorm/custom room. In that common single-scene // case, stamp the only row anyway; otherwise the pushed // RoomDetails can keep Scenes[0].DataBlobName at v(N) while // Rooms.CurrentDataBlobName is v(N+1), and the post-save reload // reads stale bytes. var sceneRowsForRoom = await db.RoomScenes .Where(s => s.RoomId == roomId) .OrderBy(s => s.OrderIndex) .ToListAsync(); var sceneRow = sceneRowsForRoom.FirstOrDefault(s => s.OrderIndex == (int)body.RoomSceneId) ?? (sceneRowsForRoom.Count == 1 ? sceneRowsForRoom[0] : null); // Dorms: per-player state, keyed by the calling player so two // people in the same dorm room ID save into separate slots. if (room.IsDormRoom) { if (room.CreatorPlayerId != pid) return Forbid(); var dormState = await db.DormStates.FirstOrDefaultAsync(d => d.PlayerId == pid); if (dormState is null) { dormState = new DormStateEntity { PlayerId = pid }; db.DormStates.Add(dormState); } dormState.CurrentDataBlobName = newBlob; dormState.UpdatedAt = savedAt; } else { 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 = savedAt; } await db.Rooms .Where(r => r.Id == roomId) .ExecuteUpdateAsync(s => s .SetProperty(r => r.CurrentDataBlobName, newBlob) .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 // SignalR push before flipping the save state machine out of // SAVING. Without this push the spinner stays "Saving..." forever // even though both HTTP calls returned 200 — verified via the // RoomDetailsUpdatedCallback closure in // RoomPersistenceManager+<>c__DisplayClass174_0. // // For dorms we re-shape with the caller as creator + their own // DormStateEntity blob name so the watch's set_LocalRoomDetails // matches LocalRoomDetails.RoomId and triggers // SafeRaiseRoomDetailsUpdated(roomId). var pushRoom = room; if (room.IsDormRoom) pushRoom = CloneWithCreator(room, pid, newBlob); else pushRoom = CloneWithCreator(room, room.CreatorPlayerId, newBlob); foreach (var scene in sceneRowsForRoom) { if (sceneRow is not null && scene.Id == sceneRow.Id) { scene.DataBlobName = newBlob; scene.DataModifiedAt = savedAt; } } var savedSceneId = sceneRow?.OrderIndex ?? body.RoomSceneId; var roomDetailsPayload = BuildRoomDetails(pushRoom, sceneRowsForRoom, savedSceneId, newBlob); logger.LogInformation( "[rooms-save] saveData player={PlayerId} room={RoomId} requestedScene={RequestedSceneId} savedScene={SavedSceneId} blob={BlobName} sceneRow={SceneRowFound} scenes={SceneCount}", pid, roomId, body.RoomSceneId, savedSceneId, newBlob, sceneRow is not null, sceneRowsForRoom.Count); var activeInstanceId = current is not null && current.RoomId == roomId ? current.RoomInstanceId : 0L; var playersInInstance = activeInstanceId == 0 ? new[] { pid } : onlinePresence.OnlinePlayerIds() .Where(playerId => { var playerRoom = presence.GetRoom(playerId); return playerRoom is not null && playerRoom.RoomId == roomId && playerRoom.RoomInstanceId == activeInstanceId; }) .Append(pid) .Distinct() .ToArray(); logger.LogInformation( "[rooms-save] fanout room update player={PlayerId} room={RoomId} instance={InstanceId} blob={BlobName} recipients={RecipientCount}", pid, roomId, activeInstanceId, newBlob, playersInInstance.Length); var updatedRoomInstances = new Dictionary(); foreach (var playerId in playersInInstance) { var currentPresence = presence.GetRoom(playerId); if (currentPresence is null && playerId == pid && current is not null) { currentPresence = current; } if (currentPresence is null || currentPresence.RoomId != roomId) continue; currentPresence.DataBlob = newBlob; presence.SetRoom(playerId, currentPresence); updatedRoomInstances[playerId] = currentPresence; } // SubscriptionUpdateRoom delivers the new RoomDetails (with // Scenes[0].DataBlobName = newBlob). Rooms.OnSubscriptionUpdateRoom // → StoreRoomDetailsInCache → set_LocalRoomDetails iterates // Scenes for one matching RoomSceneId == LocalSubRoomId and writes // LocalRoomScene to that scene, which is what DownloadRoomData // BlobCoroutine reads when PostSaveReloading runs. foreach (var playerId in playersInInstance) { await notifications.NotifyAsync(playerId, PushNotificationId.SubscriptionUpdateRoom, roomDetailsPayload); } // RoomInstanceUpdate keeps the matchmaking-side dataBlob and any // friends-list "in room X" indicators in sync. No nudge — live // doesn't send one, and the fake sub-room id confuses the watch's // OnLocalGameSessionUpdated scene re-resolve. foreach (var (playerId, currentPresence) in updatedRoomInstances) { await notifications.NotifyAsync( playerId, PushNotificationId.SubscriptionUpdateGameSession, currentPresence); } // PresenceUpdate keeps the next heartbeat aligned with the blob // applied through the room-details push above. foreach (var (playerId, currentPresence) in updatedRoomInstances) { var presencePayload = new { playerId = (int)playerId, statusVisibility = 1, deviceClass = 0, vrMovementMode = 0, roomInstance = new { roomInstanceId = currentPresence.RoomInstanceId, roomId = currentPresence.RoomId, subRoomId = currentPresence.SubRoomId, roomInstanceType = currentPresence.RoomInstanceType, location = currentPresence.Location, dataBlob = currentPresence.DataBlob, eventId = currentPresence.EventId, clubId = currentPresence.ClubId, roomCode = currentPresence.RoomCode, photonRegionId = currentPresence.PhotonRegionId, photonRoomId = currentPresence.PhotonRoomId, name = currentPresence.Name, maxCapacity = currentPresence.MaxCapacity, isFull = currentPresence.IsFull, isPrivate = currentPresence.IsPrivate, isInProgress = currentPresence.IsInProgress, EncryptVoiceChat = currentPresence.EncryptVoiceChat, }, isOnline = true, appVersion = 20201210, }; await notifications.NotifyAsync(playerId, PushNotificationId.SubscriptionUpdatePresence, presencePayload); } return Ok(new { RoomSceneId = savedSceneId, RoomId = roomId, RoomSceneLocationId = sceneRow?.RoomSceneLocationId ?? room.LocationReplicationId, Name = sceneRow?.Name ?? "Home", IsSandbox = sceneRow?.IsSandbox ?? false, DataBlobName = newBlob, MaxPlayers = sceneRow?.MaxPlayers ?? 8, CanMatchmakeInto = sceneRow?.CanMatchmakeInto ?? true, DataModifiedAt = savedAt.ToString("yyyy-MM-ddTHH:mm:ssZ"), }); } public class SaveRoomSceneRequest { public long RoomSceneId { get; set; } public string? RoomDataFilename { get; set; } [System.Text.Json.Serialization.JsonPropertyName("filename")] public string? Filename { get; set; } public List? InventionUsages { get; set; } public string? InventionUsageBase64 { get; set; } public CreatorActionContextDto? CreatorActionContext { get; set; } public long RequestPlayerId { get; set; } public int SaveRequestPlayerId { get; set; } public string EffectiveRoomDataFilename => RoomDataFilename ?? Filename ?? string.Empty; } public class CreatorActionContextDto { public bool IsTeachableMomentRunning { get; set; } } /// POST api/rooms/v1/restore — switch a room back to a /// previous save. Body: roomId, blobName (or version id). Updates /// the player's DormStateEntity (for dorms) or RoomEntity /// CurrentDataBlobName (for owned rooms) so the next visit loads /// the historical bytes. /// POST api/rooms/v1/datahistory/restore — restore a /// room to a previous save. /// /// Request body: RestoreDataHistoryRequest serialized by /// Unity's JsonUtility.ToJson, which uses C# field names. /// Verified at Cpp2IL_ISIL/.../Rooms.txt:13513-14 — the only /// field on the request type is the long history id at offset +16, /// and Unity's serializer names it roomDataHistoryId. We /// look up by that id, find its /// parent room, and rewrite CurrentDataBlobName (and the /// dorm-state row for personal dorms) so the next room reload /// downloads the historical bytes. /// /// Response: CreateModifyRoomSceneResponse — keys are /// PascalCase per Cpp2IL_ISIL/.../Rooms_NestedType_CreateModifyRoomSceneResponse.txt: /// Result (int, CreateModifyRoomStatus: 0=Success), and /// RoomScene (the restored scene). The watch's /// ProcessCreateModifyRoomSceneResponse chain expects both; /// returning a bare {restored: "..."} like the old impl makes /// the watch's deserializer throw and the restore silently fails. /// The legacy form-urlencoded path stays under /// /api/rooms/v1/restore for the admin SPA's restore UI. /// public sealed class RestoreDataHistoryRequest { // Unity JsonUtility serializes C# private backing fields by // their declared name. The watch's request type has a single // field which Unity emits as "roomDataHistoryId" (camelCase) — // [JsonPropertyName] here matches that wire key explicitly so // System.Text.Json picks it up regardless of any future global // naming policy. [System.Text.Json.Serialization.JsonPropertyName("roomDataHistoryId")] public long RoomDataHistoryId { get; set; } } [HttpPost("api/rooms/v1/datahistory/restore")] [Authorize] public async Task RestoreDataHistory([FromBody] RestoreDataHistoryRequest body) { var pid = this.RequireCurrentPlayerId(); var blob = await db.RoomDataBlobs.FirstOrDefaultAsync(b => b.Id == body.RoomDataHistoryId); if (blob is null) return NotFound(new { error = "version_not_found" }); var room = await rooms.GetByIdAsync(blob.RoomId); if (room is null) return NotFound(new { error = "room_not_found" }); // Ownership: only the dorm owner / room creator can restore. // Admin override is intentionally NOT included here — restore // is destructive (overrides the player's chosen state) so we // require the actual creator. if (room.CreatorPlayerId != pid) return Forbid(); if (room.IsDormRoom) { var dormState = await db.DormStates.FirstOrDefaultAsync(d => d.PlayerId == pid); if (dormState is null) { dormState = new DormStateEntity { PlayerId = pid }; db.DormStates.Add(dormState); } dormState.CurrentDataBlobName = blob.BlobName; dormState.UpdatedAt = DateTime.UtcNow; } await db.Rooms .Where(r => r.Id == room.Id) .ExecuteUpdateAsync(s => s .SetProperty(r => r.CurrentDataBlobName, blob.BlobName) .SetProperty(r => r.UpdatedAt, DateTime.UtcNow)); await db.SaveChangesAsync(); // Push the same SubscriptionUpdateRoom the save flow sends so // the watch's StoreRoomDetailsInCache → SafeRaiseRoomDetailsUpdated // chain fires and any open room-detail panels refresh. var pushRoom = room.IsDormRoom ? CloneWithCreator(room, pid, blob.BlobName) : CloneWithCreator(room, room.CreatorPlayerId, blob.BlobName); var allScenes = await db.RoomScenes .Where(s => s.RoomId == room.Id) .OrderBy(s => s.OrderIndex) .ToListAsync(); var roomDetailsPayload = BuildRoomDetails(pushRoom, allScenes, 0L, blob.BlobName); await notifications.NotifyAsync(pid, PushNotificationId.SubscriptionUpdateRoom, roomDetailsPayload); // CreateModifyRoomSceneResponse shape. RoomScene fields match // RecNet.RoomScene.Deserialize (PascalCase, RoomSceneId, // RoomId, RoomSceneLocationId, Name, IsSandbox, DataBlobName, // MaxPlayers, CanMatchmakeInto, DataModifiedAt). return Ok(new { Result = 0, // CreateModifyRoomStatus.Success RoomScene = new { RoomSceneId = 0L, RoomId = room.Id, RoomSceneLocationId = room.LocationReplicationId, Name = room.Name, IsSandbox = false, DataBlobName = blob.BlobName, MaxPlayers = 8, CanMatchmakeInto = true, DataModifiedAt = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ"), }, }); } /// Legacy form-urlencoded restore — kept for the admin SPA's /// "Restore version" picker which posts {roomId, blobName} fields. /// New callers (the watch) use the JSON variant above. [HttpPost("api/rooms/v1/restore")] [Authorize] public async Task RestoreVersionLegacy( [FromForm] long roomId, [FromForm] string? blobName, [FromForm] long version = 0) { var pid = this.RequireCurrentPlayerId(); var room = await rooms.GetByIdAsync(roomId); if (room is null) return NotFound(new { error = "room_not_found" }); var blob = !string.IsNullOrEmpty(blobName) ? await db.RoomDataBlobs.FirstOrDefaultAsync(b => b.BlobName == blobName && b.RoomId == roomId) : await db.RoomDataBlobs.FirstOrDefaultAsync(b => b.Id == version && b.RoomId == roomId); if (blob is null) return NotFound(new { error = "version_not_found" }); if (room.CreatorPlayerId != pid) return Forbid(); if (room.IsDormRoom) { var dormState = await db.DormStates.FirstOrDefaultAsync(d => d.PlayerId == pid); if (dormState is null) { dormState = new DormStateEntity { PlayerId = pid }; db.DormStates.Add(dormState); } dormState.CurrentDataBlobName = blob.BlobName; dormState.UpdatedAt = DateTime.UtcNow; } await db.Rooms .Where(r => r.Id == roomId) .ExecuteUpdateAsync(s => s .SetProperty(r => r.CurrentDataBlobName, blob.BlobName) .SetProperty(r => r.UpdatedAt, DateTime.UtcNow)); await db.SaveChangesAsync(); return Ok(new { restored = blob.BlobName }); } /// GET api/rooms/v{1,2}/personaldetails/{roomId} — /// per-caller bookmark/cheer flags for one room. Wire shape verified /// against RecNet.PersonalRoomDetails.Deserialize /// (Cpp2IL_ISIL/.../PersonalRoomDetails.txt:85-91): /// IsCheering - bool, REQUIRED (Util.GetKey, throws if missing) /// IsBookmarked - bool, REQUIRED /// Both keys are PascalCase. Returning the full room-details payload /// here (which is what we used to do) makes the deserialiser throw /// KeyNotFoundException for IsCheering, and that blocks the entire /// /goto/* flow because RecNet.Rooms.GetPersonalRoomDetails is /// awaited before SessionManager.JoinRoom can run. [HttpGet("api/rooms/v2/personaldetails/{roomId:long}")] [HttpGet("api/rooms/v1/personaldetails/{roomId:long}")] public async Task PersonalDetails(long roomId) { var pid = CurrentPlayerId ?? 0; bool cheering = false, bookmarked = false; if (pid > 0) { cheering = await db.Cheers.AnyAsync(c => c.TargetRoomId == roomId && c.FromPlayerId == pid); bookmarked = await db.RoomBookmarks.AnyAsync(b => b.RoomId == roomId && b.PlayerId == pid); } return Ok(new { IsCheering = cheering, IsBookmarked = bookmarked }); } /// /// GET /roomserver/rooms/{id}/interactionby/me — sibling of /// personaldetails but DIFFERENT wire shape. Per /// EJDCNGBEICB.txt:8219,8266 the deserialiser is /// CJODCLDGFCF, which reads Cheered + Favorited /// (PascalCase, both bool, both required — verified at /// CJODCLDGFCF.txt:118,123). Sharing the /// {IsCheering, IsBookmarked} payload with personaldetails /// throws KeyNotFoundException on the watch and stalls the /// room-tile UI. /// [HttpGet("roomserver/rooms/{roomId:long}/interactionby/me")] [HttpGet("rooms/{roomId:long}/interactionby/me")] public async Task RoomInteractionByMe(long roomId) { var pid = CurrentPlayerId ?? 0; bool cheered = false, favorited = false; if (pid > 0) { cheered = await db.Cheers.AnyAsync(c => c.TargetRoomId == roomId && c.FromPlayerId == pid); favorited = await db.RoomBookmarks.AnyAsync(b => b.RoomId == roomId && b.PlayerId == pid); } return Ok(new { Cheered = cheered, Favorited = favorited }); } /// /// GET /roomserver/playlists/{id}/interactionby/me — same /// CJODCLDGFCF wire shape as the room variant. Per /// EJDCNGBEICB.txt:8600 the URL is /// "playlists/{0}/interactionby/me". Reads from /// ; an anonymous caller /// (no JWT) reads as (false, false) so the watch's pre-login /// browse renders cleanly. /// [HttpGet("roomserver/playlists/{playlistId:long}/interactionby/me")] [HttpGet("playlists/{playlistId:long}/interactionby/me")] public async Task PlaylistInteractionByMe(long playlistId) { var pid = CurrentPlayerId ?? 0; if (pid <= 0) return Ok(new { Cheered = false, Favorited = false }); var (cheered, favorited) = await playlists.InteractionForAsync(playlistId, pid); return Ok(new { Cheered = cheered, Favorited = favorited }); } [HttpGet("api/rooms/v4/details/{roomId:long}")] [HttpGet("api/rooms/v3/details/{roomId:long}")] [HttpGet("roomserver/rooms/{roomId:long}")] public async Task Details(long roomId) { var r = await rooms.GetByIdAsync(roomId); var room = r ?? Synthetic($"Room_{roomId}", roomId); // Dorm-specific shaping: each player has their own first-class // dorm row. Only the dorm's actual creator should see Creator // role / Maker Pen. Guests loading someone else's dorm must see // the owner as CreatorPlayerId, while still receiving the // 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); } var scenes = await db.RoomScenes .Where(s => s.RoomId == room.Id) .OrderBy(s => s.OrderIndex) .ToListAsync(); var roomRoles = await db.RoomRoles .Where(r => r.RoomId == room.Id) .ToListAsync(); if (Request.Path.StartsWithSegments("/roomserver", StringComparison.OrdinalIgnoreCase)) return Ok(BuildRoomServerDetails(room, scenes, roles: roomRoles)); return Ok(BuildRoomDetails(room, scenes, roles: roomRoles)); } [HttpGet("roomserver/rooms")] [HttpGet("rooms")] public async Task RoomServerDetailsByName([FromQuery(Name = "name")] string? roomName) { if (string.IsNullOrWhiteSpace(roomName)) return Ok(Array.Empty()); var pid = CurrentPlayerId ?? 0; RoomEntity? room; if (roomName.Equals("DormRoom", StringComparison.OrdinalIgnoreCase) && pid > 0) { room = await rooms.EnsurePersonalDormAsync(pid); } else { room = await rooms.GetByNameAsync(roomName); } 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)); } var scenes = await db.RoomScenes .Where(s => s.RoomId == room.Id) .OrderBy(s => s.OrderIndex) .ToListAsync(); var roomRoles = await db.RoomRoles .Where(r => r.RoomId == room.Id) .ToListAsync(); return Ok(BuildRoomServerDetails(room, scenes, roles: roomRoles)); } /// GET /roomserver/rooms/bulk?name=X&name=Y or /// ?id=1&id=2 — bulk room cache lookup. Decomp-verified /// in EJDCNGBEICB: the Int64 overload writes query key id /// and the string overload writes name. Returns the same /// per-room object that /roomserver/rooms/{id} emits; /// unresolved entries are silently skipped (watch handles a shorter /// list fine, but a 404 wedges the room browser). [HttpGet("roomserver/rooms/bulk")] [HttpGet("rooms/bulk")] public async Task RoomServerBulkByName() { var names = Request.Query["name"].Where(n => !string.IsNullOrWhiteSpace(n)).ToArray(); var ids = Request.Query["id"] .Where(v => !string.IsNullOrWhiteSpace(v)) .SelectMany(v => v!.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) .Select(v => long.TryParse(v, out var id) ? id : 0) .Where(id => id > 0) .Distinct() .ToArray(); if (names.Length == 0 && ids.Length == 0) return Ok(Array.Empty()); var pid = CurrentPlayerId ?? 0; var results = new List(names.Length + ids.Length); var emitted = new HashSet(); foreach (var raw in names) { var name = raw!.Trim(); RoomEntity? room; if (name.Equals("DormRoom", StringComparison.OrdinalIgnoreCase) && pid > 0) room = await rooms.EnsurePersonalDormAsync(pid); else room = await rooms.GetByNameAsync(name); 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)); } var scenes = await db.RoomScenes .Where(s => s.RoomId == room.Id) .OrderBy(s => s.OrderIndex) .ToListAsync(); var roomRoles = await db.RoomRoles .Where(r => r.RoomId == room.Id) .ToListAsync(); results.Add(BuildRoomServerDetails(room, scenes, roles: roomRoles)); emitted.Add(room.Id); } foreach (var id in ids) { if (!emitted.Add(id)) continue; var room = await rooms.GetByIdAsync(id); if (room is null) continue; 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); } var scenes = await db.RoomScenes .Where(s => s.RoomId == room.Id) .OrderBy(s => s.OrderIndex) .ToListAsync(); var roomRoles = await db.RoomRoles .Where(r => r.RoomId == room.Id) .ToListAsync(); results.Add(BuildRoomServerDetails(room, scenes, roles: roomRoles)); } return Ok(results); } /// /// Shallow clone of a RoomEntity with a different CreatorPlayerId /// and (optionally) a different CurrentDataBlobName. Used to give /// the per-caller dorm the appearance of being authored by the /// caller and pointing at the caller's own most recent save. /// Doesn't touch the DB — purely a response-shaping utility. /// /// When a dorm was resolved via the magic name "DormRoom", /// returns that requested name (verbatim, so casing matches what the /// watch will look the cache up by) to stamp onto the wire room. /// Returns null for any other request name so the dorm keeps its real /// "Dorm_{playerId}" name in by-id / direct-name responses. private static string? DormNameOverride(string requestedName) => requestedName.Equals("DormRoom", StringComparison.OrdinalIgnoreCase) ? requestedName : null; public static RoomEntity CloneWithCreator( RoomEntity src, long creatorId, string? overrideBlobName = null, string? overrideName = null) => new() { Id = src.Id, // overrideName lets the by-name resolvers stamp the wire Name with // the name the client actually requested. The personal-dorm entity // is named "Dorm_{playerId}", but the watch resolves it via the // magic name "DormRoom" (HomeScreenFlow.Button_DormRoom → // RunJoinRoom("DormRoom") → OJMCBOKJFOF.NHBPIIGDAJP("DormRoom")). // That call caches the response in a Dictionary and // looks it back up by the REQUESTED string ("DormRoom"); if the // wire Name is "Dorm_{id}" the lookup misses → the promise rejects // with "No such room" → the "contact recroom.happyfox.com" toast. // Stamping Name="DormRoom" here makes the lookup hit, the same way // RecCenter (whose entity Name IS "RecCenter") already works. Name = overrideName ?? src.Name, Description = src.Description, CreatorPlayerId = creatorId, ImageName = src.ImageName, State = src.State, Accessibility = src.Accessibility, SupportsLevelVoting = src.SupportsLevelVoting, IsAGRoom = src.IsAGRoom, IsDormRoom = src.IsDormRoom, CloningAllowed = src.CloningAllowed, SupportsVRLow = src.SupportsVRLow, SupportsMobile = src.SupportsMobile, SupportsScreens = src.SupportsScreens, SupportsWalkVR = src.SupportsWalkVR, SupportsTeleportVR = src.SupportsTeleportVR, AllowsJuniors = src.AllowsJuniors, RoomWarningMask = src.RoomWarningMask, CustomRoomWarning = src.CustomRoomWarning, DisableMicAutoMute = src.DisableMicAutoMute, LocationReplicationId = src.LocationReplicationId, TagsCsv = src.TagsCsv, CheerCount = src.CheerCount, FavoriteCount = src.FavoriteCount, VisitCount = src.VisitCount, HotScore = src.HotScore, CreatedAt = src.CreatedAt, UpdatedAt = src.UpdatedAt, CurrentDataBlobName = overrideBlobName ?? src.CurrentDataBlobName, }; /// /// Wire shape for the RoomDetails object — matches RoomDetails.Deserialize /// at RVA 0x114C580. JSON key names DO NOT necessarily match the C# /// property/field names from dump.cs — the deserializer is hand-written /// and uses different (mostly PascalCase) literal strings. Verified /// empirically: this exact shape gets past Deserialize successfully. /// public static object BuildRoomDetails( RoomEntity room, IReadOnlyList? sceneRows = null, long? overrideSceneId = null, string? overrideDataBlobName = null, IReadOnlyList? roles = null) { // Tags is a list of OBJECTS, not strings. RoomDetails.Deserialize // does Util.DeserializeList(tags) — each entry is parsed as // a Dictionary with `Type` (int RoomTagType: 0=Auto, 1=PlayerAdded, // 2=AG) and `Tag` (string). Sending bare strings crashes: // InvalidCastException: Unable to cast 'String' to 'Dictionary`2' var tags = string.IsNullOrEmpty(room.TagsCsv) ? Array.Empty() : room.TagsCsv .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Select(t => (object)new { Type = 0, Tag = t }) .ToArray(); // Scenes population: every room must produce a Scenes[0] entry // with RoomSceneId=0 (matching the matchmaking SubRoomId=0) so // that Rooms.set_LocalRoomDetails actually transitions // Rooms.LocalRoomScene to the new room's scene reference. // // Why every room: the setter (verified in Cpp2IL ISIL of // RecNet.Rooms.set_LocalRoomDetails) iterates Scenes looking // for a RoomSceneId match; if none matches (Scenes=[] or // mismatched), it falls through WITHOUT writing LocalRoomScene // — so the field stays stale at whatever the previous room set. // After the dorm fix landed, that stale value started leaking // into the rec center: the dorm sets LocalRoomScene → user // goes to rec center → if we send Scenes=[], LocalRoomScene // STAYS pointing at the dorm scene → DownloadRoomDataBlob // Coroutine reads scene.DataBlobName from the dorm and // downloads room_1_v1.dat (smoking gun in the server log) // during the rec center load. The rec center master init // blocks → FROSTBITE. // // The DataBlobName field is what determines whether the // persistence coroutine actually downloads anything. // OnRoomDataBlobNameChanged (RoomPersistenceManager.txt ISIL // {027-040}) short-circuits when DataBlobName is empty: it // sets GetRoomDataBlobPromise to a CompletedPromise // with empty bytes and returns immediately — no HTTP fetch, // no DeserializeRoomDataBlobCoroutine, the master init // proceeds with default-zero permissions. // // • Dorm / customisable rooms: DataBlobName is either 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). // • AG-Original rooms: DataBlobName="" → completed-promise // short-circuit → no download → no leaked stale-blob // behaviour → master flow proceeds → spawn fires. Maker // Pen stays locked, which matches public-server semantics // (you can't edit the rec center). // // Heuristic: "customisable" iff it's the dorm OR a user-created // room (CreatorPlayerId != 1, the seeded system account that // owns AG-Originals like the rec center). User clones inherit // the all-perms default blob until their first save replaces // it via StorageController; the rec center / paintball / etc. // serve "" so the persistence flow short-circuits. var roleList = roles ?? Array.Empty(); var editableRro = room.IsAGRoom && roleList.Any(r => r.Accepted); var customisable = room.IsDormRoom || room.CreatorPlayerId != 1 || editableRro; var dataBlobName = !string.IsNullOrEmpty(room.CurrentDataBlobName) ? room.CurrentDataBlobName : customisable ? $"room_{room.Id}_v1.dat" : ""; // Multi-scene rooms (imported via tools/import-room.ps1) carry one // RoomSceneEntity row per sub-room. Emit the full Scenes[] array // when present so cross-scene portals (Lobby→Ch1, etc.) can // resolve. Single-scene rooms (every legacy room, every dorm, // every AG-Original) fall back to the synthesised Scenes[0] from // RoomEntity fields. object[] scenes; if (sceneRows is { Count: > 0 }) { scenes = sceneRows .Select(s => (object)new { RoomSceneId = (long)s.OrderIndex, RoomId = room.Id, RoomSceneLocationId = s.RoomSceneLocationId, Name = s.Name, IsSandbox = s.IsSandbox, DataBlobName = !string.IsNullOrWhiteSpace(overrideDataBlobName) && (overrideSceneId == s.OrderIndex || sceneRows.Count == 1) ? overrideDataBlobName : s.DataBlobName, MaxPlayers = s.MaxPlayers, CanMatchmakeInto = s.CanMatchmakeInto, DataModifiedAt = (s.DataModifiedAt == default ? DateTime.UtcNow : s.DataModifiedAt) .ToString("yyyy-MM-ddTHH:mm:ssZ"), }) .ToArray(); } else { scenes = new object[] { new { RoomSceneId = 0L, // MUST match RoomInstance.SubRoomId RoomId = room.Id, RoomSceneLocationId = room.LocationReplicationId, Name = "Home", IsSandbox = false, DataBlobName = dataBlobName, MaxPlayers = room.MaxCapacity, CanMatchmakeInto = true, // Use the room's actual UpdatedAt so the watch's // "is my local cache stale?" check sees an accurate // freshness timestamp. Hardcoding the build date // (2020-03-06) made the watch flag every dorm visit // as "room is not up to date" because the cached // local copy was always newer than the server's // declared timestamp. DataModifiedAt = (room.UpdatedAt == default ? DateTime.UtcNow : room.UpdatedAt) .ToString("yyyy-MM-ddTHH:mm:ssZ"), }, }; } // Owners tile (UIRoomDetails bottom-left) reads CoOwners — the // 2020 RoomDetails.Deserialize reads keys WITHOUT the "Ids" // suffix (verified at RoomDetails.txt:1096/1141/1146/1191/1196: // "CoOwners" / "InvitedCoOwners" / "Moderators" / // "InvitedModerators" / "Hosts" / "InvitedHosts"). The // GetListKey calls are strict — wrong names throw // KeyNotFoundException → "Failed to get room details: // Malformed Response" → dorm load stalls in a redirect loop. // // CreatorPlayerId is always implicitly a CoOwner so RR Originals // surface Coach (id=1) as the owner instead of rendering blank. // Additional grants come from RoomRoleEntity rows when supplied; // callers without role data (clone/myrooms/etc.) ship just the // implicit creator. int[] PlayersIn(int role, bool accepted) => roleList .Where(r => r.Role == role && r.Accepted == accepted) .Select(r => (int)r.PlayerId) .ToArray(); var coOwnerSet = new HashSet { (int)room.CreatorPlayerId }; foreach (var pid in PlayersIn(0, true)) coOwnerSet.Add(pid); return new { Room = RoomService.ToWireRoom(room), Scenes = scenes, CoOwners = coOwnerSet.ToArray(), InvitedCoOwners = PlayersIn(0, false), Moderators = PlayersIn(1, true), InvitedModerators = PlayersIn(1, false), Hosts = PlayersIn(2, true), InvitedHosts = PlayersIn(2, false), CheerCount = room.CheerCount, FavoriteCount = room.FavoriteCount, VisitCount = room.VisitCount, // VisitorCount is the official Rec.Net "unique visitors" // companion field (see Stats payload at apim.rec.net). // 2020 RoomDetails.Deserialize doesn't read it but leaving // it on the wire is harmless and lets the admin UI / any // future client surface the number. VisitorCount = room.VisitorCount, Tags = tags, }; } /// /// 2020.12 /roomserver/rooms/{id}?include=301 shape. The client /// deserializes this directly as PPENFJMFPNE, not as the older /// { Room, Scenes, ... } wrapper. /// public static object BuildRoomServerDetails( RoomEntity room, IReadOnlyList? sceneRows = null, string? overrideDataBlobName = null, IReadOnlyList? roles = null) { var roleList = roles ?? Array.Empty(); var editableRro = room.IsAGRoom && roleList.Any(r => r.Accepted); var customisable = room.IsDormRoom || room.CreatorPlayerId != 1 || editableRro; var dataBlobName = !string.IsNullOrEmpty(room.CurrentDataBlobName) ? room.CurrentDataBlobName : customisable ? $"room_{room.Id}_v1.dat" : ""; var updatedAt = (room.UpdatedAt == default ? DateTime.UtcNow : room.UpdatedAt) .ToString("yyyy-MM-ddTHH:mm:ssZ"); object[] subRooms = sceneRows is { Count: > 0 } ? sceneRows.Select(s => (object)new { SubRoomId = (long)s.OrderIndex, RoomId = room.Id, Name = s.Name, DataBlob = !string.IsNullOrWhiteSpace(overrideDataBlobName) && sceneRows.Count == 1 ? overrideDataBlobName : s.DataBlobName, DataSavedAt = (s.DataModifiedAt == default ? DateTime.UtcNow : s.DataModifiedAt) .ToString("yyyy-MM-ddTHH:mm:ssZ"), IsSandbox = s.IsSandbox, MaxPlayers = s.MaxPlayers, Accessibility = room.Accessibility, UnitySceneId = s.RoomSceneLocationId, }).ToArray() : new object[] { new { SubRoomId = 0L, RoomId = room.Id, Name = "Home", DataBlob = dataBlobName, DataSavedAt = updatedAt, IsSandbox = false, MaxPlayers = 8, Accessibility = room.Accessibility, UnitySceneId = room.LocationReplicationId, }, }; static int WireRole(int role) => role switch { 0 => 30, // CoOwner 1 => 20, // Moderator 2 => 10, // Host _ => 0, }; var wireRoles = new List { new { AccountId = room.CreatorPlayerId, Role = 255, InvitedRole = 0 }, }; wireRoles.AddRange(roleList.Select(r => { var role = WireRole(r.Role); return (object)new { AccountId = r.PlayerId, Role = r.Accepted ? role : 0, InvitedRole = r.Accepted ? 0 : role, }; })); var tags = string.IsNullOrEmpty(room.TagsCsv) ? Array.Empty() : room.TagsCsv .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Select(t => (object)new { Type = 0, Tag = t }) .ToArray(); return new { RoomId = room.Id, Name = room.Name, Description = room.Description, ImageName = room.ImageName, WarningMask = room.RoomWarningMask, CustomWarning = room.CustomRoomWarning, CreatorAccountId = room.CreatorPlayerId, State = room.State, Accessibility = room.Accessibility, SupportsLevelVoting = room.SupportsLevelVoting, IsRRO = room.IsAGRoom, SupportsScreens = room.SupportsScreens, SupportsWalkVR = room.SupportsWalkVR, SupportsTeleportVR = room.SupportsTeleportVR, SupportsVRLow = room.SupportsVRLow, SupportsQuest2 = true, SupportsMobile = room.SupportsMobile, SupportsJuniors = room.AllowsJuniors, CreatedAt = (room.CreatedAt == default ? DateTime.UtcNow : room.CreatedAt) .ToString("yyyy-MM-ddTHH:mm:ssZ"), Stats = new { CheerCount = room.CheerCount, FavoriteCount = room.FavoriteCount, VisitorCount = room.VisitorCount, VisitCount = room.VisitCount, }, IsDorm = room.IsDormRoom, CloningAllowed = room.CloningAllowed, DisableMicAutoMute = room.DisableMicAutoMute, DisableRoomComments = false, EncryptVoiceChat = false, SubRooms = subRooms, Roles = wireRoles, LoadScreens = Array.Empty(), PromoImages = Array.Empty(), PromoExternalContent = Array.Empty(), Tags = tags, }; } // ── Clone / create ─────────────────────────────────────────────────── /// /// `Rooms.CloneRoom(roomIdToClone, name)` — POST api/rooms/v1/clone /// with body `{"Name":"","RoomId":}`. /// /// Response shape (CreateModifyRoomResponse.Deserialize, RVA 0xAF1950): /// Result (int CreateModifyRoomStatus: 0=Success, 1=Unknown, /// 2=PermissionDenied, 3=RoomNotActive, /// 4=RoomDoesNotExist, 5=RoomHasNoDataBlob, …) /// RoomDetails (RoomDetails object, only populated on Result=0) /// [HttpPost("api/rooms/v1/clone")] [Authorize] public async Task Clone([FromBody] CloneRoomRequest req) { var pid = CurrentPlayerId; if (pid is null) return Unauthorized(); // CloneAsync sets the CreateModifyRoomStatus code itself // (Success/RoomDoesNotExist/DuplicateName/InappropriateName) so the // watch can show the right toast instead of a generic "Unknown". var result = await rooms.CloneAsync(req.RoomId, req.Name ?? string.Empty, pid.Value); return Ok(new { Result = result.Status, RoomDetails = result.Room is null ? null : BuildRoomDetails(result.Room), }); } public class CloneRoomRequest { public string? Name { get; set; } public long RoomId { get; set; } } // ── My-Rooms tabs ──────────────────────────────────────────────────── // 2020.12 OJMCBOKJFOF populates its local rooms cache from these // URLs at boot (verified via metadata strings: "rooms/createdby/me", // "rooms/moderatedby/me", "rooms/visitedby/me"). The 2020.03 client // used /api/rooms/v2/myrooms, while 2020.12 moved the calls under // /roomserver/rooms/*. Keep both route families on the same handlers. [HttpGet("api/rooms/v2/myrooms")] [HttpGet("api/rooms/v1/createdby/me")] [HttpGet("api/rooms/v2/createdby/me")] [HttpGet("api/rooms/v3/createdby/me")] [HttpGet("roomserver/rooms/createdby/me")] [HttpGet("roomserver/rooms/ownedby/me")] [HttpGet("rooms/createdby/me")] [HttpGet("rooms/ownedby/me")] [Authorize] public async Task MyRooms() { var pid = CurrentPlayerId; if (pid is null) return Ok(Array.Empty()); return Ok((await rooms.CreatedByAsync(pid.Value)).Select(RoomService.ToWireRoom).ToList()); } [HttpGet("api/rooms/v1/createdby/{otherPlayerId:long}")] [HttpGet("api/rooms/v2/createdby/{otherPlayerId:long}")] [HttpGet("api/rooms/v3/createdby/{otherPlayerId:long}")] [HttpGet("roomserver/rooms/createdby/{otherPlayerId:long}")] [HttpGet("rooms/createdby/{otherPlayerId:long}")] public async Task CreatedByOther(long otherPlayerId) => Ok((await rooms.CreatedByAsync(otherPlayerId)).Select(RoomService.ToWireRoom).ToList()); [HttpGet("api/rooms/v1/visitedby/me")] [HttpGet("api/rooms/v2/visitedby/me")] [HttpGet("api/rooms/v3/visitedby/me")] [HttpGet("roomserver/rooms/visitedby/me")] [HttpGet("rooms/visitedby/me")] [Authorize] public IActionResult VisitedByMe() => Ok(Array.Empty()); [HttpGet("api/rooms/v1/moderatedby/me")] [HttpGet("api/rooms/v2/moderatedby/me")] [HttpGet("api/rooms/v3/moderatedby/me")] [HttpGet("roomserver/rooms/moderatedby/me")] [HttpGet("rooms/moderatedby/me")] [Authorize] public IActionResult ModeratedByMe() => Ok(Array.Empty()); [HttpGet("api/rooms/v1/favoritedby/me")] [HttpGet("api/rooms/v2/favoritedby/me")] [HttpGet("api/rooms/v3/favoritedby/me")] [HttpGet("roomserver/rooms/favoritedby/me")] [HttpGet("rooms/favoritedby/me")] [Authorize] public IActionResult FavoritedByMe() => Ok(Array.Empty()); [HttpGet("api/rooms/v1/cheeredby/me")] [HttpGet("api/rooms/v2/cheeredby/me")] [HttpGet("api/rooms/v3/cheeredby/me")] [HttpGet("roomserver/rooms/cheeredby/me")] [HttpGet("rooms/cheeredby/me")] [Authorize] public IActionResult CheeredByMe() => Ok(Array.Empty()); [HttpGet("api/rooms/v2/mybookmarks")] [HttpGet("api/rooms/v2/mybookmarkedrooms")] [Authorize] public async Task MyBookmarks() { var pid = CurrentPlayerId; if (pid is null) return Ok(Array.Empty()); return Ok((await rooms.BookmarkedByAsync(pid.Value)).Select(RoomService.ToWireRoom).ToList()); } [HttpGet("api/rooms/v2/mymoderated")] [HttpGet("api/rooms/v1/modrooms")] [HttpGet("api/rooms/v2/mysubscribed")] [HttpGet("api/rooms/v2/myrecent")] public IActionResult MyOtherTabs() => Ok(Array.Empty()); // ── Bookmark mutations ─────────────────────────────────────────────── [HttpPost("api/rooms/v1/bookmark/{roomId:long}")] [HttpPost("api/rooms/v2/bookmark/{roomId:long}")] [Authorize] public async Task Bookmark(long roomId) { var pid = CurrentPlayerId; if (pid is null) return Unauthorized(); await rooms.BookmarkAsync(pid.Value, roomId); return Ok(new { success = true, error = "" }); } [HttpDelete("api/rooms/v1/bookmark/{roomId:long}")] [HttpDelete("api/rooms/v2/bookmark/{roomId:long}")] [Authorize] public async Task Unbookmark(long roomId) { var pid = CurrentPlayerId; if (pid is null) return Unauthorized(); await rooms.UnbookmarkAsync(pid.Value, roomId); return Ok(new { success = true, error = "" }); } // ── Helpers ────────────────────────────────────────────────────────── /// GET api/rooms/v1/agRoomIds — list of every /// seeded AG / RR-Original room id. Used by the watch to bias the /// room browser toward official rooms first. [HttpGet("api/rooms/v1/agRoomIds")] [HttpGet("rooms/rro_ids")] public async Task AgRoomIds() => Ok(await rooms.AgRoomIdsAsync()); /// GET api/rooms/v1/featuredRoomGroup + /// v3/featured — the "Featured" carousel on the watch's /// rooms tab. Returns the top-12 AG rooms by HotScore. [HttpGet("api/rooms/v1/featuredRoomGroup")] [HttpGet("api/rooms/v3/featured")] public async Task Featured() => Ok(new { Name = "Featured", FeaturedRooms = await rooms.FeaturedAgRoomIdsAsync(12), }); /// GET featuredrooms/current — the watch's pointer /// to the current "Featured" room group. Caller: /// EJDCNGBEICB.CFKDADKHAGB. Wire shape is NMPFCIJPODA /// with required keys FeaturedRoomGroupId (long) and /// Name (string) — a pointer record, NOT the room list /// itself (the watch fetches the list separately via /// api/rooms/v3/featured). Returning the /// shape here throws KeyNotFoundException on /// the watch's group-id lookup. [HttpGet("featuredrooms/current")] public async Task FeaturedRoomsCurrent() => Ok(new { FeaturedRoomGroupId = 1L, Name = "Featured", // NMPFCIJPODA.PPGFHEDFBEA (NMPFCIJPODA.txt:100-125) reads // 3 strict keys, not 2 — FeaturedRoomGroupId, Name, AND // Rooms (List). Returning without the third // key throws KeyNotFoundException on the watch and the // Featured carousel stays empty. Rooms = await rooms.FeaturedAgRoomIdsAsync(12), }); /// POST api/rooms/v1/bookmark — toggle bookmark /// state for a room. Body: RoomId + optional Bookmark /// flag (default = add). [HttpPost("api/rooms/v1/bookmark")] [Authorize] public async Task BookmarkV1( [FromForm(Name = "RoomId")] long? roomId, [FromForm(Name = "Bookmark")] bool? bookmark) { if (roomId is not long rid) return Ok(new { success = true }); var pid = this.RequireCurrentPlayerId(); if (bookmark == false) await rooms.UnbookmarkAsync(pid, rid); else await rooms.BookmarkAsync(pid, rid); return Ok(new { success = true, error = "" }); } /// POST rooms/{roomId}/name — rename a room. /// Watch path: EJDCNGBEICB.DNOGGKKHMHI(long roomId, string newName) /// (analytics tag "change room name"). Body shape: form-urlencoded /// Name=... or a bare string in the body. Returns the /// updated Room DTO (PPENFJMFPNE : KLCOGEIGEBJ). /// /// Caller must be the room creator OR a co-owner. Other /// validations: name length 1..40, no leading/trailing whitespace, /// uniqueness against other Rooms.Name. Failures return /// the watch's CreateModifyRoomStatus-style payload with /// a non-zero Result so the rename toast surfaces a real reason. /// /// We accept both POST and PUT because the watch's ISIL /// doesn't expose the explicit HTTPMethod literal at this call /// site — covering both is cheap and means we can't get bitten /// by the wrong one. public sealed class RenameBody { public string? Name { get; set; } } [HttpPost("rooms/{roomId:long}/name")] [HttpPut("rooms/{roomId:long}/name")] [Authorize] public async Task RenameRoom( long roomId, [FromForm(Name = "Name")] string? nameForm, [FromForm(Name = "name")] string? nameFormLower, [FromBody] RenameBody? body) { var newName = nameForm ?? nameFormLower ?? body?.Name; if (string.IsNullOrWhiteSpace(newName)) return BadRequest(new { Result = 1, error = "empty_name" }); newName = newName.Trim(); if (newName.Length is < 1 or > 40) return BadRequest(new { Result = 1, error = "invalid_length", length = newName.Length }); var me = this.RequireCurrentPlayerId(); var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); if (room is null) return NotFound(new { Result = 4, error = "room_not_found" }); if (room.CreatorPlayerId != me) { // RoomRoles co-owner check — same authorisation as the // existing modify endpoint, lets co-owners rename too. var coOwner = await db.RoomRoles.AnyAsync(rr => rr.RoomId == roomId && rr.PlayerId == me && rr.Accepted && (rr.Role == 0 || rr.Role == 1)); if (!coOwner) return Forbid(); } // Uniqueness — Rooms.Name has a unique index (DorkNetDbContext.cs:118). var collision = await db.Rooms.AnyAsync(r => r.Id != roomId && r.Name == newName); if (collision) return Conflict(new { Result = 5, error = "name_taken" }); room.Name = newName; room.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); logger.LogInformation("[room-rename] room={Room} by={By} → '{Name}'", roomId, me, newName); // Return the full updated Room DTO. The watch's // PPENFJMFPNE deserialiser inherits from // KLCOGEIGEBJ (the standard Room shape) so we can // forward to the same v3-details payload builder by re-reading. // BuildRoomDetailsV3Async is too heavy here — just return the // minimal mutated subset; the watch UI re-fetches details // shortly after for the room-info screen. return Ok(new { Result = 0, RoomId = room.Id, Name = room.Name, Description = room.Description, ImageName = room.ImageName, CreatorPlayerId = room.CreatorPlayerId, CreatorAccountId = room.CreatorPlayerId, State = room.State, Accessibility = room.Accessibility, IsAGRoom = room.IsAGRoom, IsRRO = false, IsDormRoom = room.IsDormRoom, }); } /// POST rooms/{roomId}/subrooms/{subRoomId}/name — /// rename a scene/sub-room within a multi-scene room. Watch path: /// EJDCNGBEICB.PAENPJCDLNJ(long roomId, long subRoomId, string newName). /// Updates the matching after /// verifying ownership and intra-room uniqueness. [HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/name")] [HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/name")] [Authorize] public async Task RenameSubroom( long roomId, long subRoomId, [FromForm(Name = "Name")] string? nameForm, [FromForm(Name = "name")] string? nameFormLower, [FromBody] RenameBody? body) { var newName = nameForm ?? nameFormLower ?? body?.Name; if (string.IsNullOrWhiteSpace(newName)) return BadRequest(new { Result = 1, error = "empty_name" }); newName = newName.Trim(); if (newName.Length is < 1 or > 40) return BadRequest(new { Result = 1, error = "invalid_length", length = newName.Length }); var me = this.RequireCurrentPlayerId(); var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); if (room is null) return NotFound(new { Result = 4, error = "room_not_found" }); if (room.CreatorPlayerId != me) { var coOwner = await db.RoomRoles.AnyAsync(rr => rr.RoomId == roomId && rr.PlayerId == me && rr.Accepted && (rr.Role == 0 || rr.Role == 1)); if (!coOwner) return Forbid(); } var scene = await db.RoomScenes.FirstOrDefaultAsync(s => s.RoomId == roomId && s.OrderIndex == subRoomId); if (scene is null) return NotFound(new { Result = 4, error = "subroom_not_found", subRoomId }); // Uniqueness within this room — same scene name twice would // make /goto/room/X/Y ambiguous against the RoomScenes index // on (RoomId, Name) at DorkNetDbContext.cs:153. var collision = await db.RoomScenes.AnyAsync(s => s.RoomId == roomId && s.OrderIndex != subRoomId && s.Name == newName); if (collision) return Conflict(new { Result = 5, error = "subroom_name_taken" }); scene.Name = newName; scene.DataModifiedAt = DateTime.UtcNow; await db.SaveChangesAsync(); logger.LogInformation("[subroom-rename] room={Room} sub={Sub} by={By} → '{Name}'", roomId, subRoomId, me, newName); return Ok(new { Result = 0, RoomId = roomId, RoomSceneId = scene.OrderIndex, SubRoomId = scene.OrderIndex, Name = scene.Name, scene.DataBlobName, RoomSceneLocationId = scene.RoomSceneLocationId, scene.IsSandbox, scene.MaxPlayers, scene.CanMatchmakeInto, }); } /// POST api/rooms/v1/cheer — cheer a room (body /// form). Idempotent per (caller, room) — duplicate taps don't /// inflate CheerCount. [HttpPost("api/rooms/v1/cheer")] [Authorize] public async Task CheerRoom( [FromForm(Name = "RoomId")] long? roomId, [FromForm(Name = "Type")] int? type) { if (roomId is not long rid) return Ok(new { success = true }); var me = this.RequireCurrentPlayerId(); var (newCount, already) = await rooms.CheerRoomAsync(me, rid, type ?? 0); return Ok(new { RoomId = rid, CheerCount = newCount, AlreadyCheered = already }); } /// POST api/rooms/v1/uncheer — remove a previously /// applied room cheer. [HttpPost("api/rooms/v1/uncheer")] [HttpPost("api/rooms/v1/uncheer/{roomId:long}")] [Authorize] public async Task UncheerRoom( long? roomId, [FromForm(Name = "RoomId")] long? roomIdForm, [FromForm(Name = "Type")] int? type) { var rid = roomId ?? roomIdForm ?? 0; if (rid == 0) return Ok(new { success = true }); var me = this.RequireCurrentPlayerId(); var already = await rooms.UncheerRoomAsync(me, rid, type ?? 0); return Ok(new { RoomId = rid, AlreadyUncheered = already }); } /// GET api/rooms/v1/cheers/{roomId} — did the /// caller cheer this room, and what's the current count? [HttpGet("api/rooms/v1/cheers/{roomId:long}")] [Authorize] public async Task CheckCheered(long roomId) { var me = this.CurrentPlayerId(); var (count, iCheered) = await rooms.GetCheerStateAsync(me ?? 0, roomId); return Ok(new { RoomId = roomId, CheerCount = count, ICheered = iCheered }); } // ── Bare-path interactionby/me toggles ───────────────────────────── [HttpPost("rooms/{roomId:long}/interactionby/me/cheer")] [HttpPut("rooms/{roomId:long}/interactionby/me/cheer")] [Authorize] public async Task BareCheer(long roomId) { var me = this.RequireCurrentPlayerId(); await rooms.CheerRoomAsync(me, roomId, 0); return Ok(new { success = true }); } [HttpDelete("rooms/{roomId:long}/interactionby/me/cheer")] [Authorize] public async Task BareUncheer(long roomId) { var me = this.RequireCurrentPlayerId(); await rooms.UncheerRoomAsync(me, roomId, 0); return Ok(new { success = true }); } [HttpPost("rooms/{roomId:long}/interactionby/me/favorite")] [HttpPut("rooms/{roomId:long}/interactionby/me/favorite")] [Authorize] public async Task BareFavorite(long roomId) { var me = this.RequireCurrentPlayerId(); await rooms.BookmarkAsync(me, roomId); return Ok(new { success = true }); } [HttpDelete("rooms/{roomId:long}/interactionby/me/favorite")] [Authorize] public async Task BareUnfavorite(long roomId) { var me = this.RequireCurrentPlayerId(); await rooms.UnbookmarkAsync(me, roomId); return Ok(new { success = true }); } // ── Rooms-and-Playlists union (2020.12 watch) ──────────────────────── /// /// GET /roomserver/roomsandplaylists/hot — the 2020.12 watch's /// merged "Trending" / "New" tab. Two URL variants exist with /// DIFFERENT wire shapes: /// /// 1. /roomserver/roomsandplaylists/hot (this endpoint) /// Watch caller: EJDCNGBEICB.EBBHGHEGBKG /// Signature: IPromise<HJKAOMOICJG> /// Wire shape: { TotalResults, Results: [union] } /// (verified at EJDCNGBEICB.txt:2571,2645 + /// HJKAOMOICJG.txt:39-50) /// 2. /roomserver/hot_roomsandplaylists/{tags-joined} /// Watch caller: OJMCBOKJFOF.EBBHGHEGBKG /// Signature: IPromise<List<MKAMHOIHOJK>> /// Wire shape: bare list, see /// (verified at OJMCBOKJFOF.txt:4607,4743) /// /// Returning a bare list here throws /// InvalidCastException: Unable to cast object of type 'List`1' /// to type 'Dictionary`2' on the watch — the wrapped /// HJKAOMOICJG deserializer reads TotalResults as a top-level /// dict key, and a JSON array body fails that cast. /// /// Inside Results: union entries discriminated by lowercase /// roomId key per the MKAMHOIHOJK factory at /// MKAMHOIHOJK.txt:621 — see /// + . /// /// Why rooms-only despite the "and playlists" name: the /// Hot tab of the watch's Play page mounts this endpoint and used to /// interleave playlist tiles next to room tiles, but design intent is /// that playlists appear only under the Moods/Playlists section (their /// own endpoints — /api/curatedroomplaylists + /// /roomserver/playlists/{id}). The watch's union deserializer /// happily accepts a Results list that's 100% rooms, so we keep the /// wrapper shape but drop the playlist half. /// [HttpGet("roomserver/roomsandplaylists/hot")] [HttpGet("roomsandplaylists/hot")] public async Task RoomsAndPlaylistsHot([FromQuery] string? tag) { var (entries, total) = await BuildHotRoomsAsync(tag); return Ok(new Dictionary(StringComparer.Ordinal) { ["TotalResults"] = (long)total, ["Results"] = entries, }); } /// /// GET /roomserver/hot_roomsandplaylists/{tagsJoined} — /// path-style sibling of with a /// BARE List<MKAMHOIHOJK> shape (no TotalResults /// wrapper). Tags are comma-joined into the URL path per /// OJMCBOKJFOF.txt:4739,4743 (String.Join(",", tags) /// followed by String.Concat("hot_roomsandplaylists/", joined)). /// Empty path is legal — equivalent to "no tag filter". /// [HttpGet("roomserver/hot_roomsandplaylists/{tagsJoined?}")] [HttpGet("hot_roomsandplaylists/{tagsJoined?}")] public async Task HotRoomsAndPlaylistsPath(string? tagsJoined) { // Watch joins multiple tags with "," — use the first one as a // substring filter since RoomService.HotAsync takes a single // tag arg. Empty / null = unfiltered. // // Returns rooms-only for the same reason as the wrapped // RoomsAndPlaylistsHot endpoint above: playlists belong in // Moods, not in the Hot tab. See that endpoint's doc-comment. var firstTag = string.IsNullOrWhiteSpace(tagsJoined) ? null : tagsJoined.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .FirstOrDefault(); var (entries, _) = await BuildHotRoomsAsync(firstTag); return Ok(entries); } /// GET hot_rooms/{tagsJoined?} — rooms-only variant /// of . Caller: /// OJMCBOKJFOF.DLPLPKCNLNA. Same MKAMHOIHOJK union /// entry shape as hot_roomsandplaylists, just skips playlist /// rows so the watch's rooms-only browse tab doesn't render /// playlist tiles that have nowhere to go. [HttpGet("hot_rooms/{tagsJoined?}")] public async Task HotRoomsOnlyPath(string? tagsJoined) { var firstTag = string.IsNullOrWhiteSpace(tagsJoined) ? null : tagsJoined.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .FirstOrDefault(); var roomList = await rooms.HotAsync(firstTag, take: 12); var roomIds = roomList.Select(r => r.Id).ToList(); var sceneRows = roomIds.Count == 0 ? new List() : await db.RoomScenes.Where(s => roomIds.Contains(s.RoomId)) .OrderBy(s => s.OrderIndex).ToListAsync(); var scenesByRoom = sceneRows.GroupBy(s => s.RoomId) .ToDictionary(g => g.Key, g => (IReadOnlyList)g.ToList()); return Ok(roomList .Select(r => BuildRoomUnionEntry(r, scenesByRoom.GetValueOrDefault(r.Id))) .ToList()); } /// /// Shared body for the two hot variants — pulls hot rooms only, /// fans out sub-room rows in one query to avoid N+1, and returns /// the union-shape entries the watch's factory expects (each entry /// happens to be a room dict; the factory accepts that). Returns /// (entries, totalCount) so the wrapped variant can fill in /// TotalResults. /// /// Take limit raised to 100 (was 12) — the Hot tab used to /// surface only the top dozen rooms which is far less than the /// catalog the watch's browse UI can actually render. The watch /// paginates client-side if it needs to chunk further. /// private async Task<(List> Entries, int Total)> BuildHotRoomsAsync(string? tag) { var roomList = await rooms.HotAsync(tag, take: 100); var roomIds = roomList.Select(r => r.Id).ToList(); var sceneRows = roomIds.Count == 0 ? new List() : await db.RoomScenes .Where(s => roomIds.Contains(s.RoomId)) .OrderBy(s => s.OrderIndex) .ToListAsync(); var scenesByRoom = sceneRows .GroupBy(s => s.RoomId) .ToDictionary(g => g.Key, g => (IReadOnlyList)g.ToList()); var entries = roomList .Select(r => BuildRoomUnionEntry(r, scenesByRoom.GetValueOrDefault(r.Id))) .ToList(); return (entries, entries.Count); } /// /// GET /api/curatedroomplaylists — the editorial "Curated" /// row on the watch's playlists tab. /// /// Wire shape: List<Int64> — JUST the playlist IDs, not /// the full playlist objects. Verified against /// FPNPBJBCMKB.EPPPMHMMGME (signature /// IPromise<List<long>>) and the call site at /// BrowseRoomsScreen.txt:2806 which constructs an /// Action<List<Int64>> callback. Returning rich /// objects here throws /// InvalidCastException: Unable to cast object of type 'List`1' /// to type 'Dictionary`2' on the watch ("Failed to get curated /// room playlists: Malformed Response"). The watch fetches each /// playlist's details separately via the playlist-detail endpoints. /// [HttpGet("api/curatedroomplaylists")] public async Task CuratedRoomPlaylists() { var curated = await playlists.CuratedAsync(); return Ok(curated.Select(p => p.Id).ToList()); } /// /// GET /roomserver/playlists/{id} — single-playlist details /// with members. The watch deserializes this as BMFAGMFKODA /// (verified at BMFAGMFKODA.txt:182-191) which: /// 1. Calls KMKPEOGJDFK.PPGFHEDFBEA to read the base /// Playlist union fields (PlaylistId + all /// MKAMHOIHOJK base keys). /// 2. Reads required key Rooms — List<KLCOGEIGEBJ> /// (the playlist's member rooms, full Room dicts). /// 3. Reads required key Tags — List<DPHPFLGAICI> /// (tag rows with Type + Tag). /// Returning just the KMKPEOGJDFK shape (no Rooms/Tags) throws /// KeyNotFoundException: Failed to find key 'Rooms' when /// deserializing object of type KLCOGEIGEBJ on the watch. /// [HttpGet("roomserver/playlists/{id:long}")] [HttpGet("playlists/{id:long}")] public async Task PlaylistDetails(long id) { var p = await db.Playlists.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id); if (p is null) return NotFound(); // Pull member rooms in playlist order, then fan out room + // scene rows so each entry can render as a full KLCOGEIGEBJ. var roomIds = await db.PlaylistRooms .Where(pr => pr.PlaylistId == id) .OrderBy(pr => pr.OrderIndex) .Select(pr => pr.RoomId) .ToListAsync(); List memberRooms; Dictionary> scenesByRoom; if (roomIds.Count == 0) { memberRooms = new List(); scenesByRoom = new Dictionary>(); } else { var roomRows = await db.Rooms .Where(r => roomIds.Contains(r.Id)) .ToListAsync(); // Preserve playlist order — db.Rooms.Where doesn't guarantee // it matches the OrderBy on PlaylistRooms.OrderIndex. var roomById = roomRows.ToDictionary(r => r.Id); memberRooms = roomIds .Select(rid => roomById.TryGetValue(rid, out var r) ? r : null) .Where(r => r is not null) .Select(r => r!) .ToList(); var sceneRows = await db.RoomScenes .Where(s => roomIds.Contains(s.RoomId)) .OrderBy(s => s.OrderIndex) .ToListAsync(); scenesByRoom = sceneRows .GroupBy(s => s.RoomId) .ToDictionary(g => g.Key, g => (IReadOnlyList)g.ToList()); } var roomsWire = memberRooms .Select(r => BuildRoomServerDetails(r, scenesByRoom.GetValueOrDefault(r.Id))) .ToList(); // Tags wire shape: [{Type:int, Tag:string}] — same shape used in // BuildRoomServerDetails for Room.Tags. Split TagsCsv if present. var tagsWire = string.IsNullOrWhiteSpace(p.TagsCsv) ? new List() : p.TagsCsv .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Select(t => (object)new { Type = 0, Tag = t }) .ToList(); // Start from BuildPlaylistUnionEntry for base + PlaylistId, then // splice in the Rooms + Tags lists. Dictionary lets us add the // extra keys after the base build. var result = BuildPlaylistUnionEntry(p); result["Rooms"] = roomsWire; result["Tags"] = tagsWire; return Ok(result); } /// /// GET /roomserver/roomsandplaylists/search?query=X — search /// surface used by the watch's friend-mention / room-search affordance. /// Verified at EJDCNGBEICB.txt:2486,2560 — signature /// IPromise<HJKAOMOICJG>. Per /// at HJKAOMOICJG.txt:39-50 /// the response object reads TotalResults (Int64) and /// Results (List of MKAMHOIHOJK union entries — same /// Room/Playlist factory used by /hot). /// /// Distinct from /roomserver/search_roomsandplaylists/{query} /// (path-param shape from OJMCBOKJFOF.txt:4567); both URLs /// reach the same data but differ in shape — the path-param sibling /// returns a bare List<MKAMHOIHOJK> without the /// TotalResults wrapper. /// [HttpGet("roomserver/roomsandplaylists/search")] [HttpGet("roomsandplaylists/search")] public async Task RoomsAndPlaylistsSearch([FromQuery] string? query) { var (merged, total) = await BuildSearchUnionAsync(query); return Ok(new Dictionary(StringComparer.Ordinal) { ["TotalResults"] = (long)total, ["Results"] = merged, }); } /// /// GET /roomserver/search_roomsandplaylists/{query} — free-text /// search across both rooms and playlists, BARE-LIST variant. Per /// OJMCBOKJFOF.txt:4439,4567 the signature is /// IPromise<List<MKAMHOIHOJK>> — no TotalResults /// wrapper. URL is search_roomsandplaylists/{query} /// (query in path, not query-string). /// [HttpGet("roomserver/search_roomsandplaylists/{query}")] [HttpGet("search_roomsandplaylists/{query}")] public async Task SearchRoomsAndPlaylists(string query) { var (merged, _) = await BuildSearchUnionAsync(query); return Ok(merged); } /// GET search_rooms/{query} — rooms-only search /// variant. Same MKAMHOIHOJK union entry shape as /// search_roomsandplaylists/{query}, just skips playlist /// rows. [HttpGet("search_rooms/{query}")] public async Task SearchRoomsOnly(string query) { if (string.IsNullOrWhiteSpace(query)) return Ok(Array.Empty()); var roomList = await rooms.SearchAsync(query); var roomIds = roomList.Select(r => r.Id).ToList(); var sceneRows = roomIds.Count == 0 ? new List() : await db.RoomScenes.Where(s => roomIds.Contains(s.RoomId)) .OrderBy(s => s.OrderIndex).ToListAsync(); var scenesByRoom = sceneRows.GroupBy(s => s.RoomId) .ToDictionary(g => g.Key, g => (IReadOnlyList)g.ToList()); return Ok(roomList .Select(r => BuildRoomUnionEntry(r, scenesByRoom.GetValueOrDefault(r.Id))) .ToList()); } /// /// Shared body for the two search variants. Empty/whitespace query /// returns an empty result without round-tripping the DB. /// private async Task<(List> Entries, int Total)> BuildSearchUnionAsync(string? query) { if (string.IsNullOrWhiteSpace(query)) return (new List>(), 0); // Sequential — shared scoped DbContext isn't thread-safe. var roomList = await rooms.SearchAsync(query); var playlistList = await playlists.SearchAsync(query); var roomIds = roomList.Select(r => r.Id).ToList(); var sceneRows = await db.RoomScenes .Where(s => roomIds.Contains(s.RoomId)) .OrderBy(s => s.OrderIndex) .ToListAsync(); var scenesByRoom = sceneRows .GroupBy(s => s.RoomId) .ToDictionary(g => g.Key, g => (IReadOnlyList)g.ToList()); var merged = InterleaveUnion( roomList.Select(r => BuildRoomUnionEntry(r, scenesByRoom.GetValueOrDefault(r.Id))), playlistList.Select(BuildPlaylistUnionEntry)); return (merged, roomList.Count + playlistList.Count); } /// Round-robin merge of two enumerables so the watch's /// hot row alternates between rooms and playlists rather than /// stacking one type on top. Once one side runs out the remainder /// of the other side spills out in order. private static List> InterleaveUnion( IEnumerable> a, IEnumerable> b) { var listA = a.ToList(); var listB = b.ToList(); var output = new List>(listA.Count + listB.Count); int i = 0, j = 0; while (i < listA.Count || j < listB.Count) { if (i < listA.Count) output.Add(listA[i++]); if (j < listB.Count) output.Add(listB[j++]); } return output; } /// /// Build a union-shape Room entry — same payload as /// but with the additional /// lowercase roomId key that the 2020.12 /// MKAMHOIHOJK factory uses as its Room-vs-Playlist /// discriminator (factory at MKAMHOIHOJK.txt:621 calls /// Dictionary.ContainsKey("roomId"); the actual Room /// deserializer at KLCOGEIGEBJ.txt:208-234 reads /// PascalCase RoomId). Must include BOTH keys. /// private static IDictionary BuildRoomUnionEntry( RoomEntity room, IReadOnlyList? sceneRows) { // Start from the canonical RoomServerDetails shape so the union // entry carries every field the 2020.12 client expects on a // Room subclass (SubRooms, Roles, Stats, etc.). Then add the // lowercase 'roomId' discriminator key on top. var details = BuildRoomServerDetails(room, sceneRows); // Convert anonymous record into a mutable dictionary so we can // add the lowercase 'roomId' key that anonymous types can't // express (you can't have two properties differing only in // letter casing on a single anonymous type). var dict = new Dictionary(StringComparer.Ordinal); foreach (var prop in details.GetType().GetProperties()) { var value = prop.GetValue(details); if (value is not null) dict[prop.Name] = value; } // Discriminator key — lowercase 'roomId'. Without this the // MKAMHOIHOJK factory falls through to the Playlist branch // and PlaylistId.PPGFHEDFBEA throws on the missing key. dict["roomId"] = room.Id; return dict; } /// /// Build a union-shape Playlist entry — the /// branch of MKAMHOIHOJK. Keys read at /// MKAMHOIHOJK.txt:516-612 (PascalCase base keys) and /// KMKPEOGJDFK.txt:68 (PascalCase PlaylistId). /// Omits the lowercase roomId key so the factory's /// Room-vs-Playlist branch falls through to the Playlist /// constructor. /// public static IDictionary BuildPlaylistUnionEntry(PlaylistEntity p) { return new Dictionary(StringComparer.Ordinal) { ["PlaylistId"] = p.Id, ["Name"] = p.Name, ["Description"] = p.Description, ["ImageName"] = p.ImageName, ["CreatorAccountId"] = p.CreatorPlayerId, ["State"] = 0, ["Accessibility"] = 1, ["WarningMask"] = 0, ["CustomWarning"] = string.Empty, ["SupportsLevelVoting"] = false, // Playlists are watchable from every device the // member rooms support; advertise the broadest set so // the watch never filters them out by capability. ["IsRRO"] = p.IsCurated, ["SupportsScreens"] = true, ["SupportsWalkVR"] = true, ["SupportsTeleportVR"] = true, ["SupportsVRLow"] = true, ["SupportsQuest2"] = true, ["SupportsMobile"] = true, ["SupportsJuniors"] = true, ["CreatedAt"] = (p.CreatedAt == default ? DateTime.UtcNow : p.CreatedAt) .ToString("yyyy-MM-ddTHH:mm:ssZ"), ["Stats"] = new { CheerCount = p.CheerCount, FavoriteCount = p.FavoriteCount, VisitorCount = p.VisitorCount, VisitCount = p.VisitCount, }, }; } // ── Subroom mutations (bare path) ────────────────────────────────── public sealed class CreateSubRoomRequest { public string? Name { get; set; } public string? RoomSceneLocationId { get; set; } public int? MaxPlayers { get; set; } public bool? IsSandbox { get; set; } } /// POST rooms/{id}/subrooms — append a new scene. /// Owner-gated. OrderIndex = max+1, Name unique within room. [HttpPost("rooms/{roomId:long}/subrooms")] [Authorize] public async Task AddSubRoom(long roomId, [FromBody] CreateSubRoomRequest req) { var pid = this.RequireCurrentPlayerId(); var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); if (room is null) return NotFound(); if (room.CreatorPlayerId != pid) return Forbid(); var name = (req.Name ?? string.Empty).Trim(); if (name.Length == 0) name = "NewScene"; var collision = await db.RoomScenes.AnyAsync(s => s.RoomId == roomId && s.Name == name); if (collision) return Conflict(new { error = "name_taken" }); var nextOrder = (await db.RoomScenes .Where(s => s.RoomId == roomId) .Select(s => (int?)s.OrderIndex).MaxAsync() ?? -1) + 1; var scene = new RoomSceneEntity { RoomId = roomId, OrderIndex = nextOrder, Name = name, RoomSceneLocationId = req.RoomSceneLocationId ?? "a75f7547-79eb-47c6-8986-6767abcb4f92", MaxPlayers = req.MaxPlayers ?? 8, IsSandbox = req.IsSandbox ?? false, }; db.RoomScenes.Add(scene); await db.SaveChangesAsync(); return Ok(SceneWire(scene)); } [HttpDelete("rooms/{roomId:long}/subrooms/{subRoomId:long}")] [Authorize] public async Task DeleteSubRoom(long roomId, long subRoomId) { var pid = this.RequireCurrentPlayerId(); var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); if (room is null) return NotFound(); if (room.CreatorPlayerId != pid) return Forbid(); var scene = await db.RoomScenes .FirstOrDefaultAsync(s => s.RoomId == roomId && s.OrderIndex == subRoomId); if (scene is null) return NotFound(); if (scene.OrderIndex == 0) return BadRequest(new { error = "cannot_delete_entry_scene" }); db.RoomScenes.Remove(scene); await db.SaveChangesAsync(); return Ok(new { Result = 0 }); } public sealed class SubRoomIntRequest { public int? Value { get; set; } } public sealed class SubRoomBoolRequest { public bool? Value { get; set; } } public sealed class SubRoomModifyRequest { public string? Name { get; set; } public int? MaxPlayers { get; set; } public bool? IsSandbox { get; set; } public bool? CanMatchmakeInto { get; set; } public string? RoomSceneLocationId { get; set; } } public sealed class SubRoomMoveRequest { public int? NewIndex { get; set; } } [HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/maxplayers")] [HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/maxplayers")] [Authorize] public Task SubRoomMaxPlayers(long roomId, long subRoomId, [FromBody] SubRoomIntRequest req) => MutateScene(roomId, subRoomId, s => { if (req.Value is int v) s.MaxPlayers = Math.Max(1, v); }); [HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/accessibility")] [HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/accessibility")] [Authorize] public Task SubRoomAccessibility(long roomId, long subRoomId, [FromBody] SubRoomBoolRequest req) => MutateScene(roomId, subRoomId, s => { if (req.Value is bool v) s.CanMatchmakeInto = v; }); /// POST rooms/{id}/subrooms/{sub}/clone — duplicate /// a scene (DataBlobName + location pointer). New scene goes at the /// end with " Copy" suffix; per-room name uniqueness preserved. [HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/clone")] [Authorize] public async Task CloneSubRoom(long roomId, long subRoomId) { var pid = this.RequireCurrentPlayerId(); var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); if (room is null) return NotFound(); if (room.CreatorPlayerId != pid) return Forbid(); var src = await db.RoomScenes .FirstOrDefaultAsync(s => s.RoomId == roomId && s.OrderIndex == subRoomId); if (src is null) return NotFound(); var baseName = $"{src.Name} Copy"; var newName = baseName; int suffix = 1; while (await db.RoomScenes.AnyAsync(s => s.RoomId == roomId && s.Name == newName)) { suffix += 1; newName = $"{baseName} {suffix}"; } var nextOrder = (await db.RoomScenes.Where(s => s.RoomId == roomId) .Select(s => (int?)s.OrderIndex).MaxAsync() ?? -1) + 1; var clone = new RoomSceneEntity { RoomId = roomId, OrderIndex = nextOrder, Name = newName, RoomSceneLocationId = src.RoomSceneLocationId, DataBlobName = src.DataBlobName, MaxPlayers = src.MaxPlayers, IsSandbox = src.IsSandbox, CanMatchmakeInto = src.CanMatchmakeInto, }; db.RoomScenes.Add(clone); await db.SaveChangesAsync(); return Ok(SceneWire(clone)); } [HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/modify")] [HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/modify")] [Authorize] public Task ModifySubRoom(long roomId, long subRoomId, [FromBody] SubRoomModifyRequest req) => MutateScene(roomId, subRoomId, s => { if (!string.IsNullOrWhiteSpace(req.Name)) s.Name = req.Name.Trim(); if (req.MaxPlayers is int mp) s.MaxPlayers = Math.Max(1, mp); if (req.IsSandbox is bool sb) s.IsSandbox = sb; if (req.CanMatchmakeInto is bool cmi) s.CanMatchmakeInto = cmi; if (req.RoomSceneLocationId is not null) s.RoomSceneLocationId = req.RoomSceneLocationId; }); /// POST rooms/{id}/subrooms/{sub}/move — reorder a /// scene. Body NewIndex is the target OrderIndex; the in-between /// rows shift to fill the gap. [HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/move")] [Authorize] public async Task MoveSubRoom(long roomId, long subRoomId, [FromBody] SubRoomMoveRequest req) { var pid = this.RequireCurrentPlayerId(); var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); if (room is null) return NotFound(); if (room.CreatorPlayerId != pid) return Forbid(); var scenes = await db.RoomScenes .Where(s => s.RoomId == roomId).OrderBy(s => s.OrderIndex).ToListAsync(); var moving = scenes.FirstOrDefault(s => s.OrderIndex == subRoomId); if (moving is null) return NotFound(); var newIndex = Math.Clamp(req.NewIndex ?? 0, 0, scenes.Count - 1); scenes.Remove(moving); scenes.Insert(newIndex, moving); // Recompute OrderIndex on the entire row set. The unique // (RoomId, OrderIndex) index would clash if we wrote rows in // place — flush a sentinel offset first, then settle to // final indices. const int sentinelOffset = 100_000; for (int i = 0; i < scenes.Count; i++) scenes[i].OrderIndex = i + sentinelOffset; await db.SaveChangesAsync(); for (int i = 0; i < scenes.Count; i++) scenes[i].OrderIndex = i; await db.SaveChangesAsync(); return Ok(SceneWire(moving)); } /// POST rooms/{id}/subrooms/{sub}/data — bare-path /// equivalent of api/rooms/v4/saveData. Delegates to the /// existing save logic so the watch's two URL variants land on the /// same persistence path. [HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/data")] [Authorize] public Task SubRoomData(long roomId, long subRoomId, [FromBody] SaveRoomSceneRequest body) { body.RoomSceneId = subRoomId; return SaveDataCore(body, roomId); } private async Task MutateScene(long roomId, long subRoomId, Action mutator) { var pid = this.RequireCurrentPlayerId(); var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); if (room is null) return NotFound(); if (room.CreatorPlayerId != pid) return Forbid(); var scene = await db.RoomScenes .FirstOrDefaultAsync(s => s.RoomId == roomId && s.OrderIndex == subRoomId); if (scene is null) return NotFound(); mutator(scene); scene.DataModifiedAt = DateTime.UtcNow; await db.SaveChangesAsync(); return Ok(SceneWire(scene)); } private static object SceneWire(RoomSceneEntity s) => new { Result = 0, RoomId = s.RoomId, RoomSceneId = s.OrderIndex, SubRoomId = s.OrderIndex, Name = s.Name, s.DataBlobName, RoomSceneLocationId = s.RoomSceneLocationId, s.IsSandbox, s.MaxPlayers, s.CanMatchmakeInto, }; // ── Bare-path roles ──────────────────────────────────────────────── /// GET rooms/{id}/roles — accepted role grants on /// the room. Owner is implicit (CreatorPlayerId) and surfaces under /// Role=30; co-owner/mod/host rows come from RoomRoles. [HttpGet("rooms/{roomId:long}/roles")] public async Task RolesList(long roomId) { var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); if (room is null) return NotFound(); var rows = await db.RoomRoles.Where(r => r.RoomId == roomId).ToListAsync(); var list = new List(rows.Count + 1) { new { RoomId = roomId, PlayerId = (int)room.CreatorPlayerId, Role = 30 /*Owner*/, Accepted = true }, }; list.AddRange(rows.Select(r => (object)new { RoomId = roomId, PlayerId = (int)r.PlayerId, Role = r.Role, Accepted = r.Accepted, })); return Ok(list); } [HttpGet("rooms/{roomId:long}/roles/{playerId:long}")] public async Task RoleForPlayer(long roomId, long playerId) { var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); if (room is null) return NotFound(); if (room.CreatorPlayerId == playerId) return Ok(new { RoomId = roomId, PlayerId = (int)playerId, Role = 30, Accepted = true }); var row = await db.RoomRoles .FirstOrDefaultAsync(r => r.RoomId == roomId && r.PlayerId == playerId); if (row is null) return NotFound(); return Ok(new { RoomId = roomId, PlayerId = (int)playerId, Role = row.Role, Accepted = row.Accepted }); } public sealed class GrantRoleRequest { public int? Role { get; set; } } /// POST rooms/{id}/roles/{playerId} — owner grants /// a role to a player. Auto-accepted (no invite step). [HttpPut("rooms/{roomId:long}/roles/{playerId:long}")] [HttpPost("rooms/{roomId:long}/roles/{playerId:long}")] [HttpPut("/api/rooms/v1/rooms/{roomId:long}/roles/{playerId:long}")] [HttpPost("/api/rooms/v1/rooms/{roomId:long}/roles/{playerId:long}")] [HttpPut("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}")] [HttpPost("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}")] [Authorize] public async Task GrantRole(long roomId, long playerId) { var me = this.RequireCurrentPlayerId(); var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); if (room is null) return NotFound(); if (!await CanManageRoomRolesAsync(room, me)) return Forbid(); var role = await ReadClientRoomRoleAsync(); if (role is null) return BadRequest(new { error = "missing_role" }); if (role.Value == 0) return await ClearRoomAccountRoleAsync(room, playerId); if (!TryMapClientRoleToRoomRole(role.Value, out var roomRole)) return BadRequest(new { error = "invalid_role", role }); var existing = await db.RoomRoles .FirstOrDefaultAsync(r => r.RoomId == roomId && r.PlayerId == playerId && r.Role == roomRole); if (existing is null) { await RemoveOtherRoomRolesAsync(roomId, playerId, roomRole); db.RoomRoles.Add(new RoomRoleEntity { RoomId = roomId, PlayerId = playerId, Role = roomRole, Accepted = true, GrantedByPlayerId = me, }); } else { existing.Accepted = true; } await db.SaveChangesAsync(); return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId)); } /// POST rooms/{id}/roles/{playerId}/invite — same /// as grant but Accepted=false; the target's accept-invite flow /// flips the flag (separate endpoint, not yet exposed). For now /// invited rows surface in RoomDetails.InvitedCoOwners etc. [HttpPut("rooms/{roomId:long}/roles/{playerId:long}/invite")] [HttpPost("rooms/{roomId:long}/roles/{playerId:long}/invite")] [HttpPut("/api/rooms/v1/rooms/{roomId:long}/roles/{playerId:long}/invite")] [HttpPost("/api/rooms/v1/rooms/{roomId:long}/roles/{playerId:long}/invite")] [HttpPut("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}/invite")] [HttpPost("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}/invite")] [Authorize] public async Task InviteRole(long roomId, long playerId) { var me = this.RequireCurrentPlayerId(); var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); if (room is null) return NotFound(); if (!await CanManageRoomRolesAsync(room, me)) return Forbid(); var role = await ReadClientRoomRoleAsync(); if (role is null) return BadRequest(new { error = "missing_role" }); if (role.Value == 0) return await ClearRoomAccountRoleAsync(room, playerId); if (!TryMapClientRoleToRoomRole(role.Value, out var roomRole)) return BadRequest(new { error = "invalid_role", role }); var existing = await db.RoomRoles .FirstOrDefaultAsync(r => r.RoomId == roomId && r.PlayerId == playerId && r.Role == roomRole); if (existing is null) { await RemoveOtherRoomRolesAsync(roomId, playerId, roomRole); db.RoomRoles.Add(new RoomRoleEntity { RoomId = roomId, PlayerId = playerId, Role = roomRole, Accepted = false, GrantedByPlayerId = me, }); } else { existing.Accepted = false; } await db.SaveChangesAsync(); return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId)); } private async Task ClearRoomAccountRoleAsync(RoomEntity room, long playerId) { if (room.CreatorPlayerId == playerId) return BadRequest(new { error = "cannot_change_creator_role" }); var rows = await db.RoomRoles .Where(r => r.RoomId == room.Id && r.PlayerId == playerId) .ToListAsync(); if (rows.Count > 0) db.RoomRoles.RemoveRange(rows); await db.SaveChangesAsync(); return Ok(await BuildRoomServerDetailsWithRolesAsync(room.Id)); } private async Task CanManageRoomRolesAsync(RoomEntity room, long playerId) { if (room.CreatorPlayerId == playerId) return true; return await db.RoomRoles.AnyAsync(r => r.RoomId == room.Id && r.PlayerId == playerId && r.Accepted && r.Role == 0); } private async Task ReadClientRoomRoleAsync() { int parsed; if (Request.HasFormContentType) { var form = await Request.ReadFormAsync(); if (int.TryParse(form["role"].FirstOrDefault(), out parsed)) return parsed; if (int.TryParse(form["Role"].FirstOrDefault(), out parsed)) return parsed; } if (int.TryParse(Request.Query["role"].FirstOrDefault(), out parsed)) return parsed; if (int.TryParse(Request.Query["Role"].FirstOrDefault(), out parsed)) return parsed; if (Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true) { try { var body = await JsonSerializer.DeserializeAsync( Request.Body, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); if (body?.Role is int bodyRole) return bodyRole; } catch (JsonException) { return null; } } return null; } private async Task RemoveOtherRoomRolesAsync(long roomId, long playerId, int keepRole) { var rows = await db.RoomRoles .Where(r => r.RoomId == roomId && r.PlayerId == playerId && r.Role != keepRole) .ToListAsync(); if (rows.Count > 0) db.RoomRoles.RemoveRange(rows); } private static bool TryMapClientRoleToRoomRole(int clientRole, out int roomRole) { roomRole = clientRole switch { 30 => 0, // CoOwner 20 => 1, // Moderator 10 => 2, // Host _ => -1, }; return roomRole >= 0; } private async Task BuildRoomServerDetailsWithRolesAsync(long roomId) { var room = await db.Rooms.FirstAsync(r => r.Id == roomId); var scenes = await db.RoomScenes .Where(s => s.RoomId == roomId) .OrderBy(s => s.OrderIndex) .ToListAsync(); var roles = await db.RoomRoles .Where(r => r.RoomId == roomId) .ToListAsync(); return BuildRoomServerDetails(room, scenes, roles: roles); } /// /// Synthesise a placeholder Room when the client asks for one we don't /// have in the DB. Keeps the deserializer happy — every required key /// is populated. Safe defaults: public, AG, default DormRoom location. /// /// Special case: roomId=1 is the canonical DormRoom id used by /// GoToController when the client visits its own dorm. Make sure the /// /v4/details/1 follow-up shows "DormRoom" rather than "Room_1". /// private static RoomEntity Synthetic(string name, long? id = null) { var isDorm = (id == 1) || name.Equals("DormRoom", StringComparison.OrdinalIgnoreCase); var displayName = isDorm ? "DormRoom" : name; return new RoomEntity { Id = id ?? Math.Abs((long)name.GetHashCode()), Name = displayName, Description = isDorm ? "Your private dorm — yours alone, decorated however you like." : $"{displayName} room", CreatorPlayerId = 1, State = 0, Accessibility = isDorm ? 0 /* Private */ : 1 /* Public */, IsAGRoom = !isDorm, IsDormRoom = isDorm, LocationReplicationId = "76d98498-60a1-430c-ab76-b54a29b7a163", }; } }