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,
SystemNotificationService systemNotifications,
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")]
public async Task HotV1(
[FromQuery] string? roomScoreType,
[FromQuery] string? tags)
=> Ok((await rooms.HotAsync(tags)).Select(RoomService.ToWireRoom).ToList());
/// GET rooms/hot — the room-server "hot rooms" list.
/// Response is the PAGED wrapper: the 2023 client's issuing method
/// NLDBPDCNNCF.FANOAMOPHBE returns Task<FFCPFGBNLHN>
/// (NLDBPDCNNCF.txt:2865 + literal at :2983) and FFCPFGBNLHN's reader
/// requires Results + TotalResults
/// (PJPBJKLDMNA.txt ISIL 040/059).
// The roomserver/ twin is mandatory: FANOAMOPHBE lives on the same
// NLDBPDCNNCF client whose requests all arrive with the roomserver/
// prefix in this deployment (docs/recroom-2023-room-save.md:83-93), so
// the bare-only registration 404'd for the 2023 client.
[HttpGet("rooms/hot")]
[HttpGet("roomserver/rooms/hot")]
public async Task HotRoomServer(
[FromQuery] int skip = 0,
[FromQuery] int take = 100)
{
// The tag filter is a REPEATED query field (`tag=a&tag=b`) — the
// client's IFLHPHIPFNC closure adds one entry per selected chip —
// so a `[FromQuery] string? tag` binding silently dropped every tag
// past the first. Read the raw collection and union the per-tag hits.
var tagFilters = Request.Query["tag"]
.Concat(Request.Query["tags"])
.SelectMany(v => (v ?? string.Empty).Split(',',
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
List all;
if (tagFilters.Count == 0)
{
all = await rooms.HotAsync(null, take: 200);
}
else
{
var merged = new List();
var seen = new HashSet();
foreach (var filter in tagFilters)
foreach (var room in await rooms.HotAsync(filter, take: 200))
if (seen.Add(room.Id)) merged.Add(room);
all = merged.OrderByDescending(r => r.HotScore).ToList();
}
var page = all
.Skip(Math.Max(skip, 0))
.Take(Math.Clamp(take, 1, 100))
.Select(RoomService.ToWireRoom)
.ToList();
return Ok(new
{
Results = page,
TotalResults = all.Count,
});
}
[HttpGet("rooms/topcreators")]
[HttpGet("roomserver/rooms/topcreators")]
public async Task TopCreators([FromQuery] int skip = 0, [FromQuery] int take = 100)
{
var rows = await PublicRoomQuery()
.OrderByDescending(r => r.HotScore)
.ThenByDescending(r => r.VisitCount)
.Take(250)
.ToListAsync();
var page = rows
.GroupBy(r => r.CreatorPlayerId)
.Select(g => g.First())
.Skip(Math.Max(skip, 0))
.Take(Math.Clamp(take, 1, 100))
.ToList();
return Ok(await BuildRoomServerListAsync(page));
}
[HttpGet("rooms/contestwinners")]
[HttpGet("roomserver/rooms/contestwinners")]
public async Task ContestWinners([FromQuery] int skip = 0, [FromQuery] int take = 100)
{
var rows = await PublicRoomQuery()
.Where(r =>
EF.Functions.Like(r.TagsCsv, "%contest%") ||
EF.Functions.Like(r.TagsCsv, "%winner%") ||
EF.Functions.Like(r.TagsCsv, "%featured%"))
.OrderByDescending(r => r.HotScore)
.ThenBy(r => r.Name)
.Skip(Math.Max(skip, 0))
.Take(Math.Clamp(take, 1, 100))
.ToListAsync();
if (rows.Count == 0)
{
rows = await PublicRoomQuery()
.OrderByDescending(r => r.HotScore)
.ThenBy(r => r.Name)
.Skip(Math.Max(skip, 0))
.Take(Math.Clamp(take, 1, 100))
.ToListAsync();
}
return Ok(await BuildRoomServerListAsync(rows));
}
[HttpGet("rooms/fromcreators")]
[HttpPost("rooms/fromcreators")]
[HttpGet("roomserver/rooms/fromcreators")]
[HttpPost("roomserver/rooms/fromcreators")]
public async Task FromCreators([FromQuery] int skip = 0, [FromQuery] int take = 100)
{
var creatorIds = Request.Query["creatorId"]
.Concat(Request.Query["creatorIds"])
.Concat(Request.Query["accountId"])
.Concat(Request.Query["accountIds"])
.Concat(Request.Query["id"])
.SelectMany(v => (v ?? string.Empty).Split(',',
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
.Select(v => long.TryParse(v, out var id) ? id : 0)
.Where(id => id > 0)
.Distinct()
.ToList();
var query = PublicRoomQuery();
if (creatorIds.Count > 0)
query = query.Where(r => creatorIds.Contains(r.CreatorPlayerId));
var rows = await query
.OrderByDescending(r => r.HotScore)
.ThenByDescending(r => r.UpdatedAt)
.Skip(Math.Max(skip, 0))
.Take(Math.Clamp(take, 1, 100))
.ToListAsync();
return Ok(await BuildRoomServerListAsync(rows));
}
/// GET rooms/magic_door — the Magic Door destination.
/// The response is a WRAPPER, not the bare room: the client's
/// MOOCMPOIIGM returns Task<GAPJAGDNFEO>
/// (NLDBPDCNNCF.txt:4503) and that reader requires the keys
/// RefreshesAt , RefreshIntervalMinutes and Room
/// (MIFGPMGEMEK.txt ISIL 046/073/097). Returning the room object
/// directly left Room null and MagicDoorManager with no
/// destination. The partySize query param is accepted and
/// ignored — we have no party-size-aware curation.
[HttpGet("rooms/magic_door")]
[HttpGet("roomserver/rooms/magic_door")]
public async Task MagicDoor([FromQuery] int? partySize)
{
var room = await PublicRoomQuery()
.OrderByDescending(r => r.HotScore)
.ThenBy(r => r.Id)
.FirstOrDefaultAsync();
if (room is null) return NotFound();
// The door re-rolls its destination on the client every
// RefreshIntervalMinutes; RefreshesAt is when the current pick
// expires. Keep the two consistent so the client's local timer
// and its next fetch line up.
const int refreshIntervalMinutes = 10;
return Ok(new
{
Room = (await BuildRoomServerListAsync(new[] { room })).First(),
RefreshesAt = DateTime.UtcNow.AddMinutes(refreshIntervalMinutes)
.ToString("yyyy-MM-ddTHH:mm:ssZ"),
RefreshIntervalMinutes = refreshIntervalMinutes,
});
}
///
/// 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);
}
[HttpGet("rooms/{roomId:long}/similar")]
[HttpGet("roomserver/rooms/{roomId:long}/similar")]
public async Task SimilarRooms(long roomId, [FromQuery] int take = 12)
{
var seed = await rooms.GetByIdAsync(roomId);
if (seed is null) return NotFound();
var tags = (seed.TagsCsv ?? string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.ToArray();
var query = PublicRoomQuery().Where(r => r.Id != roomId);
if (tags.Length > 0)
{
var primaryTag = tags[0];
query = query.Where(r => EF.Functions.Like(r.TagsCsv, $"%{primaryTag}%"));
}
var similar = await query
.OrderByDescending(r => r.HotScore)
.ThenBy(r => r.Name)
.Take(Math.Clamp(take, 1, 100))
.ToListAsync();
var seedWire = (await BuildRoomServerListAsync(new[] { seed })).First();
var similarWire = await BuildRoomServerListAsync(similar);
return Ok(new Dictionary(StringComparer.Ordinal)
{
["SeedRoom"] = seedWire,
["Rooms"] = similarWire,
});
}
///
/// `Rooms.SearchForRooms(query)` — free-text room search.
///
[HttpGet("api/rooms/v1/search")]
[HttpGet("api/rooms/v2/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 rooms/search — the room-server search route.
/// Unlike the api/rooms/v* variants (flat List<Room> ),
/// this one's client contract is a PAGED wrapper object
/// { Results, TotalResults } (IDLBPALJJDJ : PagedResult<Room>);
/// returning a bare array crashes the watch's key-based reader.
/// Confirmed for 2023 too: NLDBPDCNNCF.MOBJJDBNBMF returns
/// Task<FFCPFGBNLHN> (NLDBPDCNNCF.txt:2733) whose reader
/// requires Results/TotalResults (PJPBJKLDMNA.txt ISIL 040/059).
// roomserver/ twin: the 2023 caller is on NLDBPDCNNCF, whose requests all
// carry the roomserver/ prefix (docs/recroom-2023-room-save.md:83-93).
[HttpGet("rooms/search")]
[HttpGet("roomserver/rooms/search")]
public async Task SearchRoomServer(
[FromQuery(Name = "query")] string? query,
[FromQuery(Name = "value")] string? value,
[FromQuery] int skip = 0,
[FromQuery] int take = 100)
{
var all = (await rooms.SearchAsync(query ?? value ?? string.Empty)).ToList();
var page = all
.Skip(Math.Max(skip, 0))
.Take(Math.Clamp(take, 1, 100))
.Select(RoomService.ToWireRoom)
.ToList();
return Ok(new
{
Results = page,
TotalResults = all.Count,
});
}
/// 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` — the room-creation "base room" picker. A fixed,
/// admin-curated list of room NAMES (server setting
/// BaseRoomNamesJson , default ), resolved to rooms
/// in the configured order. Names are looked up via
/// , which also finds
/// HiddenFromBrowse rooms like MakerRoom. Edit the list from the admin SPA
/// (api/admin/v1/settings/base-rooms ).
///
[HttpGet("api/rooms/v2/baserooms")]
[HttpGet("api/rooms/v3/baserooms")]
// The 2023 client issues this from NLDBPDCNNCF, whose requests all carry
// the roomserver/ prefix in this deployment — without the twin the base/AG
// room list 404s and the room browser's base section is empty.
[HttpGet("rooms/base")]
[HttpGet("roomserver/rooms/base")]
public async Task BaseRooms()
{
var names = await serverSettings.GetBaseRoomNamesAsync();
var result = new List();
foreach (var name in names)
{
var room = await rooms.GetByNameAsync(name);
if (room is not null)
result.Add(RoomService.ToWireRoom(room));
}
return Ok(result);
}
///
/// `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,
TrendingFilters = tags.TrendingTags,
});
}
[HttpGet("api/rooms/v1/tags")]
public async Task Tags()
{
var tags = await serverSettings.GetPlayMenuTagsAsync();
return Ok(tags.PinnedTags
.Concat(tags.PopularTags)
.Concat(tags.TrendingTags)
.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);
[HttpGet("api/rooms/v1/trendingtags")]
public async Task TrendingTags()
=> Ok((await serverSettings.GetPlayMenuTagsAsync()).TrendingTags);
// ── 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 flat shape as ByName.
///
[HttpGet("api/rooms/v2/{roomId:long}")]
[HttpGet("api/rooms/v3/{roomId:long}")]
public async Task ById(long roomId)
{
var r = await rooms.GetByIdAsync(roomId);
return Ok(RoomService.ToWireRoom(r ?? Synthetic($"Room_{roomId}", roomId)));
}
[HttpGet("rooms/{roomId:long}")]
public async Task RoomServerById(
long roomId,
[FromQuery] int? unityAssetTarget,
[FromQuery] int? unityAssetVersion)
{
var room = await rooms.GetByIdAsync(roomId) ?? Synthetic($"Room_{roomId}", roomId);
var sceneRows = await db.RoomScenes
.Where(s => s.RoomId == room.Id)
.OrderBy(s => s.OrderIndex)
.ToListAsync();
var roles = await db.RoomRoles
.Where(r => r.RoomId == room.Id)
.ToListAsync();
return Ok(BuildRoomServerDetails(
room,
sceneRows,
roles: roles,
unityAssetTarget: unityAssetTarget,
unityAssetVersion: unityAssetVersion));
}
/// DELETE rooms/{roomId} — the owner's "delete room"
/// button. Client method NLDBPDCNNCF.FPDBNHOHIAO formats
/// "rooms/{0}" and moves verb ordinal 4 (= DELETE) into r8
/// (NLDBPDCNNCF.txt ISIL 021/030 around :4897); its return type is the
/// body-less promise LDGADANDBIO (CAOCJPMJMDB.txt:339), so the
/// response payload is never parsed.
///
/// The only DELETE for rooms/{id} in the codebase lived on
/// AdminController, which is rooted at api/admin/v1 — the game
/// path therefore 405'd on the bare route and 404'd on the roomserver/
/// one, so owners could not delete their rooms at all.
///
/// Soft-archive (State = 1 ) exactly like
/// AdminController.DeleteRoom : a hard delete would orphan the
/// room's saves, visits, bookmarks and role rows. Archived rooms drop
/// out of every browse/search query (all of which filter
/// State == 0 ).
[HttpDelete("rooms/{roomId:long}")]
[HttpDelete("roomserver/rooms/{roomId:long}")]
[Authorize]
public async Task DeleteRoom(long roomId)
{
var me = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
if (room.CreatorPlayerId != me)
{
// Co-owners (Role 0) can delete too — same gate the rename path uses.
var coOwner = await db.RoomRoles.AnyAsync(rr =>
rr.RoomId == roomId && rr.PlayerId == me && rr.Accepted && rr.Role == 0);
if (!coOwner) return Forbid();
}
room.State = 1; // Archived
room.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
logger.LogInformation("[room-delete] room={Room} by={By}", roomId, me);
return Ok(new { Result = 0, room.Id, room.State });
}
// ── 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)
{
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
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 = RoomSceneIds.ToWire(subRoomId),
DataBlob = b.BlobName,
SavedByAccountId = b.UploadedByPlayerId,
CreatedAt = b.UploadedAt,
})
.ToListAsync();
return Ok(blobs);
}
/// GET rooms/{roomId}/subrooms/{subRoomId}/saves/{saveId}
/// — Studio room save metadata. The 2023 client can load baked Studio
/// scenes by first asking this endpoint which Unity asset bundle file
/// belongs to the save, then downloading it from
/// cdn.../room/{Filename}.assetbundle . The importer persists
/// the exact bundle filenames from the Studio dump on RoomScenes, so
/// this response is backed by the imported archive bytes rather than a
/// placeholder.
[HttpGet("rooms/{roomId:long}/subrooms/{subRoomId:long}/saves/{saveId:long}")]
[HttpGet("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/saves/{saveId:long}")]
[Authorize]
public async Task StudioSubRoomSave(
long roomId,
long subRoomId,
long saveId,
[FromQuery] int? unityAssetTarget,
[FromQuery] int? unityAssetVersion)
{
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var room = await rooms.GetByIdAsync(roomId);
if (room is null) return NotFound(new { error = "room_not_found", roomId });
var sceneRows = await db.RoomScenes
.Where(s => s.RoomId == roomId)
.OrderBy(s => s.OrderIndex)
.ToListAsync();
var scene = sceneRows.FirstOrDefault(s => s.OrderIndex == (int)subRoomId)
?? sceneRows.FirstOrDefault(s => s.StudioSubRoomDataSaveId == saveId);
if (scene is null) return NotFound(new { error = "subroom_not_found", roomId, subRoomId });
return Ok(BuildStudioSaveWire(room, scene, subRoomId, saveId, unityAssetTarget, unityAssetVersion));
}
/// GET rooms/{roomId}/subrooms — the subroom list the
/// 2023 client / Studio editor fetches
/// (String.Format("rooms/{0}/subrooms", roomId) ). Emits the same
/// per-subroom shape as the SubRooms[] entries inside
/// /roomserver/rooms/{id} , so a single-scene room synthesises one
/// "Home" entry and a multi-scene imported room enumerates its
/// RoomScenes.
[HttpGet("rooms/{roomId:long}/subrooms")]
[HttpGet("roomserver/rooms/{roomId:long}/subrooms")]
[Authorize]
public async Task SubRooms(long roomId)
{
var room = await rooms.GetByIdAsync(roomId);
if (room is null) return NotFound(new { error = "room_not_found", roomId });
var sceneRows = await db.RoomScenes
.Where(s => s.RoomId == roomId)
.OrderBy(s => s.OrderIndex)
.ToListAsync();
var wire = sceneRows.Count > 0
? sceneRows.Select(s => BuildSubRoomWire(room, s)).ToArray()
: new[] { BuildSubRoomWire(room, null) };
return Ok(wire);
}
/// GET rooms/{roomId}/subrooms/{subRoomId} — one subroom
/// (String.Format("rooms/{0}/subrooms/{1}", roomId, subRoomId) ).
/// is the RoomScene OrderIndex.
[HttpGet("rooms/{roomId:long}/subrooms/{subRoomId:long}")]
[HttpGet("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}")]
[Authorize]
public async Task SubRoom(long roomId, long subRoomId)
{
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var room = await rooms.GetByIdAsync(roomId);
if (room is null) return NotFound(new { error = "room_not_found", roomId });
var scene = await db.RoomScenes
.Where(s => s.RoomId == roomId && s.OrderIndex == (int)subRoomId)
.FirstOrDefaultAsync();
// A single-scene room has no RoomScene row; subRoom 0 is the
// synthesised "Home" scene.
if (scene is null && subRoomId != 0)
return NotFound(new { error = "subroom_not_found", roomId, subRoomId });
return Ok(BuildSubRoomWire(room, scene));
}
/// GET rooms/{roomId}/subrooms/{subRoomId}/data — the
/// current persisted save for the subroom
/// (String.Format("rooms/{0}/subrooms/{1}/data", roomId, subRoomId) ).
/// Returns the same SubRoomDataSave shape as
/// /saves/{saveId} resolved to the scene's current save id.
[HttpGet("rooms/{roomId:long}/subrooms/{subRoomId:long}/data")]
[HttpGet("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/data")]
[Authorize]
public async Task SubRoomData(
long roomId,
long subRoomId,
[FromQuery] int? unityAssetTarget,
[FromQuery] int? unityAssetVersion)
{
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var room = await rooms.GetByIdAsync(roomId);
if (room is null) return NotFound(new { error = "room_not_found", roomId });
var scene = await db.RoomScenes
.Where(s => s.RoomId == roomId && s.OrderIndex == (int)subRoomId)
.FirstOrDefaultAsync();
if (scene is null && subRoomId != 0)
return NotFound(new { error = "subroom_not_found", roomId, subRoomId });
var saveId = scene?.StudioSubRoomDataSaveId ?? 0L;
return Ok(BuildStudioSaveWire(room, scene, subRoomId, saveId, unityAssetTarget, unityAssetVersion));
}
/// GET rooms/{roomId}/subrooms/{subRoomId}/saves — the
/// list of saves for a subroom
/// (String.Format("rooms/{0}/subrooms/{1}/saves", roomId, subRoomId) ).
/// Returns the current save plus any historical blobs recorded for the
/// subroom, newest first, each in the SubRoomDataSave shape.
///
/// Wire shape is a PAGED OBJECT, not a bare list. The 2023
/// client's GetSubRoomDataSaves (RecNet.Runtime
/// NLDBPDCNNCF.LDLMCGBIJFH, state machine IOAKBKKDFEE, sends
/// skip /take ) deserializes
/// PagedResultsDTO<SubRoomDataSaveDTO> —
/// {"Results":[…],"TotalResults":N} per the Studio decompile
/// (RRStudio-decomp PagedResultsDTO.cs , plain .NET names). A
/// bare array throws expected:'{', actual:'[' in the strict
/// reader, which kills the whole calling flow: room clone dies with
/// the message-less "Failed to copy room" and the private-instance
/// button silently does nothing.
[HttpGet("rooms/{roomId:long}/subrooms/{subRoomId:long}/saves")]
[HttpGet("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/saves")]
// Studio editor asks for the same page without the heavy Unity-asset
// payload (CLAJDMOFFOS.PGGIWOJWHOM). Our rows never carry inline asset
// bytes, so the same handler serves both.
[HttpGet("rooms/{roomId:long}/subrooms/{subRoomId:long}/saves/no_unity_assets")]
[HttpGet("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/saves/no_unity_assets")]
[Authorize]
public async Task SubRoomSaves(
long roomId, long subRoomId,
[FromQuery] int skip = 0, [FromQuery] int take = 50)
{
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var room = await rooms.GetByIdAsync(roomId);
if (room is null) return NotFound(new { error = "room_not_found", roomId });
var scene = await db.RoomScenes
.Where(s => s.RoomId == roomId && s.OrderIndex == (int)subRoomId)
.FirstOrDefaultAsync();
if (scene is null && subRoomId != 0)
return NotFound(new { error = "subroom_not_found", roomId, subRoomId });
var currentSaveId = scene?.StudioSubRoomDataSaveId ?? 0L;
var saves = new List
{
BuildStudioSaveWire(room, scene, subRoomId, currentSaveId, null, null),
};
// Historical blobs for this subroom (the same rows the datahistory
// endpoint surfaces), surfaced as prior saves so the Studio editor's
// version picker has the full set. Distinct by blob name, current
// save excluded (already first in the list).
var history = await db.RoomDataBlobs
.Where(b => b.RoomId == roomId && b.SubRoomId == subRoomId)
.OrderByDescending(b => b.UploadedAt)
.Take(50)
.ToListAsync();
foreach (var h in history)
{
if (!string.IsNullOrWhiteSpace(scene?.DataBlobName) &&
string.Equals(h.BlobName, scene!.DataBlobName, StringComparison.OrdinalIgnoreCase))
continue;
saves.Add(new
{
SubRoomDataSaveId = currentSaveId,
SubRoomId = RoomSceneIds.ToWire(subRoomId),
UnityAssetId = scene?.StudioUnityAssetId ?? string.Empty,
SubRoomUnityAssetId = scene?.StudioUnityAssetId ?? string.Empty,
ReferencedUnityAssetIds = Array.Empty(),
DataBlob = h.BlobName,
DataBlobName = h.BlobName,
PersistenceVersion = 41,
OMVersion = 0,
SavedByAccountId = h.UploadedByPlayerId,
SavedOnPlatform = 7,
SavedOnDeviceClass = 2,
// Match BuildStudioSaveWire's key set — the strict readers
// key on CreatedAt/CreatedByAccountId (SavedAt was our own
// invention and no reader consumes it).
CreatedAt = (h.UploadedAt == default ? DateTime.UtcNow : h.UploadedAt)
.ToString("yyyy-MM-ddTHH:mm:ssZ"),
CreatedByAccountId = h.UploadedByPlayerId,
});
}
var total = saves.Count;
var page = saves
.Skip(Math.Max(0, skip))
.Take(take > 0 ? take : 50)
.ToList();
return Ok(new
{
Results = page,
TotalResults = total,
});
}
/// Build one subroom entry in the wire shape the watch's
/// SubRooms[] array uses (see BuildRoomServerDetails ). A
/// null yields the synthesised "Home" scene for
/// single-scene rooms.
private object BuildSubRoomWire(RoomEntity room, RoomSceneEntity? scene)
{
var fallback = CurrentOrSyntheticDataBlobName(room);
var orderIndex = scene?.OrderIndex ?? 0;
var dataBlob = scene is null
? fallback
: SceneOrSyntheticDataBlobName(room, scene.DataBlobName, fallback);
var modified = scene?.DataModifiedAt ?? room.UpdatedAt;
return new
{
SubRoomId = RoomSceneIds.ToWire(orderIndex),
RoomId = room.Id,
Name = scene?.Name ?? "Home",
DataBlob = dataBlob,
DataSavedAt = (modified == default ? DateTime.UtcNow : modified)
.ToString("yyyy-MM-ddTHH:mm:ssZ"),
IsSandbox = scene?.IsSandbox ?? false,
MaxPlayers = scene?.MaxPlayers ?? 8,
Accessibility = room.Accessibility,
UnitySceneId = scene?.RoomSceneLocationId ?? room.LocationReplicationId,
UnityAssetId = scene?.StudioUnityAssetId ?? string.Empty,
SubRoomUnityAssetId = scene?.StudioUnityAssetId ?? string.Empty,
CurrentSave = new
{
SubRoomDataSaveId = scene?.StudioSubRoomDataSaveId ?? 0L,
SubRoomId = RoomSceneIds.ToWire(orderIndex),
UnityAssetId = scene?.StudioUnityAssetId ?? string.Empty,
SubRoomUnityAssetId = scene?.StudioUnityAssetId ?? string.Empty,
ReferencedUnityAssetIds = Array.Empty(),
DataBlob = dataBlob,
},
};
}
/// Build the SubRoomDataSave wire object for a Studio
/// save — shared by /saves/{saveId} , /data , and the first
/// entry of /saves . A null (single-scene
/// room) returns a save with no baked bundles, pointing at the room's
/// current data blob.
private object BuildStudioSaveWire(
RoomEntity room,
RoomSceneEntity? scene,
long subRoomId,
long saveId,
int? unityAssetTarget,
int? unityAssetVersion)
{
var bundles = StudioBundlesForScene(scene, saveId);
if (bundles.Count == 0 && scene is not null)
{
logger.LogWarning(
"[rooms-studio-save] no asset bundles room={RoomId} subRoom={SubRoomId} save={SaveId} scene={Scene} csvLen={CsvLen}",
room.Id, subRoomId, saveId, scene.Name, scene.StudioAssetBundleNamesCsv?.Length ?? 0);
}
var unityAssetId = scene?.StudioUnityAssetId ?? string.Empty;
var bakedAssets = BuildStudioBakedAssets(room, scene, saveId, unityAssetTarget, unityAssetVersion);
var primary = bakedAssets.FirstOrDefault();
var unityAsset = BuildStudioUnityAsset(room, scene, bakedAssets);
var dataBlob = !string.IsNullOrWhiteSpace(scene?.DataBlobName)
? scene!.DataBlobName
: CurrentOrSyntheticDataBlobName(room);
logger.LogInformation(
"[rooms-studio-save] room={RoomId} subRoom={SubRoomId} save={SaveId} scene={Scene} target={Target} version={Version} primary={Primary} bundles={BundleCount}",
room.Id, subRoomId, saveId, scene?.Name ?? "Home", unityAssetTarget, unityAssetVersion,
primary?.Filename ?? string.Empty, bundles.Count);
return new
{
SubRoomDataSaveId = saveId,
SubRoomId = RoomSceneIds.ToWire(subRoomId),
UnityAssetId = unityAssetId,
SubRoomUnityAssetId = unityAssetId,
ReferencedUnityAssetIds = Array.Empty(),
DataBlob = dataBlob,
DataBlobName = dataBlob,
PersistenceVersion = 41,
OMVersion = 0,
SavedByAccountId = room.CreatorPlayerId,
SavedOnPlatform = 7,
SavedOnDeviceClass = 2,
CreatedAt = (scene?.DataModifiedAt ?? room.UpdatedAt) is var created && created != default
? created.ToString("yyyy-MM-ddTHH:mm:ssZ")
: DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ"),
CreatedByAccountId = room.CreatorPlayerId,
UnityAssetHash = string.Empty,
UnityAsset = unityAsset,
UnityAssetFilename = primary?.Filename ?? string.Empty,
BakedUnityAssets = bakedAssets,
UnitySubAssets = Array.Empty(),
};
}
/// 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)
{
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
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 (!await CanEditRoomAsync(room, 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 });
var restoredAt = DateTime.UtcNow;
sceneRow.DataBlobName = filename;
sceneRow.DataModifiedAt = restoredAt;
// 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, restoredAt));
room.CurrentDataBlobName = filename;
room.UpdatedAt = restoredAt;
}
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 RecNetResult —
// the same legacy success/value/error envelope used by
// roomserver/rooms/{id}/subrooms/{id}/data. `value` must be the
// flattened /roomserver/rooms/{id} DTO, not the modern
// { Room, Scenes } details payload pushed over SubscriptionUpdateRoom.
var roomServerDetails = BuildRoomServerDetails(pushRoom, sceneRows, filename);
return Ok(new { success = true, value = roomServerDetails, error = string.Empty });
}
/// 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,
bool wrapCreateModifyResponse = false)
{
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.
var missingPresenceScene = 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"),
};
return Ok(wrapCreateModifyResponse
? new { success = true, value = missingPresenceScene, error = string.Empty }
: missingPresenceScene);
}
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);
if (activeInstanceId != 0)
{
var updatedPrivateInstances = await db.PrivateInstances
.Where(instance => instance.Id == activeInstanceId && instance.RoomId == roomId)
.ExecuteUpdateAsync(setters => setters
.SetProperty(instance => instance.DataBlob, newBlob));
if (updatedPrivateInstances > 0)
{
logger.LogInformation(
"[rooms-save] updated private instance blob room={RoomId} instance={InstanceId} blob={BlobName}",
roomId, activeInstanceId, newBlob);
}
}
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.NameWire,
maxCapacity = currentPresence.MaxCapacity,
isFull = currentPresence.IsFull,
isPrivate = currentPresence.IsPrivate,
isInProgress = currentPresence.IsInProgress,
EncryptVoiceChat = currentPresence.EncryptVoiceChat,
},
isOnline = true,
// 2023.03.21 client build version. MUST be the STRING
// "20230317" — the client's presence DTO reads appVersion
// with a string reader, so an int crashes the SignalR
// message ("expected:'String Begin Token'") and aborts the
// update. Must also match the joining client's version.
appVersion = "20230317",
};
await notifications.NotifyAsync(playerId, PushNotificationId.SubscriptionUpdatePresence, presencePayload);
}
var savedScene = 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"),
};
if (wrapCreateModifyResponse)
{
// 2023 nested-JSON save commit: the response deserializer
// (NEOPBOMGIOG via KMBHAKAHNGH mapper) REQUIRES value =
// { Room: , SubRoomDataSave: } — both
// non-null, or its post-parse validator NREs and the watch
// reports "Failed to save room" even though the save
// persisted. Room reuses the GET rooms/{id} shape; the save
// reuses BuildStudioSaveWire (JKIFFPPAJNK keys per
// PELEHJLMKJO mapper: SubRoomDataSaveId/SubRoomId/
// UnityAssetId/DataBlob/DataBlobHash/SavedByAccountId/
// SavedOnPlatform/SavedOnDeviceClass/Description/CreatedAt).
if (body.SubRoomData is not null)
{
var subRoomId = (long)(sceneRow?.OrderIndex ?? (int)body.RoomSceneId);
var value = new
{
Room = BuildRoomServerDetails(pushRoom, sceneRowsForRoom, newBlob),
SubRoomDataSave = BuildStudioSaveWire(
room, sceneRow, subRoomId,
sceneRow?.StudioSubRoomDataSaveId ?? 0L,
unityAssetTarget: null, unityAssetVersion: null),
};
return Ok(new { success = true, value, error = string.Empty });
}
var roomServerDetails = BuildRoomServerDetails(pushRoom, sceneRowsForRoom, newBlob);
return Ok(new { success = true, value = roomServerDetails, error = string.Empty });
}
return Ok(savedScene);
}
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; }
// 2023 client shape. rooms/{id}/subrooms/{id}/data arrives as JSON:
// { "UnityAssetId": null,
// "RoomData": { "Filename": "roommeta_….bin", "Hash": …, "OwnershipProof": … },
// "SubRoomData": { "Filename": "dorm_p…_vN.dat", "Hash": …, "OwnershipProof": … } }
// SubRoomData.Filename is the scene save blob (the FileType=1
// /upload result) — that's what must become DataBlobName.
// RoomData.Filename is the FileType=6 room-metadata blob.
public long? UnityAssetId { get; set; }
public BlobRefDto? RoomData { get; set; }
public BlobRefDto? SubRoomData { get; set; }
public string EffectiveRoomDataFilename =>
RoomDataFilename ?? Filename ?? SubRoomData?.Filename ?? string.Empty;
}
public class BlobRefDto
{
public string? Filename { get; set; }
public string? Hash { get; set; }
public string? OwnershipProof { get; set; }
}
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)
{
room = await ShapeDormRoomAsync(room);
}
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)
{
// Stamp the wire Name with the requested name so the watch's
// name-keyed room cache (OJMCBOKJFOF.NHBPIIGDAJP) can find it.
room = await ShapeDormRoomAsync(room, 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)
{
// Stamp the wire Name with the requested name so the watch's
// name-keyed room cache (OJMCBOKJFOF.NHBPIIGDAJP) finds it.
room = await ShapeDormRoomAsync(room, 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)
{
room = await ShapeDormRoomAsync(room);
}
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;
private async Task ShapeDormRoomAsync(RoomEntity room, string? overrideName = null)
{
var dormBlobName = await rooms.ResolveDormDataBlobNameAsync(room.CreatorPlayerId, room.Id);
var dormName = overrideName ?? await rooms.BuildPersonalDormDisplayNameAsync(room.CreatorPlayerId);
return CloneWithCreator(room, room.CreatorPlayerId, dormBlobName, dormName);
}
public static RoomEntity CloneWithCreator(
RoomEntity src, long creatorId, string? overrideBlobName = null,
string? overrideName = null) => new()
{
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,
AllowNewUsers = src.AllowNewUsers,
MinLevel = src.MinLevel,
MaxPlayerCalculationMode = src.MaxPlayerCalculationMode,
LoadScreensJson = src.LoadScreensJson,
PromoImagesJson = src.PromoImagesJson,
PromoExternalContentJson = src.PromoExternalContentJson,
RoomWarningMask = src.RoomWarningMask,
CustomRoomWarning = src.CustomRoomWarning,
DisableMicAutoMute = src.DisableMicAutoMute,
LocationReplicationId = src.LocationReplicationId,
IsStudioRoom = src.IsStudioRoom,
IsRoomLinkedToRecRoomStudio = src.IsRoomLinkedToRecRoomStudio,
StudioSessionId = src.StudioSessionId,
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 = RoomTagTypeForWire(t),
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.
//
// If a room has a saved blob, use it. Otherwise point at
// a synthetic CDN key, which misses S3 and is served by
// RoomDataBlobService.GetDefaultBlob. That default is a captured
// PersistedRoomData (v38); the older synthetic role-only blob and
// empty DataBlobName path both leave the 2023 room-permissions
// runtime without data it dereferences during spawn.
var roleList = roles ?? Array.Empty();
var dataBlobName = CurrentOrSyntheticDataBlobName(room);
// 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
: SceneOrSyntheticDataBlobName(room, s.DataBlobName, 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,
int? unityAssetTarget = null,
int? unityAssetVersion = null)
{
var roleList = roles ?? Array.Empty();
// 2023's room-permissions runtime needs a real PersistedRoomData
// source during spawn. If no saved blob exists, use the captured
// default via the synthetic CDN path.
var dataBlobName = CurrentOrSyntheticDataBlobName(room);
var updatedAt = (room.UpdatedAt == default ? DateTime.UtcNow : room.UpdatedAt)
.ToString("yyyy-MM-ddTHH:mm:ssZ");
// The blob the room actually loads from: sub-room 0's, override and
// all. `dataBlobName` above is the per-ROOM name, which is the wrong
// answer whenever a caller passes an override — a dorm's data is
// per-PLAYER (dorm_p{id}_v*.dat) and arrives that way.
var primarySubRoom = sceneRows is { Count: > 0 }
? sceneRows.FirstOrDefault(s => s.OrderIndex == 0) ?? sceneRows[0]
: null;
var primarySubRoomDataBlob =
!string.IsNullOrWhiteSpace(overrideDataBlobName) && (sceneRows?.Count ?? 0) <= 1
? overrideDataBlobName!
: primarySubRoom is not null
? SceneOrSyntheticDataBlobName(room, primarySubRoom.DataBlobName, dataBlobName)
: dataBlobName;
object[] subRooms = sceneRows is { Count: > 0 }
? sceneRows.Select(s =>
{
var dataBlob = !string.IsNullOrWhiteSpace(overrideDataBlobName) && sceneRows.Count == 1
? overrideDataBlobName
: SceneOrSyntheticDataBlobName(room, s.DataBlobName, dataBlobName);
// A scene with a baked Studio asset (non-empty UnityAssetId)
// must advertise a Studio-era PersistenceVersion on its
// CurrentSave. The 2023 client gates the baked-Addressables
// load path on this: a version of 0 (the old default) makes
// it treat the save as a pre-baked legacy blob and it never
// fetches unity_assets/{id}/{target}/{version}, so the
// persistence views reference prefabs that were never loaded
// ("Expected a prefab, but found none"). 41 matches the real
// RecRocks export (room.json PersistenceVersion=41).
var baked = !string.IsNullOrWhiteSpace(s.StudioUnityAssetId);
var saveVersion = baked ? StudioPersistenceVersion : 0;
var bakedAssets = BuildStudioBakedAssets(
room,
s,
s.StudioSubRoomDataSaveId ?? 0L,
unityAssetTarget,
unityAssetVersion);
var unityAsset = bakedAssets.FirstOrDefault();
var unityAssetParent = BuildStudioUnityAsset(room, s, bakedAssets);
return (object)new
{
ReplicationId = s.RoomSceneLocationId,
PersistenceVersion = saveVersion,
SupportsJoinInProgress = true,
UseLevelBasedMatchmaking = false,
UseAgeBasedMatchmaking = false,
UseRecRoyaleMatchmaking = false,
SubRoomId = RoomSceneIds.ToWire(s.OrderIndex),
RoomId = room.Id,
Name = s.Name,
UnityAssetId = s.StudioUnityAssetId,
UnityAsset = unityAsset?.Filename ?? string.Empty,
UnityAssetHash = string.Empty,
DataBlob = dataBlob,
DataBlobHash = (string?)null,
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
{
ReplicationId = room.LocationReplicationId,
PersistenceVersion = 0,
SupportsJoinInProgress = true,
UseLevelBasedMatchmaking = false,
UseAgeBasedMatchmaking = false,
UseRecRoyaleMatchmaking = false,
SubRoomId = RoomSceneIds.ToWire(0),
RoomId = room.Id,
Name = "Home",
UnityAssetId = string.Empty,
UnityAsset = string.Empty,
UnityAssetHash = string.Empty,
// Same rule as the populated branch: honour the override,
// so a dorm with no scene rows still points at that
// player's own save rather than a per-room name.
DataBlob = primarySubRoomDataBlob,
DataBlobHash = (string?)null,
DataSavedAt = updatedAt,
IsSandbox = false,
MaxPlayers = 8,
Accessibility = room.Accessibility,
UnitySceneId = room.LocationReplicationId,
},
};
// The creator is Creator (255), never CoOwner (30): the client reads
// the local player's role from this list and only lets a player edit
// roles strictly below their own (see RoomRoleRules).
var wireRoles = RoomRoleRules.BuildWires(room, roleList);
var tags = string.IsNullOrEmpty(room.TagsCsv)
? Array.Empty()
: room.TagsCsv
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(t => (object)new
{
Type = RoomTagTypeForWire(t),
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,
// Player rooms are never RRO on the wire (see RoomService.WireIsRRO):
// the client refuses publish and room settings for an RRO.
IsRRO = RoomService.WireIsRRO(room),
SupportsScreens = room.SupportsScreens,
SupportsWalkVR = room.SupportsWalkVR,
SupportsTeleportVR = room.SupportsTeleportVR,
SupportsVRLow = room.SupportsVRLow,
SupportsQuest2 = true,
SupportsMobile = room.SupportsMobile,
SupportsJuniors = room.AllowsJuniors,
MinLevel = room.MinLevel,
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,
LoadScreenLocked = false,
PersistenceVersion = room.IsStudioRoom ? StudioPersistenceVersion : 0,
IsDeveloperOwned = room.IsStudioRoom || HasRoomTag(room, "developer"),
MaxPlayerCalculationMode = room.MaxPlayerCalculationMode,
SubRooms = subRooms,
Roles = wireRoles,
LoadScreens = JsonArrayOrEmpty(room.LoadScreensJson),
PromoImages = JsonStringArrayOrEmpty(room.PromoImagesJson),
PromoExternalContent = JsonArrayOrEmpty(room.PromoExternalContentJson),
Tags = tags,
// The client's room-details reader (GLEGPFFPDBE) registers these
// five alongside the rest. A registered slot wants a value, and
// leaving one absent is not the same as sending null — it is what
// makes the reader throw mid-deserialise on a response the server
// already answered 200 to. Kept last so the additions are visible
// against the reader's own list; key ORDER does not matter to a
// name-keyed reader, presence does.
// DataBlob / DataBlobHash are deliberately NOT sent at room level,
// even though the reader registers them.
//
// The room a player actually loads is chosen per INSTANCE: the
// matchmake response carries the blob (a dorm's is per-player,
// dorm_p{id}_v*.dat), and each sub-room carries its own. Adding a
// room-level name on top gave the client a second, room-scoped
// answer for the same question, and it loaded that one — a file
// that for a dorm was never written — then rejected the room with
// "the data in the room you are trying to load is corrupt".
//
// A registered slot does NOT oblige us to fill it: Newtonsoft
// leaves an absent key at its default, which is the state every
// room loaded fine in. Sending it is what broke the dorm, so the
// absence here is the fix, not an omission to tidy up later.
// The room-level cap is the room's own advertised capacity, which is
// what RoomEntity.MaxCapacity exists for and what matchmaking hands
// to RoomInstance.MaxCapacity. Each sub-room carries its own
// MaxPlayers in the SubRooms array — they are separate settings, so
// this must not be read off sub-room 0.
MaxPlayers = room.MaxCapacity,
// ToxMod is Rec Room's hosted voice moderation. There is no such
// service here, so it is off — the client only uses this to decide
// whether to show the indicator.
ToxmodEnabled = false,
RankingContext = (object?)null,
};
}
private static int RoomTagTypeForWire(string tag)
{
if (string.Equals(tag, "beta", StringComparison.OrdinalIgnoreCase)) return 1;
return tag.ToLowerInvariant() switch
{
"rrstudio" or "community" or "screen" or "walkvr" or "teleportvr" or "junior" or "pickup" => 2,
_ => 0,
};
}
private static bool HasRoomTag(RoomEntity room, string tag)
{
return !string.IsNullOrWhiteSpace(room.TagsCsv) &&
room.TagsCsv
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Any(t => string.Equals(t, tag, StringComparison.OrdinalIgnoreCase));
}
private static JsonElement[] JsonArrayOrEmpty(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return Array.Empty();
try
{
return JsonSerializer.Deserialize(json) ?? Array.Empty();
}
catch (JsonException)
{
return Array.Empty();
}
}
private static string[] JsonStringArrayOrEmpty(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return Array.Empty();
try
{
return JsonSerializer.Deserialize(json) ?? Array.Empty();
}
catch (JsonException)
{
return Array.Empty();
}
}
/// PersistenceVersion / UgcSubVersion stamped on a baked Studio
/// scene's CurrentSave. The 2023 client's room-load gate treats anything
/// below the Studio-asset-bundle era as a legacy non-baked save and skips
/// the Addressables bundle fetch. 41 matches the real RecRocks export
/// (room.json + the per-save sidecars).
public const int StudioPersistenceVersion = 41;
/// Room-level UgcVersion for a Studio room. The client uses this
/// as the asset-bundle version in unity_assets/{id}/{target}/{version}
/// (RecRocks bundles bake at v1, room.json UgcVersion=1).
public const int StudioUgcVersion = 1;
// ── 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("roomserver/rooms/ownedby/{otherPlayerId:long}")]
[HttpGet("rooms/createdby/{otherPlayerId:long}")]
[HttpGet("rooms/ownedby/{otherPlayerId:long}")]
public async Task CreatedByOther(long otherPlayerId)
=> Ok((await rooms.CreatedByAsync(otherPlayerId)).Select(RoomService.ToWireRoom).ToList());
/// Per-player showcase curation, stored in the existing generic
/// player-settings table as a CSV of room ids. Colon-namespaced to match
/// the other server-owned keys (settings:partyinvite ).
private const string ShowcaseSettingKey = "rooms:showcase";
private const int ShowcaseMaxRooms = 50;
/// GET showcase/{accountId} — the profile "Showcase Rooms"
/// carousel (RoomListModel.JMGLGEKHHAK.PlayerShowcaseRooms = 7 ,
/// dump.cs:2042498, driven by
/// AccountModelController.RoomsShowcaseLinkImpl at dump.cs:2113041).
/// Distinct from PlayerCreatedRooms = 5 , which is the
/// rooms/createdby/{id} list above.
///
/// Verb. NLDBPDCNNCF.EJPNHLIJNPM formats the literal
/// "showcase/{0}" (NLDBPDCNNCF.txt ISIL 021 at :4484) and moves the
/// HTTPMethods ordinal 0 (= GET) into rdx immediately before the
/// dispatch call (ISIL 031/032 at :4494-4495). No cmov, so GET is the only
/// verb. The path arg is boxed as System.Int32 (ISIL 017) — the
/// account id.
///
/// Response shape. The issuing method's return type is the
/// contract: Task<List<System.Int64>>
/// (NLDBPDCNNCF.txt:4422) — a BARE JSON array of room ids, not Room
/// objects and not a {Results,TotalResults} container. The Rooms the
/// UI renders are a purely client-side projection: the cache entry
/// <GetRoomsShowcase>b__0 is typed
/// Task<IReadOnlyList<Int64>>
/// (IBEOONPEELF_NestedType_HHDHJPIJBJB.txt:14) and the outer
/// IBEOONPEELF.EJPNHLIJNPM resolves those ids through the room cache
/// to FGLDKEJLAKB<IReadOnlyList<NEMINAEBALC>>
/// (IBEOONPEELF.txt:8952) before RoomListModel consumes it
/// (RoomListModel.txt ISIL 742 at :5819).
///
/// Backing data. showcase/{0} is the only showcase
/// literal in the binary — the 2023 client can read the list but never
/// writes it (curation lived on the website), so the curated order is kept
/// in the existing per-player settings store under
/// and is writable through
/// PUT /settings/v1/{accountId} with no new schema. Curated ids are
/// re-validated against Rooms on every read so a room that has since been
/// archived or privatised drops off the profile, and the curated order is
/// preserved. A player who never curated one falls back to the rooms they
/// actually created, most prominent first — the showcase the carousel is
/// there to display.
///
/// Unauthenticated on purpose: this is another player's profile
/// surface, and the sibling rooms/createdby/{id} is open too. The
/// owner viewing their own profile additionally sees their non-public
/// picks; everyone else sees public rooms only.
[HttpGet("showcase/{accountId:long}")]
// roomserver/ twin: EJPNHLIJNPM lives on NLDBPDCNNCF, whose requests all
// arrive with the roomserver/ prefix in this deployment
// (docs/recroom-2023-room-save.md:83-93) — same reason every other
// NLDBPDCNNCF route in this file is registered twice.
[HttpGet("roomserver/showcase/{accountId:long}")]
public async Task RoomsShowcase(long accountId)
{
var viewerIsOwner = CurrentPlayerId == accountId;
var curatedCsv = await db.PlayerSettings.AsNoTracking()
.Where(s => s.PlayerId == accountId && s.Key == ShowcaseSettingKey)
.Select(s => s.Value)
.FirstOrDefaultAsync();
var curatedIds = (curatedCsv ?? string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(v => long.TryParse(v, out var id) ? id : 0L)
.Where(id => id > 0)
.Distinct()
.Take(ShowcaseMaxRooms)
.ToList();
if (curatedIds.Count > 0)
{
var visible = await db.Rooms.AsNoTracking()
.Where(r => curatedIds.Contains(r.Id)
&& r.State == 0
&& !r.IsDormRoom
&& !r.HiddenFromBrowse
&& (r.Accessibility == 1 || viewerIsOwner))
.Select(r => r.Id)
.ToListAsync();
var visibleSet = visible.ToHashSet();
// Curated order is the display order — filter, don't re-sort.
var ordered = curatedIds.Where(visibleSet.Contains).ToList();
if (ordered.Count > 0) return Ok(ordered);
}
var fallback = await db.Rooms.AsNoTracking()
.Where(r => r.CreatorPlayerId == accountId
&& r.State == 0
&& !r.IsDormRoom
&& !r.HiddenFromBrowse
&& (r.Accessibility == 1 || viewerIsOwner))
.OrderByDescending(r => r.HotScore)
.ThenByDescending(r => r.VisitCount)
.ThenByDescending(r => r.UpdatedAt)
.Select(r => r.Id)
.Take(ShowcaseMaxRooms)
.ToListAsync();
return Ok(fallback);
}
[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 async Task VisitedByMe()
{
var pid = CurrentPlayerId;
if (pid is null) return Ok(Array.Empty());
return await VisitedByForViewerAsync(pid.Value, pid.Value);
}
[HttpGet("rooms/visitedby/{playerId:long}")]
[HttpGet("roomserver/rooms/visitedby/{playerId:long}")]
public async Task VisitedBy(long playerId)
{
return await VisitedByForViewerAsync(playerId, CurrentPlayerId);
}
private async Task VisitedByForViewerAsync(long playerId, long? viewerPlayerId)
{
var rows = await db.RoomVisits.AsNoTracking()
.Where(v => v.PlayerId == playerId)
.Join(
db.Rooms.AsNoTracking(),
visit => visit.RoomId,
room => room.Id,
(visit, room) => new { visit, room })
.Where(x => x.room.Accessibility == 1 ||
(viewerPlayerId.HasValue && x.room.CreatorPlayerId == viewerPlayerId.Value))
.OrderByDescending(x => x.visit.LastVisitAt)
.Select(x => x.room)
.Take(100)
.ToListAsync();
if (rows.Count == 0) return Ok(Array.Empty());
return Ok(await BuildRoomServerListAsync(rows));
}
[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 async Task ModeratedByMe()
{
var pid = CurrentPlayerId;
if (pid is null) return Ok(Array.Empty());
var ids = await db.RoomRoles.AsNoTracking()
.Where(r => r.PlayerId == pid.Value && r.Accepted)
.OrderByDescending(r => r.GrantedAt)
.Select(r => r.RoomId)
.Distinct()
.Take(100)
.ToListAsync();
var rows = await db.Rooms.AsNoTracking()
.Where(r => ids.Contains(r.Id))
.OrderByDescending(r => r.UpdatedAt)
.ToListAsync();
return Ok(await BuildRoomServerListAsync(rows));
}
[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 async Task FavoritedByMe()
{
var pid = CurrentPlayerId;
if (pid is null) return Ok(Array.Empty());
var rows = await rooms.BookmarkedByAsync(pid.Value);
return Ok(await BuildRoomServerListAsync(rows));
}
[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 async Task CheeredByMe()
{
var pid = CurrentPlayerId;
if (pid is null) return Ok(Array.Empty());
var ids = await db.Cheers.AsNoTracking()
.Where(c => c.FromPlayerId == pid.Value && c.TargetRoomId != 0)
.OrderByDescending(c => c.CheeredAt)
.Select(c => c.TargetRoomId)
.Distinct()
.Take(100)
.ToListAsync();
var rows = await db.Rooms.AsNoTracking()
.Where(r => ids.Contains(r.Id))
.OrderByDescending(r => r.HotScore)
.ToListAsync();
return Ok(await BuildRoomServerListAsync(rows));
}
[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());
/// Per-(player, room) persistence blob, base64. Lives in the
/// generic key/value table — the same
/// "no schema change needed" trick MatchPlayerController uses for its
/// matchmaking prefs (MatchPlayerController.cs:213-217). The Value column
/// is declared MaxLength(1024) but SQLite stores it as unconstrained
/// TEXT and EF Core does not run DataAnnotations validation on SaveChanges,
/// so oversized blobs round-trip intact.
private static string PlayerRoomDataKey(long roomId) => $"roomplayerdata:{roomId}";
/// Fallback blob handed to a player who has never saved anything
/// in this room. CAE= is a one-field protobuf the client parses as
/// "empty, version 1"; a truly empty string makes the 2023 reader throw.
private const string DefaultPlayerRoomData = "CAE=";
/// GET rooms/{roomId}/playerdata/me — the per-room player
/// blob (CircuitsV2 "player persistence"). Client contract is
/// Task<ILKMFMCOPPO> whose reader requires the key
/// Data (OPNPNPMKLGI.txt ISIL 034/060); the request is issued from
/// NLDBPDCNNCF (NLDBPDCNNCF_NestedType_IGENGEIMGMJ.txt:169), so it arrives
/// with the roomserver/ prefix — the bare-only registration 404'd.
/// This used to return a hardcoded CAE= for everyone; it now returns
/// whatever last stored.
[HttpGet("player_room_data/{roomId:long}")]
[HttpGet("rooms/{roomId:long}/playerdata/me")]
[HttpGet("roomserver/rooms/{roomId:long}/playerdata/me")]
[Authorize]
public async Task PlayerDataForMe(long roomId)
{
var pid = CurrentPlayerId;
if (pid is null) return Unauthorized();
var room = await db.Rooms.AsNoTracking()
.Where(r => r.Id == roomId)
.Select(r => new { r.Id, r.VisitCount, r.CheerCount, r.FavoriteCount })
.FirstOrDefaultAsync();
var favorited = await db.RoomBookmarks
.AnyAsync(b => b.RoomId == roomId && b.PlayerId == pid.Value);
var cheered = await db.Cheers
.AnyAsync(c => c.TargetRoomId == roomId && c.FromPlayerId == pid.Value);
var visit = await db.RoomVisits.AsNoTracking()
.Where(v => v.RoomId == roomId && v.PlayerId == pid.Value)
.Select(v => new { v.VisitCount, v.FirstVisitAt, v.LastVisitAt })
.FirstOrDefaultAsync();
var key = PlayerRoomDataKey(roomId);
var stored = await db.PlayerSettings.AsNoTracking()
.Where(s => s.PlayerId == pid.Value && s.Key == key)
.Select(s => s.Value)
.FirstOrDefaultAsync();
return Ok(new
{
Data = string.IsNullOrEmpty(stored) ? DefaultPlayerRoomData : stored,
RoomId = room?.Id ?? roomId,
PlayerId = pid.Value,
Favorite = favorited,
Favorited = favorited,
IsFavorite = favorited,
Cheer = cheered,
Cheered = cheered,
IsCheered = cheered,
IsBookmarked = favorited,
IsCheering = cheered,
VisitCount = visit?.VisitCount ?? 0,
FirstVisitedAt = visit?.FirstVisitAt,
LastVisitedAt = visit?.LastVisitAt,
RoomVisitCount = room?.VisitCount ?? 0,
RoomCheerCount = room?.CheerCount ?? 0,
RoomFavoriteCount = room?.FavoriteCount ?? 0,
});
}
public sealed class PlayerRoomDataRequest { public string? Data { get; set; } }
/// PUT rooms/{roomId}/playerdata/me — persist the caller's
/// per-room blob. Client method NLDBPDCNNCF.LIHFBBCDAGO(long, string)
/// ("save player room data") moves verb ordinal 3 = PUT into r8 and posts a
/// single form field data
/// (NLDBPDCNNCF.txt ISIL 045/061/065 around :2566-2586; form key at
/// NLDBPDCNNCF_NestedType_ANPKFGHOOAJ.txt ISIL 015). Its return type is the
/// non-generic Task , so no response body is parsed.
///
/// Only the GET was registered before, so every save 405'd and per-room
/// player state never persisted.
[HttpPut("rooms/{roomId:long}/playerdata/me")]
[HttpPost("rooms/{roomId:long}/playerdata/me")]
[HttpPut("roomserver/rooms/{roomId:long}/playerdata/me")]
[HttpPost("roomserver/rooms/{roomId:long}/playerdata/me")]
[HttpPut("player_room_data/{roomId:long}")]
[Authorize]
public async Task SavePlayerDataForMe(
long roomId,
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] PlayerRoomDataRequest req)
{
var pid = CurrentPlayerId;
if (pid is null) return Unauthorized();
var data = req.Data ?? string.Empty;
var key = PlayerRoomDataKey(roomId);
var row = await db.PlayerSettings
.FirstOrDefaultAsync(s => s.PlayerId == pid.Value && s.Key == key);
if (row is null)
{
db.PlayerSettings.Add(new PlayerSettingEntity
{
PlayerId = pid.Value, Key = key, Value = data,
});
}
else
{
row.Value = data;
}
await db.SaveChangesAsync();
// The client ignores the body (non-generic Task); the echo is for
// curl/admin debugging only.
return Ok(new { Result = 0, RoomId = roomId, PlayerId = pid.Value, Data = data });
}
/// GET rooms/requiring/{restriction} — the restricted-rooms
/// list. The path segment is mode.ToString().ToLower()
/// (Matchmaking.txt ISIL 067-072 around :21263, concat onto the literal
/// "rooms/requiring/" at :21264).
///
/// Element type is String, not Int64 — verified, do not "fix" this
/// to room ids. The issuing method
/// Matchmaking.POIMPHMONON(IOCOJFJEDCM) (Matchmaking.txt:21030)
/// resolves its promise as FGLDKEJLAKB<List<String>>
/// (typeof load at Matchmaking.txt:21327, continuation delegate
/// Action<List<String>> at :21315) and hands the payload to
/// <RequestRoomRestrictions>b__2(List<String> rooms)
/// (Matchmaking_NestedType_PMNEOAMKLJL.txt:14), which forwards it to
/// Matchmaking.IINEGHJJBAN(IEnumerable<String>, IOCOJFJEDCM)
/// (:20758) where the strings become the KEYS of the restriction map
/// (List<IOCOJFJEDCM> per key, ISIL 069-079 at :20963-20974).
/// So a bare array of room NAMES is the correct wire shape. Failures log
/// "Failed to get restricted rooms list: " (Matchmaking.txt:21306).
[HttpGet("rooms/requiring/{restriction}")]
[HttpGet("roomserver/rooms/requiring/{restriction}")]
[Authorize]
public async Task RoomsRequiring(string restriction)
{
var key = (restriction ?? string.Empty).Trim().TrimStart('#').ToLowerInvariant();
if (key.Length == 0) return Ok(new List());
IQueryable query = db.Rooms.AsNoTracking()
.Where(r => !r.HiddenFromBrowse && r.State == 0);
query = key switch
{
"developer" or "studio" or "rrstudio" => query.Where(r =>
r.IsStudioRoom
|| r.IsRoomLinkedToRecRoomStudio
|| EF.Functions.Like(r.TagsCsv, "%developer%")
|| EF.Functions.Like(r.TagsCsv, "%studio%")),
"rrplus" or "recroomplus" => query.Where(r =>
EF.Functions.Like(r.TagsCsv, "%rrplus%")
|| EF.Functions.Like(r.TagsCsv, "%recroomplus%")),
_ => query.Where(r => EF.Functions.Like(r.TagsCsv, $"%{key}%")),
};
var rows = await query
.OrderByDescending(r => r.HotScore)
.ThenBy(r => r.Name)
.Select(r => r.Name)
.Take(100)
.ToListAsync();
return Ok(rows);
}
[HttpGet("rooms/curated_playlists")]
[HttpGet("roomserver/rooms/curated_playlists")]
[Authorize]
public async Task CuratedPlaylistsCompat()
{
var curated = await playlists.CuratedAsync();
return Ok(curated.Select(p => p.Id).ToList());
}
[HttpGet("clubhousesearch/mostactivenow")]
[HttpGet("roomserver/clubhousesearch/mostactivenow")]
[Authorize]
public async Task MostActiveClubhouses()
{
var rows = await db.Clubs.AsNoTracking()
.Where(c => c.State == 0 && c.ClubhouseRoomId != null)
.GroupJoin(
db.ClubMemberships.AsNoTracking(),
c => c.Id,
m => m.ClubId,
(club, memberships) => new
{
Club = club,
MemberCount = memberships.Count(),
})
.Join(
db.Rooms.AsNoTracking(),
c => c.Club.ClubhouseRoomId!.Value,
r => r.Id,
(c, room) => new
{
c.Club,
c.MemberCount,
Room = room,
})
.OrderByDescending(x => x.MemberCount)
.ThenByDescending(x => x.Room.HotScore)
.Take(50)
.ToListAsync();
return Ok(rows.Select(x => new
{
ClubId = x.Club.Id,
x.Club.Name,
x.Club.Description,
MainImageName = x.Club.ImageName,
ImageName = x.Club.ImageName,
State = x.Club.State,
CreatorAccountId = x.Club.CreatorPlayerId,
Category = x.Club.Category,
Visibility = x.Club.Visibility,
Joinability = x.Club.Joinability,
x.Club.AllowJuniors,
MemberCount = x.MemberCount,
x.Club.IsRRO,
x.Club.ClubhouseRoomId,
x.Club.ClubType,
Room = RoomService.ToWireRoom(x.Room),
RoomId = x.Room.Id,
RoomName = x.Room.Name,
}).ToList());
}
// ── 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 = "" });
}
private IQueryable PublicRoomQuery() =>
db.Rooms.AsNoTracking().Where(r =>
r.State == 0 &&
r.Accessibility == 1 &&
!r.IsDormRoom &&
!r.HiddenFromBrowse);
private async Task> BuildRoomServerListAsync(IReadOnlyCollection roomRows)
{
if (roomRows.Count == 0) return new List();
var ids = roomRows.Select(r => r.Id).Distinct().ToList();
var sceneRows = await db.RoomScenes.AsNoTracking()
.Where(s => ids.Contains(s.RoomId))
.OrderBy(s => s.OrderIndex)
.ToListAsync();
var scenesByRoom = sceneRows
.GroupBy(s => s.RoomId)
.ToDictionary(g => g.Key, g => (IReadOnlyList)g.ToList());
var roleRows = await db.RoomRoles.AsNoTracking()
.Where(r => ids.Contains(r.RoomId))
.ToListAsync();
var rolesByRoom = roleRows
.GroupBy(r => r.RoomId)
.ToDictionary(g => g.Key, g => (IReadOnlyList)g.ToList());
return roomRows
.Select(r => BuildRoomServerDetails(
r,
scenesByRoom.GetValueOrDefault(r.Id),
roles: rolesByRoom.GetValueOrDefault(r.Id)))
.ToList();
}
// ── 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.
// Bare array of Int64 — the 2023 issuing method GBONEKLGFFK returns
// Task> (NLDBPDCNNCF.txt:1886, literal at :1929). The
// roomserver/ twin is what that client actually requests (same prefix
// rule as rooms/base, docs/recroom-2023-room-save.md:83-93).
[HttpGet("api/rooms/v1/agRoomIds")]
[HttpGet("rooms/rro_ids")]
[HttpGet("roomserver/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.
// Same roomserver/ prefix requirement as rooms/base.
[HttpGet("featuredrooms/current")]
[HttpGet("roomserver/featuredrooms/current")]
public async Task FeaturedRoomsCurrent()
{
var ids = await rooms.FeaturedAgRoomIdsAsync(12);
var featuredRooms = await db.Rooms
.Where(r => ids.Contains(r.Id))
.ToListAsync();
var byId = featuredRooms.ToDictionary(r => r.Id);
// The featured-tile reader looks for RoomName, which ToWireRoom does not
// emit — every tile's caption came back null. Re-project the wire object
// into a dictionary so the extra key sits alongside the real ones rather
// than nesting them.
var wireRooms = ids
.Where(byId.ContainsKey)
.Select(id =>
{
var room = byId[id];
var wire = RoomService.ToWireRoom(room);
var map = new Dictionary();
foreach (var prop in wire.GetType().GetProperties())
map[prop.Name] = prop.GetValue(wire);
map["RoomName"] = room.Name;
return (object)map;
})
.ToList();
return Ok(new
{
FeaturedRoomGroupId = 1L,
Name = "Featured",
Rooms = wireRooms,
});
}
/// 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.
///
/// 2023 client: NLDBPDCNNCF.LHNDKOJNKBE
/// ("change room name", NLDBPDCNNCF.txt:5069) moves verb ordinal 3 = PUT,
/// sends the lowercase form key name
/// (NLDBPDCNNCF_NestedType_LMADOAEHOPP.txt ISIL 015) and returns
/// Task<FGCPNAACHIK> — the FULL room-details object, not a
/// status wrapper. It also carries the roomserver/ prefix like every
/// other room mutation from that client
/// (docs/recroom-2023-room-save.md:83-93), so the bare-only routes 404'd.
/// The old signature mixed [FromForm] with a [FromBody]
/// parameter, which under [ApiController] makes the form PUT 415
/// before the handler runs — hence the shared FormOrJsonModelBinder.
public sealed class RenameBody { public string? Name { get; set; } }
[HttpPost("rooms/{roomId:long}/name")]
[HttpPut("rooms/{roomId:long}/name")]
[HttpPost("roomserver/rooms/{roomId:long}/name")]
[HttpPut("roomserver/rooms/{roomId:long}/name")]
[Authorize]
public async Task RenameRoom(
long roomId,
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] RenameBody body)
{
var newName = 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.
// Room settings are creator / co-owner only; moderators and
// hosts moderate players, they don't edit the room.
if (!await CanEditRoomAsync(room, me)) 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);
// Full room details, not the old minimal {Result,RoomId,Name,…} object:
// the 2023 issuing method returns Task (NLDBPDCNNCF.txt:5069)
// and that reader needs Room/Scenes/CoOwners/Stats — the trimmed payload
// made a successful rename surface as a parse failure in the client.
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
}
/// 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.
///
/// 2023 client: NLDBPDCNNCF.EJOHFBMFEGB
/// ("change subroom name", NLDBPDCNNCF.txt:8694) is verb 3 = PUT with form
/// key name and returns Task<FGCPNAACHIK> . Same
/// [FromForm]+[FromBody] 415 hazard as the room rename above, so it binds
/// through the shared FormOrJsonModelBinder and echoes full details.
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/name")]
[HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/name")]
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/name")]
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/name")]
[Authorize]
public async Task RenameSubroom(
long roomId,
long subRoomId,
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] RenameBody body)
{
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var newName = 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)
{
// Room settings are creator / co-owner only; moderators and
// hosts moderate players, they don't edit the room.
if (!await CanEditRoomAsync(room, me)) 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(await BuildRoomServerDetailsWithRolesAsync(roomId));
}
/// 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 ─────────────────────────────
//
// Verbs come straight from the 2023 client's four one-liner methods on
// NLDBPDCNNCF (each a non-generic Task, so no response body is parsed):
// "cheer room" :13467 r8 = 3 (PUT)
// "uncheer room" :13550 r8 = 4 (DELETE)
// "favorite room" :13633 r8 = 3 (PUT)
// "unfavorite room" :13716 r8 = 4 (DELETE)
// All four are issued from NLDBPDCNNCF, so they arrive with the
// roomserver/ prefix (docs/recroom-2023-room-save.md:83-93) — without the
// twins the cheer/favorite buttons 404'd and failed silently.
[HttpPost("rooms/{roomId:long}/interactionby/me/cheer")]
[HttpPut("rooms/{roomId:long}/interactionby/me/cheer")]
[HttpPost("roomserver/rooms/{roomId:long}/interactionby/me/cheer")]
[HttpPut("roomserver/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")]
[HttpDelete("roomserver/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")]
[HttpPost("roomserver/rooms/{roomId:long}/interactionby/me/favorite")]
[HttpPut("roomserver/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")]
[HttpDelete("roomserver/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?}")]
[HttpGet("rooms/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. The 2023 client embeds
// &skip/&take in the same segment (see ParsePagedPathSegment).
//
// 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 (tagsValue, _, _) = ParsePagedPathSegment(tagsJoined, defaultTake: 100);
var firstTag = string.IsNullOrWhiteSpace(tagsValue)
? null
: tagsValue.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?}")]
[HttpGet("rooms/hot_rooms/{tagsJoined?}")]
[HttpGet("roomserver/hot_rooms/{tagsJoined?}")]
public async Task HotRoomsOnlyPath(string? tagsJoined)
{
var (tagsValue, _, _) = ParsePagedPathSegment(tagsJoined, defaultTake: 12);
var firstTag = string.IsNullOrWhiteSpace(tagsValue)
? null
: tagsValue.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 = RoomTagTypeForWire(t),
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}")]
[HttpGet("rooms/search_roomsandplaylists/{query}")]
public async Task SearchRoomsAndPlaylists(string query)
{
var (text, _, _) = ParsePagedPathSegment(query, defaultTake: 50);
var (merged, _) = await BuildSearchUnionAsync(text);
return Ok(merged);
}
/// GET search_rooms/{query} — rooms-only search
/// variant. Same MKAMHOIHOJK union entry shape as
/// search_roomsandplaylists/{query} , just skips playlist
/// rows.
///
/// The 2023 client's play-menu search (IBEOONPEELF.SearchRooms ,
/// URL builder GGDPFFDEDKN ) calls the rooms/ -prefixed
/// variant and embeds paging in the SAME path segment:
/// rooms/search_rooms/{query}&skip={skip}&take={take} .
/// Missing the prefixed route = "Error refreshing rooms from source
/// ServerSearch" in the play menu.
[HttpGet("search_rooms/{query}")]
[HttpGet("rooms/search_rooms/{query}")]
[HttpGet("roomserver/search_rooms/{query}")]
public async Task SearchRoomsOnly(string query)
{
var (text, skip, take) = ParsePagedPathSegment(query, defaultTake: 50);
if (string.IsNullOrWhiteSpace(text))
return Ok(Array.Empty());
var roomList = (await rooms.SearchAsync(text, take: skip + take))
.Skip(skip).Take(take).ToList();
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());
}
/// Split a 2023-style browse-source path segment into its
/// value and paging. The 2023 URL builder (GGDPFFDEDKN )
/// formats search_rooms/{0}&skip={1}&take={2} /
/// hot_rooms/{0}&skip={1}&take={2} — the paging pairs
/// land inside the final path segment, not the query string, so
/// route params capture "pizza&skip=0&take=20" verbatim.
/// 2020 URLs have no '&' and pass through unchanged.
private static (string Value, int Skip, int Take) ParsePagedPathSegment(string? raw, int defaultTake)
{
if (string.IsNullOrEmpty(raw)) return (string.Empty, 0, defaultTake);
var parts = raw.Split('&');
var skip = 0;
var take = defaultTake;
foreach (var pair in parts.Skip(1))
{
var kv = pair.Split('=', 2);
if (kv.Length != 2 || !int.TryParse(kv[1], out var n)) continue;
if (kv[0].Equals("skip", StringComparison.OrdinalIgnoreCase)) skip = n;
else if (kv[0].Equals("take", StringComparison.OrdinalIgnoreCase)) take = n;
}
return (parts[0], Math.Max(0, skip), Math.Clamp(take, 1, 100));
}
///
/// 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.
///
/// 2023 client: NLDBPDCNNCF.CMHBCOIBDME ("add new
/// subroom", NLDBPDCNNCF.txt:9396) is verb 2 = POST with a single form key
/// name (NLDBPDCNNCF_NestedType_AIPHLHBILDN.txt ISIL 015) and returns
/// Task<FGCPNAACHIK> . It needs the roomserver/ twin, a binder
/// that accepts the form body ([FromBody] 415s it), and full room
/// details in the response instead of the scene row.
[HttpPost("rooms/{roomId:long}/subrooms")]
[HttpPost("roomserver/rooms/{roomId:long}/subrooms")]
[Authorize]
public async Task AddSubRoom(
long roomId,
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] CreateSubRoomRequest req)
{
var pid = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
if (!await CanEditRoomAsync(room, 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(await BuildRoomServerDetailsWithRolesAsync(roomId));
}
/// DELETE rooms/{id}/subrooms/{sub} . The 2023 caller
/// NLDBPDCNNCF.HGNGMIMJFJL ("delete subroom", NLDBPDCNNCF.txt:9827)
/// moves verb ordinal 4 = DELETE and returns
/// Task<FGCPNAACHIK> ; the only registration here used to be the
/// bare one (the roomserver/ twin at the top of the file is the GET), so
/// deleting a subroom 404'd, and the {Result:0} body was not the
/// details object the client re-parses.
[HttpDelete("rooms/{roomId:long}/subrooms/{subRoomId:long}")]
[HttpDelete("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}")]
[Authorize]
public async Task DeleteSubRoom(long roomId, long subRoomId)
{
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var pid = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
if (!await CanEditRoomAsync(room, 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(await BuildRoomServerDetailsWithRolesAsync(roomId));
}
// Subroom mutation DTOs. Property names mirror the LOWERCASE form keys the
// 2023 client actually sends (FormOrJsonModelBinder matches case-insensitively):
// maxPlayers NLDBPDCNNCF_NestedType_BLNOGFGHIIF.txt ISIL 020
// accessibility NLDBPDCNNCF_NestedType_GNCLJBNJEKP.txt ISIL 020
// name/accessibility/maxPlayers ..._JEEACJHABAF.txt ISIL 021/035/048
// newRoomId NLDBPDCNNCF_NestedType_COIOGADEBKJ.txt ISIL 020
// The older {Value:…} DTOs matched no client key at all, and being bound
// [FromBody] they 415'd the form body before the handler ran.
public sealed class SubRoomMaxPlayersRequest { public int? MaxPlayers { get; set; } }
// Accessibility is typed string, not int, on purpose — the two client call
// sites disagree about how they stringify it. The standalone
// "change subroom accessibility" closure boxes the value as
// typeof(JHAOFLCJNAL) (GNCLJBNJEKP.txt ISIL 015), and the form writer's
// object overload falls through to a virtual ToString() for anything that
// isn't String/DateTime (BNDIAONDFFF.txt ISIL 107-110) — i.e. an enum
// MEMBER NAME goes on the wire. The bulk "modify" closure boxes the very
// same value as typeof(System.Int32) (JEEACJHABAF.txt ISIL 031) and sends a
// number. ParseSubRoomAccessibility below accepts both spellings.
public sealed class SubRoomAccessibilityRequest { public string? Accessibility { get; set; } }
public sealed class SubRoomModifyRequest
{
public string? Name { get; set; }
public string? Accessibility { 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
{
/// Target ROOM for the scene — the 2023 "move subroom" flow
/// re-parents a scene into another room. Nullable<Int64> on the
/// wire (NLDBPDCNNCF.MGGALMIAMBG, NLDBPDCNNCF.txt:9659).
public long? NewRoomId { get; set; }
/// Legacy in-room reorder used by the admin/import tooling.
/// Not something the 2023 client sends.
public int? NewIndex { get; set; }
}
/// One row of the per-subroom role-permission matrix the 2023
/// client PUTs to subrooms/{sub}/permissions . Keys verified in the
/// writer HDOJMEBCAGP.txt ISIL 050/069/085/101/117.
public sealed class PublishSubRoomSaveRequest
{
public long? SubRoomDataSaveId { get; set; }
public long? SaveId { get; set; }
public long? StagedSubRoomDataSaveId { get; set; }
public string? DataBlob { get; set; }
public string? DataBlobName { get; set; }
public string? Filename { get; set; }
}
// The "Max Player Count in This Subroom" slider (SubroomModel.
// ChangeSubRoomMaxPlayers) POSTs here on the roomserver host. Like its
// siblings (accessibility/permissions/modify) the client prefixes the
// path with "roomserver/", so the bare-only routes 404'd and the slider's
// change silently never persisted — i.e. the limit couldn't be changed.
// The value flows back to the host via the subroom details MaxPlayers,
// which sets the Photon room cap.
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/maxplayers")]
[HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/maxplayers")]
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/maxplayers")]
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/maxplayers")]
[Authorize]
public Task SubRoomMaxPlayers(
long roomId,
long subRoomId,
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] SubRoomMaxPlayersRequest req) =>
MutateScene(roomId, subRoomId, s => { if (req.MaxPlayers is int v) s.MaxPlayers = Math.Max(1, v); });
/// PUT rooms/{id}/subrooms/{sub}/accessibility . Client:
/// NLDBPDCNNCF.OAINKEAFPGE(long, long, JHAOFLCJNAL)
/// (NLDBPDCNNCF.txt:9032, verb 3), form key accessibility , response
/// Task<FGCPNAACHIK> . We only model the matchmaking bit
/// ( ), so Private collapses to
/// "not a matchmaking target" and everything else to "is one".
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/accessibility")]
[HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/accessibility")]
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/accessibility")]
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/accessibility")]
[Authorize]
public Task SubRoomAccessibility(
long roomId,
long subRoomId,
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] SubRoomAccessibilityRequest req) =>
MutateScene(roomId, subRoomId, s =>
{
if (ParseSubRoomAccessibility(req.Accessibility) is int a)
s.CanMatchmakeInto = a != AccessibilityPrivate;
});
/// PUT rooms/{id}/subrooms/{sub}/permissions — "change
/// subroom permissions", the role-permission matrix. Client:
/// NLDBPDCNNCF.AAMEOMHKPHF(long, long, IReadOnlyList<BNLPBMJJOMM>)
/// (NLDBPDCNNCF.txt:9198, verb 3, same typed helper as every other room
/// mutation, so the response is the full FGCPNAACHIK details).
/// Unlike its siblings the body is NOT form-encoded: the closure calls
/// BNDIAONDFFF.FJLLPHFOOJJ(String) (CCNOMNNHNNG.txt ISIL 009), a raw
/// JSON array of cells. The editor sends one changed cell per call
/// (OMJKHJLFOCO.AFHJLBBFAGP.<LocalRequestModifyRolePermission>g__ChangeAndRefresh
/// builds a one-element BNLPBMJJOMM[]), so each cell is upserted; cells not
/// sent are left alone.
///
/// Read back through GET photon_access_token (its
/// Permissions list), and pushed to everyone in that sub-room as
/// the PhotonAccessToken hub message so the change takes effect
/// without a rejoin.
///
/// Authorization mirrors the client's own rule
/// (OMJKHJLFOCO.DIIMLMKEJLC): the creator may edit every role, including
/// Co-Owner and Creator; a co-owner only the roles below their own;
/// nobody else may edit ( ).
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")]
[HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")]
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")]
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")]
[Authorize]
public async Task SubRoomPermissions(long roomId, long subRoomId)
{
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var pid = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
var roleRows = await db.RoomRoles.Where(r => r.RoomId == roomId).ToListAsync();
if (!RoomRoleRules.CanEditRoom(room, roleRows, pid)) return Forbid();
string body;
using (var reader = new StreamReader(Request.Body))
body = await reader.ReadToEndAsync();
var cells = RoomPermissionMatrix.Parse(body);
if (cells is null) return BadRequest(new { error = "invalid_permissions_body" });
var denied = cells.Where(c => !RoomRoleRules.CanEditRolePermissions(room, roleRows, pid, c.Role)).ToList();
if (denied.Count > 0)
{
logger.LogWarning(
"[subroom-permissions] denied room={Room} sub={Sub} by={By} roles={Roles}",
roomId, subRoomId, pid, string.Join(",", denied.Select(d => d.Role).Distinct()));
return Forbid();
}
var scene = await GetOrCreateSceneForMutationAsync(room, subRoomId);
if (scene is null) return NotFound();
var existing = await db.RoomRolePermissions
.Where(p => p.RoomId == roomId && p.SubRoomId == subRoomId)
.ToListAsync();
var now = DateTime.UtcNow;
foreach (var cell in cells)
{
var row = existing.FirstOrDefault(p =>
p.Role == cell.Role
&& string.Equals(p.Permission, cell.Permission, StringComparison.OrdinalIgnoreCase));
if (row is null)
{
row = new RoomRolePermissionEntity
{
RoomId = roomId,
SubRoomId = subRoomId,
Role = cell.Role,
Permission = cell.Permission,
};
db.RoomRolePermissions.Add(row);
existing.Add(row);
}
row.Type = cell.Type;
row.Override = cell.Override;
row.Value = cell.Value;
row.UpdatedByPlayerId = pid;
row.UpdatedAt = now;
}
scene.DataModifiedAt = now;
await db.SaveChangesAsync();
logger.LogInformation(
"[subroom-permissions] room={Room} sub={Sub} by={By} cells={Count}",
roomId, subRoomId, pid, cells.Count);
// Everyone in that sub-room gets the new matrix; the client's
// PhotonAccessToken handler re-initialises its permissions from it.
var matrix = await RoomPermissionMatrix.LoadAsync(db, roomId, subRoomId);
foreach (var (instance, players) in presence.EnumerateActiveInstances(roomId, includePrivate: true))
{
if (instance.SubRoomId != subRoomId) continue;
var tokenBody = RoomPermissionMatrix.AccessTokenBody(instance.RoomInstanceId, matrix);
foreach (var other in players)
await notifications.NotifyTypedAsync(other, "PhotonAccessToken", tokenBody);
}
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
}
// JHAOFLCJNAL member values. The numeric leg is verified (the "modify"
// closure boxes the same value as System.Int32 — JEEACJHABAF.txt ISIL 031);
// the NAMES are inferred from the room-accessibility enum in the nearest
// dump with real names (RecRoom-2023.06.21 dump.cs:1293997-1294004
// MKDFFJGGKDL { Private = 0, Public = 1, Unlisted = 2 }), which is the enum
// Enum.ToString() would spell out on the standalone accessibility call.
private const int AccessibilityPrivate = 0;
private static int? ParseSubRoomAccessibility(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return null;
var value = raw.Trim();
if (int.TryParse(value, out var numeric)) return numeric;
if (value.StartsWith("priv", StringComparison.OrdinalIgnoreCase)) return 0;
if (value.StartsWith("pub", StringComparison.OrdinalIgnoreCase)) return 1;
if (value.StartsWith("unlist", StringComparison.OrdinalIgnoreCase)) return 2;
return null;
}
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/publish_save")]
[HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/publish_save")]
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/publish_save")]
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/publish_save")]
[Authorize]
public async Task PublishSubRoomSave(long roomId, long subRoomId)
{
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var pid = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
if (!await CanEditRoomAsync(room, pid)) return Forbid();
var scene = await GetOrCreateSceneForMutationAsync(room, subRoomId);
if (scene is null) return NotFound();
var request = await ReadPublishSubRoomSaveRequestAsync();
var saveId = request.SubRoomDataSaveId ?? request.SaveId ?? request.StagedSubRoomDataSaveId;
var dataBlob = request.DataBlobName ?? request.DataBlob ?? request.Filename;
if (!string.IsNullOrWhiteSpace(dataBlob))
{
var blobExists = await db.RoomDataBlobs.AnyAsync(b => b.RoomId == roomId && b.BlobName == dataBlob);
if (!blobExists) return NotFound(new { error = "blob_not_found", dataBlob });
scene.DataBlobName = dataBlob;
if (scene.OrderIndex == 0) room.CurrentDataBlobName = dataBlob;
}
if (saveId is > 0)
scene.StudioSubRoomDataSaveId = saveId.Value;
scene.DataModifiedAt = DateTime.UtcNow;
room.UpdatedAt = scene.DataModifiedAt;
await db.SaveChangesAsync();
// "restore subroom save" returns Task (NLDBPDCNNCF.txt:10476),
// so the scene row alone made a successful restore read as a failure.
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
}
private async Task ReadPublishSubRoomSaveRequestAsync()
{
var request = new PublishSubRoomSaveRequest();
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
if (long.TryParse(FirstFormValue(form, "subRoomDataSaveId", "SubRoomDataSaveId", "saveId", "SaveId", "stagedSubRoomDataSaveId", "StagedSubRoomDataSaveId"), out var saveId))
request.SubRoomDataSaveId = saveId;
request.DataBlobName = FirstFormValue(form, "dataBlobName", "DataBlobName", "dataBlob", "DataBlob", "filename", "Filename");
return request;
}
if (long.TryParse(Request.Query["subRoomDataSaveId"].FirstOrDefault()
?? Request.Query["SubRoomDataSaveId"].FirstOrDefault()
?? Request.Query["saveId"].FirstOrDefault()
?? Request.Query["SaveId"].FirstOrDefault()
?? Request.Query["stagedSubRoomDataSaveId"].FirstOrDefault()
?? Request.Query["StagedSubRoomDataSaveId"].FirstOrDefault(), out var querySaveId))
request.SubRoomDataSaveId = querySaveId;
request.DataBlobName = Request.Query["dataBlobName"].FirstOrDefault()
?? Request.Query["DataBlobName"].FirstOrDefault()
?? Request.Query["dataBlob"].FirstOrDefault()
?? Request.Query["DataBlob"].FirstOrDefault()
?? Request.Query["filename"].FirstOrDefault()
?? Request.Query["Filename"].FirstOrDefault();
if ((Request.ContentLength ?? 0) <= 0) return request;
try
{
var body = await JsonSerializer.DeserializeAsync(
Request.Body,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
return body ?? request;
}
catch (JsonException)
{
return request;
}
}
/// 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")]
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/clone")]
[Authorize]
public async Task CloneSubRoom(long roomId, long subRoomId)
{
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var pid = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
if (!await CanEditRoomAsync(room, 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();
// Task ("clone subroom", NLDBPDCNNCF.txt:9554) — the scene
// row is not what the client re-parses.
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
}
// Subroom settings save POSTs here on the roomserver host ("failed to
// modify room" = the bare-only routes 404'd). Same roomserver/ prefix as
// the sibling subroom mutations.
//
// Body is form-urlencoded name/accessibility/maxPlayers ("modify room",
// NLDBPDCNNCF.txt:10846 verb 3; keys at JEEACJHABAF.txt ISIL 021/035/048).
// [FromBody] rejected that with 415 before the handler ran, and the DTO had
// no 'accessibility' member at all, so the bulk subroom-settings save was a
// guaranteed failure.
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/modify")]
[HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/modify")]
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/modify")]
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/modify")]
[Authorize]
public Task ModifySubRoom(
long roomId,
long subRoomId,
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] 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 (ParseSubRoomAccessibility(req.Accessibility) is int a)
s.CanMatchmakeInto = a != AccessibilityPrivate;
if (req.RoomSceneLocationId is not null) s.RoomSceneLocationId = req.RoomSceneLocationId;
});
/// POST rooms/{id}/subrooms/{sub}/move .
///
/// The 2023 semantics are "move to another ROOM", not "reorder".
/// NLDBPDCNNCF.MGGALMIAMBG(long roomId, long subRoomId,
/// Nullable<Int64> newRoomId) ("move subroom",
/// NLDBPDCNNCF.txt:9659, verb 2 = POST) sends the single form key
/// newRoomId (COIOGADEBKJ.txt ISIL 013-020, boxed as
/// Nullable<Int64> ) and returns
/// Task<FGCPNAACHIK> . The old handler bound
/// [FromBody] {NewIndex} — a key the client never sends, on a body
/// binding that 415s form posts — and reordered scenes in place instead.
///
/// The caller must own BOTH rooms. The scene is re-parented to the end
/// of the target room's scene list and the source room's remaining scenes
/// are re-packed so OrderIndex stays a dense 0..n-1 sequence — the
/// wire RoomSceneId and matchmaking SubRoomId are that index.
/// Scene 0 is the room's entry scene and cannot be moved out.
/// newIndex (no client sends it) keeps the old in-room reorder for
/// the import tooling.
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/move")]
[HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/move")]
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/move")]
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/move")]
[Authorize]
public async Task MoveSubRoom(
long roomId,
long subRoomId,
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] SubRoomMoveRequest req)
{
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
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();
// `newRoomId` is Nullable on the wire and the form writer
// stringifies it with ToString(), which yields "" for the empty case —
// so an absent target arrives as a blank field, not a missing one.
// Treat "no target and no index" as a no-op; the old code fell through
// to `NewIndex ?? 0` and silently reordered the scene to the front.
if (req.NewRoomId is not long targetRoomId || targetRoomId == roomId)
{
if (req.NewIndex is not int requestedIndex)
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
var newIndex = Math.Clamp(requestedIndex, 0, scenes.Count - 1);
scenes.Remove(moving);
scenes.Insert(newIndex, moving);
await RepackSceneOrderAsync(scenes);
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
}
// Cross-room move — the path the 2023 client actually takes.
var target = await db.Rooms.FirstOrDefaultAsync(r => r.Id == targetRoomId);
if (target is null) return NotFound(new { error = "target_room_not_found", targetRoomId });
if (target.CreatorPlayerId != pid) return Forbid();
if (moving.OrderIndex == 0)
return BadRequest(new { error = "cannot_move_entry_scene" });
// Names are unique per room (RoomScenes index on (RoomId, Name)).
var movedName = moving.Name;
var suffix = 1;
while (await db.RoomScenes.AnyAsync(s => s.RoomId == targetRoomId && s.Name == movedName))
{
suffix += 1;
movedName = $"{moving.Name} {suffix}";
}
var targetOrder = (await db.RoomScenes.Where(s => s.RoomId == targetRoomId)
.Select(s => (int?)s.OrderIndex).MaxAsync() ?? -1) + 1;
moving.RoomId = targetRoomId;
moving.OrderIndex = targetOrder;
moving.Name = movedName;
moving.DataModifiedAt = DateTime.UtcNow;
room.UpdatedAt = moving.DataModifiedAt;
target.UpdatedAt = moving.DataModifiedAt;
await db.SaveChangesAsync();
// Re-pack the source room so its remaining scenes keep dense indices
// (same sentinel dance as the in-room reorder above).
await RepackSceneOrderAsync(scenes.Where(s => s.Id != moving.Id).ToList());
logger.LogInformation("[subroom-move] scene={Scene} room={From}→{To} by={By}",
moving.Id, roomId, targetRoomId, pid);
return Ok(await BuildRoomServerDetailsWithRolesAsync(targetRoomId));
}
/// Rewrite to 0..n-1 in
/// list order. The unique (RoomId, OrderIndex) index would clash if rows
/// were written in place, so flush a sentinel offset first, then settle.
private async Task RepackSceneOrderAsync(List scenes)
{
if (scenes.Count == 0) return;
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();
}
/// POST roomserver/rooms/{id}/subrooms/{sub}/data —
/// commit the blob returned by storage/upload as the current
/// scene save. The 2020 watch posts this as form-urlencoded
/// (filename , inventionUsage , savedByAccountId ),
/// while some tooling uses JSON. The 2023 client posts JSON with
/// nested blob refs — {UnityAssetId, RoomData:{Filename,Hash,
/// OwnershipProof}, SubRoomData:{Filename,…}} — where
/// SubRoomData.Filename is the scene save (FileType=1 upload)
/// and RoomData.Filename the FileType=6 room-metadata blob.
/// All variants delegate to the existing saveData path so room,
/// scene, dorm-state, presence, and subscription fanout stay
/// consistent.
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/data")]
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/data")]
[Authorize]
public async Task SubRoomData(long roomId, long subRoomId)
{
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var body = await ReadSaveRoomSceneRequestAsync();
body.RoomSceneId = subRoomId;
return await SaveDataCore(body, roomId, wrapCreateModifyResponse: true);
}
private async Task ReadSaveRoomSceneRequestAsync()
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
var request = new SaveRoomSceneRequest
{
Filename = FirstFormValue(form, "filename", "Filename"),
RoomDataFilename = FirstFormValue(form, "roomDataFilename", "RoomDataFilename", "roomDataFileName", "RoomDataFileName"),
InventionUsageBase64 = FirstFormValue(form, "inventionUsage", "InventionUsage", "inventionUsageBase64", "InventionUsageBase64"),
};
if (long.TryParse(FirstFormValue(form, "requestPlayerId", "RequestPlayerId", "savedByAccountId", "SavedByAccountId"), out var requestPlayerId))
request.RequestPlayerId = requestPlayerId;
if (int.TryParse(FirstFormValue(form, "saveRequestPlayerId", "SaveRequestPlayerId"), out var saveRequestPlayerId))
request.SaveRequestPlayerId = saveRequestPlayerId;
return request;
}
if (Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true)
{
try
{
return await JsonSerializer.DeserializeAsync(
Request.Body,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
?? new SaveRoomSceneRequest();
}
catch (JsonException)
{
return new SaveRoomSceneRequest();
}
}
return new SaveRoomSceneRequest();
}
private static string? FirstFormValue(IFormCollection form, params string[] keys)
{
foreach (var key in keys)
{
var value = form[key].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(value)) return value;
}
return null;
}
/// Shared owner-gate + mutate + save for the subroom setting
/// endpoints. Responds with the FULL room details: every 2023 caller that
/// lands here (maxplayers / accessibility / modify) is typed
/// Task<FGCPNAACHIK> , so the old scene-row payload made a
/// successful mutation surface as a client-side parse failure.
private async Task MutateScene(long roomId, long subRoomId, Action mutator)
{
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var pid = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
if (!await CanEditRoomAsync(room, pid)) return Forbid();
var scene = await GetOrCreateSceneForMutationAsync(room, subRoomId);
if (scene is null) return NotFound();
mutator(scene);
scene.DataModifiedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
}
private async Task GetOrCreateSceneForMutationAsync(RoomEntity room, long subRoomId)
{
var scene = await db.RoomScenes
.FirstOrDefaultAsync(s => s.RoomId == room.Id && s.OrderIndex == subRoomId);
if (scene is not null || subRoomId != 0) return scene;
scene = new RoomSceneEntity
{
RoomId = room.Id,
OrderIndex = 0,
Name = "Home",
RoomSceneLocationId = room.LocationReplicationId,
DataBlobName = CurrentOrSyntheticDataBlobName(room),
MaxPlayers = room.MaxCapacity,
IsSandbox = false,
CanMatchmakeInto = true,
DataModifiedAt = DateTime.UtcNow,
};
db.RoomScenes.Add(scene);
return scene;
}
// SceneWire (the old {Result,RoomId,RoomSceneId,…} scene-row payload) is
// gone: every subroom mutation the 2023 client issues is typed
// Task and re-parses the full room details, so all of these
// handlers now go through BuildRoomServerDetailsWithRolesAsync.
// ── Bare-path roles ────────────────────────────────────────────────
/// GET rooms/{id}/roles — account role DTOs consumed by
/// 2023's EFHPLDPNGIM deserializer
/// (NLDBPDCNNCF.LAJDCNIMBAB → Task<List<EFHPLDPNGIM>> ,
/// NLDBPDCNNCF.txt:2111, verb 0 = GET). Like every other NLDBPDCNNCF call it
/// arrives with the roomserver/ prefix, so the bare-only registration
/// 404'd the permissions screen's list fetch.
[HttpGet("rooms/{roomId:long}/roles")]
[HttpGet("roomserver/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();
return Ok(RoomRoleRules.BuildWires(room, rows));
}
private static string CurrentOrSyntheticDataBlobName(RoomEntity room)
{
if (room.IsDormRoom)
return RoomService.ResolveWireRoomDataBlobName(room.Id, room.CurrentDataBlobName);
if (RoomService.IsBakedOriginalRoom(room) && string.IsNullOrWhiteSpace(room.CurrentDataBlobName))
return string.Empty;
return !string.IsNullOrWhiteSpace(room.CurrentDataBlobName)
? room.CurrentDataBlobName
: RoomService.SyntheticDefaultRoomDataBlobName(room.Id);
}
private static string SceneOrSyntheticDataBlobName(RoomEntity room, string? sceneBlobName, string fallbackBlobName)
{
if (!string.IsNullOrWhiteSpace(sceneBlobName) &&
!(room.IsDormRoom && RoomService.IsLegacySyntheticDefaultRoomDataBlobName(room.Id, sceneBlobName)))
return sceneBlobName;
return fallbackBlobName;
}
private sealed record StudioBundleInfo(long SaveId, int Target, int Version, string Filename);
private sealed record BakedUnityAssetWire(
string UnityAssetId,
long CreatedByAccountId,
string Filename,
int Target,
int Version,
string Hash);
private sealed record UnityAssetWire(
string UnityAssetId,
long CreatedByAccountId,
string Filename,
BakedUnityAssetWire[] BakedUnityAssets);
private static BakedUnityAssetWire[] BuildStudioBakedAssets(
RoomEntity room,
RoomSceneEntity? scene,
long saveId,
int? unityAssetTarget,
int? unityAssetVersion)
{
var bundles = StudioBundlesForScene(scene, saveId);
if (unityAssetTarget is int target)
bundles = bundles.Where(b => b.Target == target).ToList();
if (unityAssetVersion is int requestedVersion && bundles.Any(b => b.Version == requestedVersion))
bundles = bundles.Where(b => b.Version == requestedVersion).ToList();
var unityAssetId = scene?.StudioUnityAssetId ?? string.Empty;
return bundles
.OrderBy(b => b.Target)
.ThenByDescending(b => b.Version)
.Select(b => new BakedUnityAssetWire(
unityAssetId,
room.CreatorPlayerId,
b.Filename,
b.Target,
b.Version,
string.Empty))
.ToArray();
}
private static UnityAssetWire? BuildStudioUnityAsset(
RoomEntity room,
RoomSceneEntity? scene,
BakedUnityAssetWire[] bakedAssets)
{
var unityAssetId = scene?.StudioUnityAssetId ?? string.Empty;
if (string.IsNullOrWhiteSpace(unityAssetId))
return null;
var filename = bakedAssets.FirstOrDefault()?.Filename ?? string.Empty;
return new UnityAssetWire(unityAssetId, room.CreatorPlayerId, filename, bakedAssets);
}
private static List StudioBundlesForScene(RoomSceneEntity? scene, long saveId)
{
return (scene?.StudioAssetBundleNamesCsv ?? string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Select(ParseStudioBundleName)
.Where(b => b is not null)
.Cast()
.Where(b => b.SaveId == saveId || scene?.StudioSubRoomDataSaveId == saveId)
.OrderBy(b => b.Target)
.ThenByDescending(b => b.Version)
.ToList();
}
private static StudioBundleInfo? ParseStudioBundleName(string fileName)
{
var name = Path.GetFileName(fileName);
var match = System.Text.RegularExpressions.Regex.Match(
name,
@"^(?\d+)__bundle_t(?\d+)(?:_v(?\d+))?\.assetbundle$",
System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.CultureInvariant);
if (!match.Success) return null;
if (!long.TryParse(match.Groups["save"].Value, out var saveId)) return null;
if (!int.TryParse(match.Groups["target"].Value, out var target)) return null;
var version = 0;
if (match.Groups["version"].Success &&
!int.TryParse(match.Groups["version"].Value, out version))
return null;
return new StudioBundleInfo(saveId, target, version, name);
}
/// GET rooms/{id}/roles/{playerId} — one role row
/// (NLDBPDCNNCF.PCFOIPELKHN → Task<EFHPLDPNGIM> ,
/// NLDBPDCNNCF.txt:2192). roomserver/ twin required, same as the list
/// above. The creator answers as Creator (255).
[HttpGet("rooms/{roomId:long}/roles/{playerId:long}")]
[HttpGet("roomserver/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();
var rows = await db.RoomRoles
.Where(r => r.RoomId == roomId && r.PlayerId == playerId)
.ToListAsync();
var wire = RoomRoleRules.BuildWires(room, rows)
.FirstOrDefault(w => w.AccountId == RoomRoleRules.ToClientAccountId(playerId));
if (wire is null) return NotFound();
return Ok(wire);
}
/// PUT rooms/{id}/roles/{playerId} — "change room account
/// role" (NLDBPDCNNCF.LMIEIJMCEAB , NLDBPDCNNCF.txt:7516, verb 3 =
/// PUT, single form key role boxed as Int32 —
/// NLDBPDCNNCF_NestedType_BFKNABDHMGG.txt ISIL 015-024), response the full
/// FGCPNAACHIK details. Three callers:
///
/// - the owner's role button (AddRemovePlayerRoomRoleConditionalButton
/// .EEGJBELCBDI): Host/Moderator are set directly, and "Remove" sends
/// role 0;
/// - the invitee accepting a co-owner invite (message Type 62,
/// KPFKPIKDBFJ.ELKAEBMMIGL / KLNLKDJJDLE Accept): their OWN account id
/// with role 30;
/// - a co-owner rejecting a direct add (Type 60, COJCABEJDFN Reject):
/// their own id with role 0.
///
/// Authorization is .
[HttpPut("rooms/{roomId:long}/roles/{playerId:long}")]
[HttpPost("rooms/{roomId:long}/roles/{playerId:long}")]
[HttpPut("roomserver/rooms/{roomId:long}/roles/{playerId:long}")]
[HttpPost("roomserver/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 Task GrantRole(long roomId, long playerId)
=> ChangeRoomAccountRoleAsync(roomId, playerId, isInvite: false);
/// PUT rooms/{id}/roles/{playerId}/invite — "invite account
/// to role" (NLDBPDCNNCF.KIJDCLINOIC , NLDBPDCNNCF.txt:7686, verb 3,
/// form key role as Int32 — NLDBPDCNNCF_NestedType_ENHMMCAOING.txt).
/// Only Co-Owner goes through an invite in this client: the owner's button
/// sends role 30 here, the invitee is told with a Type 62
/// (RoomCoOwnerInvited) message carrying RoomId, and either accepts with
/// PUT roles/{me} role 30 or rejects by sending role 0 back to THIS
/// route on their own id (KPFKPIKDBFJ.OGONAKHNMGN, KLNLKDJJDLE Reject),
/// which clears the pending invite but leaves any role they already
/// hold.
[HttpPut("rooms/{roomId:long}/roles/{playerId:long}/invite")]
[HttpPost("rooms/{roomId:long}/roles/{playerId:long}/invite")]
[HttpPut("roomserver/rooms/{roomId:long}/roles/{playerId:long}/invite")]
[HttpPost("roomserver/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 Task InviteRole(long roomId, long playerId)
=> ChangeRoomAccountRoleAsync(roomId, playerId, isInvite: true);
private async Task ChangeRoomAccountRoleAsync(long roomId, long playerId, bool isInvite)
{
var me = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
var requested = await ReadClientRoomRoleAsync();
if (requested is null) return BadRequest(new { error = "missing_role" });
if (playerId <= 0 || !await db.Players.AnyAsync(p => p.Id == playerId))
return NotFound(new { error = "unknown_player" });
var rows = await db.RoomRoles.Where(r => r.RoomId == roomId).ToListAsync();
var error = RoomRoleRules.CheckChange(
room, rows, me, playerId, requested.Value, isInvite, out var kind);
if (error is not null)
{
logger.LogWarning(
"[room-roles] denied room={Room} actor={Actor} target={Target} role={Role} invite={Invite} error={Error}",
roomId, me, playerId, requested.Value, isInvite, error);
return error == "cannot_change_creator_role" || error == "invalid_role"
? BadRequest(new { error, role = requested.Value })
: Forbid();
}
var targetRows = rows.Where(r => r.PlayerId == playerId).ToList();
var wasCoOwner = targetRows.Any(r => r.Accepted && r.Role == RoomRoleRules.StoredCoOwner);
var hadCoOwnerInvite = targetRows.Any(r => !r.Accepted && r.Role == RoomRoleRules.StoredCoOwner);
RoomRoleRules.TryFromWire(requested.Value, out var storedRole);
switch (kind)
{
case RoomRoleRules.ChangeKind.Clear when isInvite:
// Reject / withdraw an invite: only the pending rows go.
db.RoomRoles.RemoveRange(targetRows.Where(r => !r.Accepted));
break;
case RoomRoleRules.ChangeKind.Clear:
db.RoomRoles.RemoveRange(targetRows);
break;
case RoomRoleRules.ChangeKind.Invite:
{
// Already holding the role: nothing to invite to. Otherwise
// replace any other pending invite but keep the role the
// player holds until they accept.
if (targetRows.Any(r => r.Accepted && r.Role == storedRole)) break;
db.RoomRoles.RemoveRange(targetRows.Where(r => !r.Accepted && r.Role != storedRole));
var pending = targetRows.FirstOrDefault(r => !r.Accepted && r.Role == storedRole);
if (pending is null)
{
db.RoomRoles.Add(new RoomRoleEntity
{
RoomId = roomId, PlayerId = playerId, Role = storedRole,
Accepted = false, GrantedByPlayerId = me, GrantedAt = DateTime.UtcNow,
});
}
else
{
pending.GrantedByPlayerId = me;
pending.GrantedAt = DateTime.UtcNow;
}
break;
}
case RoomRoleRules.ChangeKind.AcceptInvite:
case RoomRoleRules.ChangeKind.Grant:
{
// One role per account: the new one replaces the rest
// (an accepted invite replaces the role held meanwhile).
var keep = targetRows.FirstOrDefault(r => r.Role == storedRole);
db.RoomRoles.RemoveRange(targetRows.Where(r => !ReferenceEquals(r, keep)));
if (keep is null)
{
db.RoomRoles.Add(new RoomRoleEntity
{
RoomId = roomId, PlayerId = playerId, Role = storedRole,
Accepted = true,
GrantedByPlayerId = kind == RoomRoleRules.ChangeKind.AcceptInvite
? targetRows.FirstOrDefault(r => !r.Accepted)?.GrantedByPlayerId ?? me
: me,
GrantedAt = DateTime.UtcNow,
});
}
else
{
keep.Accepted = true;
if (kind == RoomRoleRules.ChangeKind.Grant) keep.GrantedByPlayerId = me;
keep.GrantedAt = DateTime.UtcNow;
}
break;
}
}
await db.SaveChangesAsync();
var isCoOwnerNow = await db.RoomRoles.AnyAsync(r =>
r.RoomId == roomId && r.PlayerId == playerId && r.Accepted && r.Role == RoomRoleRules.StoredCoOwner);
var hasCoOwnerInviteNow = await db.RoomRoles.AnyAsync(r =>
r.RoomId == roomId && r.PlayerId == playerId && !r.Accepted && r.Role == RoomRoleRules.StoredCoOwner);
logger.LogInformation(
"[room-roles] room={Room} actor={Actor} target={Target} role={Role} invite={Invite} kind={Kind} coOwner={Was}->{Now}",
roomId, me, playerId, requested.Value, isInvite, kind, wasCoOwner, isCoOwnerNow);
// The invitee's notifications are how the role reaches them: the
// 2023 client renders message Types 60/61/62 (factory
// OCGKOJMFLAI.ALEHDHAEFOK, enum KJECOLODAFA.MEPCALGGMJC) and reads
// only RoomId from them. Self-service changes (accept / reject /
// leave) notify nobody.
if (playerId != me)
{
if (!hadCoOwnerInvite && hasCoOwnerInviteNow)
await systemNotifications.SendAsync(playerId,
SystemNotificationService.MessageType.RoomCoOwnerInvited, me, roomId: roomId);
if (!wasCoOwner && isCoOwnerNow)
await systemNotifications.SendAsync(playerId,
SystemNotificationService.MessageType.RoomCoOwnerAdded, me, roomId: roomId);
if (wasCoOwner && !isCoOwnerNow)
await systemNotifications.SendAsync(playerId,
SystemNotificationService.MessageType.RoomCoOwnerRemoved, me, roomId: roomId);
}
if (hadCoOwnerInvite && !hasCoOwnerInviteNow)
await MarkCoOwnerInviteMessagesReadAsync(playerId, roomId);
var details = await BuildRoomServerDetailsWithRolesAsync(roomId);
await PushRoomUpdateAsync(roomId, details, playerId, me);
return Ok(details);
}
/// Once an invite is answered (or withdrawn) its Type 62 message
/// must stop offering Accept/Reject in the inbox.
private async Task MarkCoOwnerInviteMessagesReadAsync(long playerId, long roomId)
{
var stale = await db.Messages
.Where(m => m.RecipientPlayerId == playerId
&& m.RoomId == roomId
&& m.Type == SystemNotificationService.MessageType.RoomCoOwnerInvited
&& m.ReadAt == null)
.ToListAsync();
if (stale.Count == 0) return;
foreach (var m in stale) m.ReadAt = DateTime.UtcNow;
await db.SaveChangesAsync();
}
/// Push the fresh room details as RoomUpdate (handler
/// IBEOONPEELF.txt:507, Action<FGCPNAACHIK>) to everyone currently
/// in the room plus the players whose view changed, so the role list,
/// the role-gated buttons and the local player's own role refresh
/// without leaving the room.
private async Task PushRoomUpdateAsync(long roomId, object details, params long[] alsoNotify)
{
var recipients = presence.EnumerateActiveInstances(roomId, includePrivate: true)
.SelectMany(i => i.PlayerIds)
.Concat(alsoNotify)
.Where(pid => pid > 0)
.Distinct()
.ToList();
foreach (var pid in recipients)
await notifications.NotifyAsync(pid, PushNotificationId.SubscriptionUpdateRoom, details);
}
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 (TryParseRoleName(form["role"].FirstOrDefault() ?? 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
{
using var doc = await JsonDocument.ParseAsync(Request.Body);
if (doc.RootElement.ValueKind == JsonValueKind.Object)
{
foreach (var prop in doc.RootElement.EnumerateObject())
{
if (!prop.Name.Equals("role", StringComparison.OrdinalIgnoreCase)) continue;
if (prop.Value.ValueKind == JsonValueKind.Number && prop.Value.TryGetInt32(out parsed))
return parsed;
if (prop.Value.ValueKind == JsonValueKind.String
&& (int.TryParse(prop.Value.GetString(), out parsed)
|| TryParseRoleName(prop.Value.GetString(), out parsed)))
return parsed;
}
}
}
catch (JsonException)
{
return null;
}
}
return null;
}
/// OMMBGJMJJPN member names, for callers that box the enum
/// itself (Enum.ToString) instead of an Int32.
internal static bool TryParseRoleName(string? raw, out int role)
{
role = (raw ?? string.Empty).Trim().ToLowerInvariant() switch
{
"none" => RoomRoleRules.WireNone,
"banned" => RoomRoleRules.WireBanned,
"host" => RoomRoleRules.WireHost,
"moderator" => RoomRoleRules.WireModerator,
"coowner" => RoomRoleRules.WireCoOwner,
"temporarycoowner" => RoomRoleRules.WireTemporaryCoOwner,
"creator" => RoomRoleRules.WireCreator,
_ => -1,
};
return role >= 0;
}
/// Creator or accepted co-owner (see
/// ).
private async Task CanEditRoomAsync(RoomEntity room, long playerId)
{
if (room.CreatorPlayerId == playerId) return true;
var rows = await db.RoomRoles
.Where(r => r.RoomId == room.Id && r.PlayerId == playerId)
.ToListAsync();
return RoomRoleRules.CanEditRoom(room, rows, playerId);
}
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",
};
}
}