Files
DorkNet/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs
T
Alexa f43ce3f368 feat: per-sub-room max players, editable in game and from admin
Player caps were two settings pretending to be one. RoomEntity.MaxCapacity is
what the ROOM advertises (and what matchmaking hands to
RoomInstance.MaxCapacity); RoomSceneEntity.MaxPlayers is what ONE sub-room caps
itself at. The details payload was reading its room-level MaxPlayers off
sub-room 0, which collapsed the two — editing a sub-room would have moved the
room's advertised capacity with it. It now reports MaxCapacity, and each entry
in SubRooms[] reports its own figure.

The in-game path already existed (the "Max Player Count in This Subroom"
slider). Admin had no way to reach it at all — only zip import ever set it — so
a cap could not be corrected on a room the admin does not own:

  GET api/admin/v1/rooms/{id}/subrooms
  PUT api/admin/v1/rooms/{id}/subrooms/{subRoomId}/maxplayers  {"MaxPlayers":n}

subRoomId is the sub-room's ORDER INDEX — the id the client and the rest of the
wire use — not the row's primary key. Row ids are serialised as strings because
the admin SPA is JavaScript and mangles integers past 2^53. Both paths clamp to
1..80, matching rooms/{id}/props: 0 would make a sub-room unjoinable, and a
shared range stops the two settings disagreeing about what is legal.

The test drives the client's exact wire rather than a convenient one:
NLDBPDCNNCF.MNBPCGJNLNP builds a BLNOGFGHIIF whose single JSON key is
"maxPlayers" and PUTs it — this route does NOT form-encode like most of its
siblings, so a form-bodied test would have passed while the real client's body
went unread. It also asserts the reply carries the new value, since the client
deserialises it back as FGCPNAACHIK.
2026-07-28 11:06:49 +03:00

4627 lines
212 KiB
C#

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;
/// <summary>
/// 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).
/// </summary>
[ApiController]
public class RoomsController(
RoomService rooms,
PlaylistService playlists,
DorkNetDbContext db,
PlayerPresenceService presence,
OnlinePresenceService onlinePresence,
DomainConfig domain,
NotificationService notifications,
ServerSettingsService serverSettings,
ILogger<RoomsController> logger) : ControllerBase
{
private long? CurrentPlayerId => ControllerBaseExtensions.CurrentPlayerId(this);
// ── Browse / Hot / Search ────────────────────────────────────────────
/// <summary>
/// `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`).
/// </summary>
[HttpGet("api/rooms/v1/hot")]
public async Task<IActionResult> HotV1(
[FromQuery] string? roomScoreType,
[FromQuery] string? tags)
=> Ok((await rooms.HotAsync(tags)).Select(RoomService.ToWireRoom).ToList());
/// <summary>GET <c>rooms/hot</c> — the room-server "hot rooms" list.
/// Response is the PAGED wrapper: the 2023 client's issuing method
/// <c>NLDBPDCNNCF.FANOAMOPHBE</c> returns <c>Task&lt;FFCPFGBNLHN&gt;</c>
/// (NLDBPDCNNCF.txt:2865 + literal at :2983) and FFCPFGBNLHN's reader
/// requires <c>Results</c> + <c>TotalResults</c>
/// (PJPBJKLDMNA.txt ISIL 040/059).</summary>
// 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<IActionResult> 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<RoomEntity> all;
if (tagFilters.Count == 0)
{
all = await rooms.HotAsync(null, take: 200);
}
else
{
var merged = new List<RoomEntity>();
var seen = new HashSet<long>();
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<IActionResult> 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<IActionResult> 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<IActionResult> 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));
}
/// <summary>GET <c>rooms/magic_door</c> — the Magic Door destination.
/// The response is a WRAPPER, not the bare room: the client's
/// <c>MOOCMPOIIGM</c> returns <c>Task&lt;GAPJAGDNFEO&gt;</c>
/// (NLDBPDCNNCF.txt:4503) and that reader requires the keys
/// <c>RefreshesAt</c>, <c>RefreshIntervalMinutes</c> and <c>Room</c>
/// (MIFGPMGEMEK.txt ISIL 046/073/097). Returning the room object
/// directly left <c>Room</c> null and MagicDoorManager with no
/// destination. The <c>partySize</c> query param is accepted and
/// ignored — we have no party-size-aware curation.</summary>
[HttpGet("rooms/magic_door")]
[HttpGet("roomserver/rooms/magic_door")]
public async Task<IActionResult> 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,
});
}
/// <summary>
/// GET <c>/roomserver/rooms/recommendations</c> — the home-tab
/// "Recommended" carousel. <b>Wire shape is NOT a flat
/// <c>List&lt;Room&gt;</c></b>; the watch's
/// <c>EJDCNGBEICB.JDKLBNBIEAH</c> at <c>EJDCNGBEICB.txt:1349,1430</c>
/// returns <c>IPromise&lt;List&lt;ANNKHNFLMNP&gt;&gt;</c> and
/// <see cref="ANNKHNFLMNP.PPGFHEDFBEA"/> at
/// <c>ANNKHNFLMNP.txt:43-97</c> reads <c>SeedRoom</c> (sub-object,
/// <c>KLCOGEIGEBJ</c> i.e. full Room) and <c>Rooms</c> (list of
/// Rooms) as required keys. Returning <c>[{room}, …]</c> raised
/// <c>KeyNotFoundException: Failed to find key 'Rooms' when
/// deserializing object of type KLCOGEIGEBJ</c>.
///
/// 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.
/// </summary>
[HttpGet("roomserver/rooms/recommendations")]
[HttpGet("rooms/recommendations")]
public async Task<IActionResult> 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<object>());
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<RoomSceneEntity>)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<string, object>(StringComparer.Ordinal)
{
["SeedRoom"] = room,
["Rooms"] = new List<object> { room },
}).ToList();
return Ok(result);
}
[HttpGet("rooms/{roomId:long}/similar")]
[HttpGet("roomserver/rooms/{roomId:long}/similar")]
public async Task<IActionResult> 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<string, object>(StringComparer.Ordinal)
{
["SeedRoom"] = seedWire,
["Rooms"] = similarWire,
});
}
/// <summary>
/// `Rooms.SearchForRooms(query)` — free-text room search.
/// </summary>
[HttpGet("api/rooms/v1/search")]
[HttpGet("api/rooms/v2/search")]
public async Task<IActionResult> Search(
[FromQuery(Name = "query")] string? query,
[FromQuery(Name = "value")] string? value)
=> Ok((await rooms.SearchAsync(query ?? value ?? string.Empty))
.Select(RoomService.ToWireRoom).ToList());
/// <summary>GET <c>rooms/search</c> — the room-server search route.
/// Unlike the <c>api/rooms/v*</c> variants (flat <c>List&lt;Room&gt;</c>),
/// this one's client contract is a PAGED wrapper object
/// <c>{ Results, TotalResults }</c> (IDLBPALJJDJ : PagedResult&lt;Room&gt;);
/// returning a bare array crashes the watch's key-based reader.
/// Confirmed for 2023 too: <c>NLDBPDCNNCF.MOBJJDBNBMF</c> returns
/// <c>Task&lt;FFCPFGBNLHN&gt;</c> (NLDBPDCNNCF.txt:2733) whose reader
/// requires Results/TotalResults (PJPBJKLDMNA.txt ISIL 040/059).</summary>
// 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<IActionResult> 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,
});
}
/// <summary>GET <c>api/rooms/v2/live</c> — 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.</summary>
[HttpGet("api/rooms/v2/live")]
public async Task<IActionResult> Live([FromQuery] string? roomScoreType)
=> Ok((await rooms.HotAsync(null)).Select(RoomService.ToWireRoom).ToList());
/// <summary>
/// `Rooms.GetBaseRooms` — the room-creation "base room" picker. A fixed,
/// admin-curated list of room NAMES (server setting
/// <c>BaseRoomNamesJson</c>, default <see
/// cref="ServerSettingsService.DefaultBaseRoomNames"/>), resolved to rooms
/// in the configured order. Names are looked up via
/// <see cref="RoomService.GetByNameAsync"/>, which also finds
/// HiddenFromBrowse rooms like MakerRoom. Edit the list from the admin SPA
/// (<c>api/admin/v1/settings/base-rooms</c>).
/// </summary>
[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<IActionResult> BaseRooms()
{
var names = await serverSettings.GetBaseRoomNamesAsync();
var result = new List<object>();
foreach (var name in names)
{
var room = await rooms.GetByNameAsync(name);
if (room is not null)
result.Add(RoomService.ToWireRoom(room));
}
return Ok(result);
}
/// <summary>
/// `Rooms.GetFilters` — pinned/popular tag chips shown above the room
/// list in the watch.
/// </summary>
// 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<IActionResult> 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<IActionResult> 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<IActionResult> PinnedTags()
=> Ok((await serverSettings.GetPlayMenuTagsAsync()).PinnedTags);
[HttpGet("api/rooms/v1/populartags")]
public async Task<IActionResult> PopularTags()
=> Ok((await serverSettings.GetPlayMenuTagsAsync()).PopularTags);
[HttpGet("api/rooms/v1/trendingtags")]
public async Task<IActionResult> TrendingTags()
=> Ok((await serverSettings.GetPlayMenuTagsAsync()).TrendingTags);
// ── Single room lookups ──────────────────────────────────────────────
/// <summary>
/// `Rooms.GetByName(roomName)` — used when the client constructs the
/// share-link target or when /goto/room/{name} runs. Falls back to a
/// synthesised "Room_<name>" for unknown names so the deserializer
/// doesn't crash on 404.
/// </summary>
[HttpGet("api/rooms/v2/name/{roomName}")]
[HttpGet("api/rooms/v3/name/{roomName}")]
public async Task<IActionResult> ByName(string roomName)
{
var r = await rooms.GetByNameAsync(roomName);
return Ok(RoomService.ToWireRoom(r ?? Synthetic(roomName)));
}
/// <summary>
/// `Rooms.GetById(roomId)` — same flat shape as ByName.
/// </summary>
[HttpGet("api/rooms/v2/{roomId:long}")]
[HttpGet("api/rooms/v3/{roomId:long}")]
public async Task<IActionResult> 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<IActionResult> 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));
}
/// <summary>DELETE <c>rooms/{roomId}</c> — the owner's "delete room"
/// button. Client method <c>NLDBPDCNNCF.FPDBNHOHIAO</c> formats
/// <c>"rooms/{0}"</c> and moves verb ordinal 4 (= DELETE) into r8
/// (NLDBPDCNNCF.txt ISIL 021/030 around :4897); its return type is the
/// body-less promise <c>LDGADANDBIO</c> (CAOCJPMJMDB.txt:339), so the
/// response payload is never parsed.
///
/// <para>The only DELETE for <c>rooms/{id}</c> in the codebase lived on
/// AdminController, which is rooted at <c>api/admin/v1</c> — 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.</para>
///
/// <para>Soft-archive (<c>State = 1</c>) exactly like
/// <c>AdminController.DeleteRoom</c>: 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
/// <c>State == 0</c>).</para></summary>
[HttpDelete("rooms/{roomId:long}")]
[HttpDelete("roomserver/rooms/{roomId:long}")]
[Authorize]
public async Task<IActionResult> 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+<DownloadRoomDataBlobCoroutine>`) 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.
/// <summary>GET <c>roomserver/rooms/{roomId}/subrooms/{subRoomId}/datahistory</c>
/// — 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
/// <c>String.Format("rooms/{0}/subrooms/{1}/datahistory", roomId, subRoomId)</c>
/// (verified at <c>EJDCNGBEICB.txt:1190</c>); the <c>roomserver/</c>
/// 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 <c>roomserver/rooms/...</c>
/// routes in this controller).
///
/// <para><b>SubRoomId semantics:</b> the watch's 2020.12 build treats
/// "SubRoomId" as the <see cref="RoomSceneEntity.OrderIndex"/>, NOT
/// rec.net's long random Guid-like SubRoomId. The heartbeat reports
/// <c>subRoom=11</c> for the 12th scene in <c>RoomDetails.SubRooms[]</c>;
/// this endpoint must filter by OrderIndex to match. The zip-importer
/// stamps each <c>SubRooms/&lt;scene&gt;/History/</c> blob's
/// <see cref="RoomDataBlobEntity.SubRoomId"/> 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.</para>
///
/// <para>Wire shape: <c>List&lt;RoomDataHistoryDTO&gt;</c>. The
/// watch's <c>DHOCPFIOKHD.PPGFHEDFBEA</c> deserializer reads EXACTLY
/// four PascalCase keys (<c>DHOCPFIOKHD.txt:136-150</c>):
/// <c>SubRoomId</c> (long, strict GetKey), <c>DataBlob</c> (string),
/// <c>SavedByAccountId</c> (long, strict GetKey), <c>CreatedAt</c>
/// (DateTime, GetKeyOrDefault). Missing strict keys throw
/// <c>KeyNotFoundException</c> in LitJson and the watch shows the
/// "Could not retrieve save history from server :-(" toast from
/// <c>UIRestoreRoomDialog.txt:1970</c>. Extra keys are ignored.</para>
///
/// <para>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.</para></summary>
[HttpGet("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/datahistory")]
[HttpGet("rooms/{roomId:long}/subrooms/{subRoomId:long}/datahistory")]
public async Task<IActionResult> SubRoomDataHistory(long roomId, long subRoomId)
{
var blobs = await db.RoomDataBlobs
.Where(b => b.RoomId == roomId
&& (b.SubRoomId == subRoomId || b.SubRoomId == null))
// Skip the canonical per-scene blob from the import — the watch
// is asking for HISTORY, and serving the current `room_<id>_<slug>_<scene>.room`
// back to it as an option would make `restore to current` a
// confusing no-op entry in the list.
.Where(b => !b.BlobName.StartsWith($"room_{roomId}_"))
.OrderByDescending(b => b.UploadedAt)
.Take(50)
.Select(b => new
{
SubRoomId = subRoomId,
DataBlob = b.BlobName,
SavedByAccountId = b.UploadedByPlayerId,
CreatedAt = b.UploadedAt,
})
.ToListAsync();
return Ok(blobs);
}
/// <summary>GET <c>rooms/{roomId}/subrooms/{subRoomId}/saves/{saveId}</c>
/// — 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
/// <c>cdn.../room/{Filename}.assetbundle</c>. 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.</summary>
[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<IActionResult> StudioSubRoomSave(
long roomId,
long subRoomId,
long saveId,
[FromQuery] int? unityAssetTarget,
[FromQuery] int? unityAssetVersion)
{
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));
}
/// <summary>GET <c>rooms/{roomId}/subrooms</c> — the subroom list the
/// 2023 client / Studio editor fetches
/// (<c>String.Format("rooms/{0}/subrooms", roomId)</c>). Emits the same
/// per-subroom shape as the <c>SubRooms[]</c> entries inside
/// <c>/roomserver/rooms/{id}</c>, so a single-scene room synthesises one
/// "Home" entry and a multi-scene imported room enumerates its
/// RoomScenes.</summary>
[HttpGet("rooms/{roomId:long}/subrooms")]
[HttpGet("roomserver/rooms/{roomId:long}/subrooms")]
[Authorize]
public async Task<IActionResult> 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);
}
/// <summary>GET <c>rooms/{roomId}/subrooms/{subRoomId}</c> — one subroom
/// (<c>String.Format("rooms/{0}/subrooms/{1}", roomId, subRoomId)</c>).
/// <paramref name="subRoomId"/> is the RoomScene OrderIndex.</summary>
[HttpGet("rooms/{roomId:long}/subrooms/{subRoomId:long}")]
[HttpGet("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}")]
[Authorize]
public async Task<IActionResult> SubRoom(long roomId, long subRoomId)
{
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));
}
/// <summary>GET <c>rooms/{roomId}/subrooms/{subRoomId}/data</c> — the
/// current persisted save for the subroom
/// (<c>String.Format("rooms/{0}/subrooms/{1}/data", roomId, subRoomId)</c>).
/// Returns the same <c>SubRoomDataSave</c> shape as
/// <c>/saves/{saveId}</c> resolved to the scene's current save id.</summary>
[HttpGet("rooms/{roomId:long}/subrooms/{subRoomId:long}/data")]
[HttpGet("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/data")]
[Authorize]
public async Task<IActionResult> SubRoomData(
long roomId,
long subRoomId,
[FromQuery] int? unityAssetTarget,
[FromQuery] int? unityAssetVersion)
{
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));
}
/// <summary>GET <c>rooms/{roomId}/subrooms/{subRoomId}/saves</c> — the
/// list of saves for a subroom
/// (<c>String.Format("rooms/{0}/subrooms/{1}/saves", roomId, subRoomId)</c>).
/// Returns the current save plus any historical blobs recorded for the
/// subroom, newest first, each in the <c>SubRoomDataSave</c> shape.
///
/// <para><b>Wire shape is a PAGED OBJECT, not a bare list.</b> The 2023
/// client's <c>GetSubRoomDataSaves</c> (RecNet.Runtime
/// NLDBPDCNNCF.LDLMCGBIJFH, state machine IOAKBKKDFEE, sends
/// <c>skip</c>/<c>take</c>) deserializes
/// <c>PagedResultsDTO&lt;SubRoomDataSaveDTO&gt;</c> —
/// <c>{"Results":[…],"TotalResults":N}</c> per the Studio decompile
/// (RRStudio-decomp <c>PagedResultsDTO.cs</c>, plain .NET names). A
/// bare array throws <c>expected:'{', actual:'['</c> 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.</para></summary>
[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<IActionResult> SubRoomSaves(
long roomId, long subRoomId,
[FromQuery] int skip = 0, [FromQuery] int take = 50)
{
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<object>
{
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 = subRoomId,
UnityAssetId = scene?.StudioUnityAssetId ?? string.Empty,
SubRoomUnityAssetId = scene?.StudioUnityAssetId ?? string.Empty,
ReferencedUnityAssetIds = Array.Empty<string>(),
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,
});
}
/// <summary>Build one subroom entry in the wire shape the watch's
/// <c>SubRooms[]</c> array uses (see <c>BuildRoomServerDetails</c>). A
/// null <paramref name="scene"/> yields the synthesised "Home" scene for
/// single-scene rooms.</summary>
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 = (long)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 = (long)orderIndex,
UnityAssetId = scene?.StudioUnityAssetId ?? string.Empty,
SubRoomUnityAssetId = scene?.StudioUnityAssetId ?? string.Empty,
ReferencedUnityAssetIds = Array.Empty<string>(),
DataBlob = dataBlob,
},
};
}
/// <summary>Build the <c>SubRoomDataSave</c> wire object for a Studio
/// save — shared by <c>/saves/{saveId}</c>, <c>/data</c>, and the first
/// entry of <c>/saves</c>. A null <paramref name="scene"/> (single-scene
/// room) returns a save with no baked bundles, pointing at the room's
/// current data blob.</summary>
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 = subRoomId,
UnityAssetId = unityAssetId,
SubRoomUnityAssetId = unityAssetId,
ReferencedUnityAssetIds = Array.Empty<string>(),
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<object>(),
};
}
/// <summary>POST <c>roomserver/rooms/{roomId}/subrooms/{subRoomId}/restoredata</c>
/// — the watch's "Go" button in the Restore Room dialog. Body is
/// form-urlencoded with a single <c>filename</c> field carrying the
/// historical blob name from the datahistory list. <paramref name="subRoomId"/>
/// 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.</summary>
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/restoredata")]
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/restoredata")]
[Authorize]
public async Task<IActionResult> SubRoomRestoreData(
long roomId, long subRoomId, [FromForm] string? filename)
{
if (string.IsNullOrWhiteSpace(filename))
return BadRequest(new { error = "missing_filename" });
var pid = this.RequireCurrentPlayerId();
var room = await rooms.GetByIdAsync(roomId);
if (room is null) return NotFound(new { error = "room_not_found" });
if (room.CreatorPlayerId != pid) return Forbid();
var blob = await db.RoomDataBlobs
.FirstOrDefaultAsync(b => b.BlobName == filename && b.RoomId == roomId);
if (blob is null) return NotFound(new { error = "blob_not_found", filename });
var sceneRows = await db.RoomScenes
.Where(s => s.RoomId == roomId)
.OrderBy(s => s.OrderIndex)
.ToListAsync();
var sceneRow = sceneRows.FirstOrDefault(s => s.OrderIndex == (int)subRoomId);
if (sceneRow is null) return NotFound(new { error = "subroom_not_found", subRoomId });
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<PPENFJMFPNE> —
// 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 });
}
/// <summary>POST api/rooms/v4/saveData — the watch's
/// <c>RecNet.Rooms.UploadLocalRoomSceneData</c> save flow.
/// Body shape mirrors <c>RecNet.Rooms+SaveRoomSceneRequest</c>
/// (Cpp2IL_CS RecNet/Rooms.cs:858): RoomSceneId, RoomDataFilename,
/// InventionUsages[], CreatorActionContext, RequestPlayerId.
///
/// Persists the new <c>CurrentDataBlobName</c> 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 <c>RoomScene</c> 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.</summary>
[HttpPost("api/rooms/v4/saveData")]
[Authorize]
public async Task<IActionResult> SaveData([FromBody] SaveRoomSceneRequest body)
=> await SaveDataCore(body);
private async Task<IActionResult> 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<long, RoomInstanceDto>();
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: <room DTO>, SubRoomDataSave: <save DTO> } — 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<long>? 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; }
}
/// <summary>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.</summary>
/// <summary>POST <c>api/rooms/v1/datahistory/restore</c> — restore a
/// room to a previous save.
///
/// Request body: <c>RestoreDataHistoryRequest</c> serialized by
/// Unity's <c>JsonUtility.ToJson</c>, which uses C# field names.
/// Verified at <c>Cpp2IL_ISIL/.../Rooms.txt:13513-14</c> — the only
/// field on the request type is the long history id at offset +16,
/// and Unity's serializer names it <c>roomDataHistoryId</c>. We
/// look up <see cref="RoomDataBlobEntity"/> by that id, find its
/// parent room, and rewrite <c>CurrentDataBlobName</c> (and the
/// dorm-state row for personal dorms) so the next room reload
/// downloads the historical bytes.
///
/// Response: <c>CreateModifyRoomSceneResponse</c> — keys are
/// PascalCase per <c>Cpp2IL_ISIL/.../Rooms_NestedType_CreateModifyRoomSceneResponse.txt</c>:
/// <c>Result</c> (int, CreateModifyRoomStatus: 0=Success), and
/// <c>RoomScene</c> (the restored scene). The watch's
/// <c>ProcessCreateModifyRoomSceneResponse</c> chain expects both;
/// returning a bare <c>{restored: "..."}</c> like the old impl makes
/// the watch's deserializer throw and the restore silently fails.
/// The legacy form-urlencoded path stays under
/// <c>/api/rooms/v1/restore</c> for the admin SPA's restore UI.
/// </summary>
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<IActionResult> 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"),
},
});
}
/// <summary>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.</summary>
[HttpPost("api/rooms/v1/restore")]
[Authorize]
public async Task<IActionResult> 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 });
}
/// <summary>GET <c>api/rooms/v{1,2}/personaldetails/{roomId}</c> —
/// per-caller bookmark/cheer flags for one room. Wire shape verified
/// against <c>RecNet.PersonalRoomDetails.Deserialize</c>
/// (<c>Cpp2IL_ISIL/.../PersonalRoomDetails.txt:85-91</c>):
/// <c>IsCheering</c> - bool, REQUIRED (Util.GetKey, throws if missing)
/// <c>IsBookmarked</c> - 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.</summary>
[HttpGet("api/rooms/v2/personaldetails/{roomId:long}")]
[HttpGet("api/rooms/v1/personaldetails/{roomId:long}")]
public async Task<IActionResult> 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 });
}
/// <summary>
/// GET <c>/roomserver/rooms/{id}/interactionby/me</c> — sibling of
/// personaldetails but DIFFERENT wire shape. Per
/// <c>EJDCNGBEICB.txt:8219,8266</c> the deserialiser is
/// <c>CJODCLDGFCF</c>, which reads <c>Cheered</c> + <c>Favorited</c>
/// (PascalCase, both bool, both required — verified at
/// <c>CJODCLDGFCF.txt:118,123</c>). Sharing the
/// <c>{IsCheering, IsBookmarked}</c> payload with personaldetails
/// throws <c>KeyNotFoundException</c> on the watch and stalls the
/// room-tile UI.
/// </summary>
[HttpGet("roomserver/rooms/{roomId:long}/interactionby/me")]
[HttpGet("rooms/{roomId:long}/interactionby/me")]
public async Task<IActionResult> 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 });
}
/// <summary>
/// GET <c>/roomserver/playlists/{id}/interactionby/me</c> — same
/// <c>CJODCLDGFCF</c> wire shape as the room variant. Per
/// <c>EJDCNGBEICB.txt:8600</c> the URL is
/// <c>"playlists/{0}/interactionby/me"</c>. Reads from
/// <see cref="PlaylistInteractionEntity"/>; an anonymous caller
/// (no JWT) reads as (false, false) so the watch's pre-login
/// browse renders cleanly.
/// </summary>
[HttpGet("roomserver/playlists/{playlistId:long}/interactionby/me")]
[HttpGet("playlists/{playlistId:long}/interactionby/me")]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> RoomServerDetailsByName([FromQuery(Name = "name")] string? roomName)
{
if (string.IsNullOrWhiteSpace(roomName))
return Ok(Array.Empty<object>());
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));
}
/// <summary>GET <c>/roomserver/rooms/bulk?name=X&amp;name=Y</c> or
/// <c>?id=1&amp;id=2</c> — bulk room cache lookup. Decomp-verified
/// in EJDCNGBEICB: the Int64 overload writes query key <c>id</c>
/// and the string overload writes <c>name</c>. Returns the same
/// per-room object that <c>/roomserver/rooms/{id}</c> emits;
/// unresolved entries are silently skipped (watch handles a shorter
/// list fine, but a 404 wedges the room browser).</summary>
[HttpGet("roomserver/rooms/bulk")]
[HttpGet("rooms/bulk")]
public async Task<IActionResult> 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<object>());
var pid = CurrentPlayerId ?? 0;
var results = new List<object>(names.Length + ids.Length);
var emitted = new HashSet<long>();
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);
}
/// <summary>
/// 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.
/// </summary>
/// <summary>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.</summary>
private static string? DormNameOverride(string requestedName) =>
requestedName.Equals("DormRoom", StringComparison.OrdinalIgnoreCase)
? requestedName
: null;
private async Task<RoomEntity> 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<string,Room> 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,
};
/// <summary>
/// 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.
/// </summary>
public static object BuildRoomDetails(
RoomEntity room,
IReadOnlyList<RoomSceneEntity>? sceneRows = null,
long? overrideSceneId = null,
string? overrideDataBlobName = null,
IReadOnlyList<RoomRoleEntity>? roles = null)
{
// Tags is a list of OBJECTS, not strings. RoomDetails.Deserialize
// does Util.DeserializeList<RoomTag>(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<object>()
: room.TagsCsv
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(t => (object)new
{
Type = RoomTagTypeForWire(t),
Tag = t,
IsPrimaryGenre = false,
})
.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<byte[]>
// 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<RoomRoleEntity>();
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> { (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,
};
}
/// <summary>
/// 2020.12 <c>/roomserver/rooms/{id}?include=301</c> shape. The client
/// deserializes this directly as PPENFJMFPNE, not as the older
/// { Room, Scenes, ... } wrapper.
/// </summary>
public static object BuildRoomServerDetails(
RoomEntity room,
IReadOnlyList<RoomSceneEntity>? sceneRows = null,
string? overrideDataBlobName = null,
IReadOnlyList<RoomRoleEntity>? roles = null,
int? unityAssetTarget = null,
int? unityAssetVersion = null)
{
var roleList = roles ?? Array.Empty<RoomRoleEntity>();
// 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");
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 = (long)s.OrderIndex,
RoomId = room.Id,
Name = s.Name,
UnityAssetId = s.StudioUnityAssetId,
SubRoomUnityAssetId = 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,
CurrentSave = new
{
SubRoomDataSaveId = s.StudioSubRoomDataSaveId ?? 0L,
SubRoomId = (long)s.OrderIndex,
UnityAssetId = s.StudioUnityAssetId,
SubRoomUnityAssetId = s.StudioUnityAssetId,
CreatedByAccountId = room.CreatorPlayerId,
ReferencedUnityAssetIds = Array.Empty<string>(),
DataBlob = dataBlob,
DataBlobHash = (string?)null,
PersistenceVersion = saveVersion,
OMVersion = 0,
SavedByAccountId = room.CreatorPlayerId,
SavedOnPlatform = 0,
SavedOnDeviceClass = 2,
Description = string.Empty,
ModerationState = 0,
CreatedAt = (s.DataModifiedAt == default ? DateTime.UtcNow : s.DataModifiedAt)
.ToString("yyyy-MM-ddTHH:mm:ssZ"),
UgcSubVersion = saveVersion,
UnityAssetHash = string.Empty,
UnityAsset = unityAssetParent,
UnityAssetFilename = unityAsset?.Filename ?? string.Empty,
BakedUnityAssets = bakedAssets,
UnitySubAssets = Array.Empty<object>(),
},
LastModeratedSaveModerationState = 0,
DefaultMatchmakingPolicy = 0,
ShouldAutoStageSaves = true,
StagedSubRoomDataSaveId = (long?)null,
};
}).ToArray()
: new object[]
{
new
{
ReplicationId = room.LocationReplicationId,
PersistenceVersion = 0,
SupportsJoinInProgress = true,
UseLevelBasedMatchmaking = false,
UseAgeBasedMatchmaking = false,
UseRecRoyaleMatchmaking = false,
SubRoomId = 0L,
RoomId = room.Id,
Name = "Home",
UnityAssetId = string.Empty,
SubRoomUnityAssetId = string.Empty,
UnityAsset = string.Empty,
UnityAssetHash = string.Empty,
DataBlob = dataBlobName,
DataBlobHash = (string?)null,
DataSavedAt = updatedAt,
IsSandbox = false,
MaxPlayers = 8,
Accessibility = room.Accessibility,
UnitySceneId = room.LocationReplicationId,
CurrentSave = new
{
SubRoomDataSaveId = 0L,
SubRoomId = 0L,
UnityAssetId = string.Empty,
SubRoomUnityAssetId = string.Empty,
ReferencedUnityAssetIds = Array.Empty<string>(),
DataBlob = dataBlobName,
},
},
};
var wireRoles = new List<object>
{
BuildRoomAccountRoleWire(room.CreatorPlayerId, 30),
};
wireRoles.AddRange(roleList.Select(BuildRoomRoleGrantWire));
var tags = string.IsNullOrEmpty(room.TagsCsv)
? Array.Empty<object>()
: room.TagsCsv
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(t => (object)new
{
Type = RoomTagTypeForWire(t),
Tag = t,
IsPrimaryGenre = false,
})
.ToArray();
return new
{
RoomId = room.Id,
Name = room.Name,
Description = room.Description,
ImageName = room.ImageName,
WarningMask = room.RoomWarningMask,
CustomWarning = room.CustomRoomWarning,
CreatorAccountId = room.CreatorPlayerId,
State = room.State,
Accessibility = room.Accessibility,
SupportsLevelVoting = room.SupportsLevelVoting,
IsRRO = room.IsAGRoom,
SupportsScreens = room.SupportsScreens,
SupportsWalkVR = room.SupportsWalkVR,
SupportsTeleportVR = room.SupportsTeleportVR,
SupportsVRLow = room.SupportsVRLow,
SupportsQuest2 = true,
SupportsMobile = room.SupportsMobile,
SupportsJuniors = room.AllowsJuniors,
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 = dataBlobName,
DataBlobHash = (string?)null,
// The room-level cap is the room's own advertised capacity, which is
// what RoomEntity.MaxCapacity exists for and what matchmaking hands
// 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<JsonElement>();
try
{
return JsonSerializer.Deserialize<JsonElement[]>(json) ?? Array.Empty<JsonElement>();
}
catch (JsonException)
{
return Array.Empty<JsonElement>();
}
}
private static string[] JsonStringArrayOrEmpty(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return Array.Empty<string>();
try
{
return JsonSerializer.Deserialize<string[]>(json) ?? Array.Empty<string>();
}
catch (JsonException)
{
return Array.Empty<string>();
}
}
/// <summary>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).</summary>
public const int StudioPersistenceVersion = 41;
/// <summary>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).</summary>
public const int StudioUgcVersion = 1;
// ── Clone / create ───────────────────────────────────────────────────
/// <summary>
/// `Rooms.CloneRoom(roomIdToClone, name)` — POST api/rooms/v1/clone
/// with body `{"Name":"<new>","RoomId":<sourceId>}`.
///
/// 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)
/// </summary>
[HttpPost("api/rooms/v1/clone")]
[Authorize]
public async Task<IActionResult> 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<IActionResult> MyRooms()
{
var pid = CurrentPlayerId;
if (pid is null) return Ok(Array.Empty<object>());
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<IActionResult> CreatedByOther(long otherPlayerId)
=> Ok((await rooms.CreatedByAsync(otherPlayerId)).Select(RoomService.ToWireRoom).ToList());
/// <summary>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 (<c>settings:partyinvite</c>).</summary>
private const string ShowcaseSettingKey = "rooms:showcase";
private const int ShowcaseMaxRooms = 50;
/// <summary>GET <c>showcase/{accountId}</c> — the profile "Showcase Rooms"
/// carousel (<c>RoomListModel.JMGLGEKHHAK.PlayerShowcaseRooms = 7</c>,
/// dump.cs:2042498, driven by
/// <c>AccountModelController.RoomsShowcaseLinkImpl</c> at dump.cs:2113041).
/// Distinct from <c>PlayerCreatedRooms = 5</c>, which is the
/// <c>rooms/createdby/{id}</c> list above.
///
/// <para><b>Verb.</b> <c>NLDBPDCNNCF.EJPNHLIJNPM</c> formats the literal
/// <c>"showcase/{0}"</c> (NLDBPDCNNCF.txt ISIL 021 at :4484) and moves the
/// HTTPMethods ordinal <c>0</c> (= 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 <c>System.Int32</c> (ISIL 017) — the
/// account id.</para>
///
/// <para><b>Response shape.</b> The issuing method's return type is the
/// contract: <c>Task&lt;List&lt;System.Int64&gt;&gt;</c>
/// (NLDBPDCNNCF.txt:4422) — a BARE JSON array of room ids, not Room
/// objects and not a <c>{Results,TotalResults}</c> container. The Rooms the
/// UI renders are a purely client-side projection: the cache entry
/// <c>&lt;GetRoomsShowcase&gt;b__0</c> is typed
/// <c>Task&lt;IReadOnlyList&lt;Int64&gt;&gt;</c>
/// (IBEOONPEELF_NestedType_HHDHJPIJBJB.txt:14) and the outer
/// <c>IBEOONPEELF.EJPNHLIJNPM</c> resolves those ids through the room cache
/// to <c>FGLDKEJLAKB&lt;IReadOnlyList&lt;NEMINAEBALC&gt;&gt;</c>
/// (IBEOONPEELF.txt:8952) before <c>RoomListModel</c> consumes it
/// (RoomListModel.txt ISIL 742 at :5819).</para>
///
/// <para><b>Backing data.</b> <c>showcase/{0}</c> 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
/// <see cref="ShowcaseSettingKey"/> and is writable through
/// <c>PUT /settings/v1/{accountId}</c> 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.</para>
///
/// <para>Unauthenticated on purpose: this is another player's profile
/// surface, and the sibling <c>rooms/createdby/{id}</c> is open too. The
/// owner viewing their own profile additionally sees their non-public
/// picks; everyone else sees public rooms only.</para></summary>
[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<IActionResult> 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<IActionResult> VisitedByMe()
{
var pid = CurrentPlayerId;
if (pid is null) return Ok(Array.Empty<object>());
return await VisitedByForViewerAsync(pid.Value, pid.Value);
}
[HttpGet("rooms/visitedby/{playerId:long}")]
[HttpGet("roomserver/rooms/visitedby/{playerId:long}")]
public async Task<IActionResult> VisitedBy(long playerId)
{
return await VisitedByForViewerAsync(playerId, CurrentPlayerId);
}
private async Task<IActionResult> 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<object>());
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<IActionResult> ModeratedByMe()
{
var pid = CurrentPlayerId;
if (pid is null) return Ok(Array.Empty<object>());
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<IActionResult> FavoritedByMe()
{
var pid = CurrentPlayerId;
if (pid is null) return Ok(Array.Empty<object>());
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<IActionResult> CheeredByMe()
{
var pid = CurrentPlayerId;
if (pid is null) return Ok(Array.Empty<object>());
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<IActionResult> MyBookmarks()
{
var pid = CurrentPlayerId;
if (pid is null) return Ok(Array.Empty<object>());
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<object>());
/// <summary>Per-(player, room) persistence blob, base64. Lives in the
/// generic <see cref="PlayerSettingEntity"/> 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 <c>MaxLength(1024)</c> but SQLite stores it as unconstrained
/// TEXT and EF Core does not run DataAnnotations validation on SaveChanges,
/// so oversized blobs round-trip intact.</summary>
private static string PlayerRoomDataKey(long roomId) => $"roomplayerdata:{roomId}";
/// <summary>Fallback blob handed to a player who has never saved anything
/// in this room. <c>CAE=</c> is a one-field protobuf the client parses as
/// "empty, version 1"; a truly empty string makes the 2023 reader throw.</summary>
private const string DefaultPlayerRoomData = "CAE=";
/// <summary>GET <c>rooms/{roomId}/playerdata/me</c> — the per-room player
/// blob (CircuitsV2 "player persistence"). Client contract is
/// <c>Task&lt;ILKMFMCOPPO&gt;</c> whose reader requires the key
/// <c>Data</c> (OPNPNPMKLGI.txt ISIL 034/060); the request is issued from
/// NLDBPDCNNCF (NLDBPDCNNCF_NestedType_IGENGEIMGMJ.txt:169), so it arrives
/// with the <c>roomserver/</c> prefix — the bare-only registration 404'd.
/// This used to return a hardcoded <c>CAE=</c> for everyone; it now returns
/// whatever <see cref="SavePlayerDataForMe"/> last stored.</summary>
[HttpGet("player_room_data/{roomId:long}")]
[HttpGet("rooms/{roomId:long}/playerdata/me")]
[HttpGet("roomserver/rooms/{roomId:long}/playerdata/me")]
[Authorize]
public async Task<IActionResult> 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; } }
/// <summary>PUT <c>rooms/{roomId}/playerdata/me</c> — persist the caller's
/// per-room blob. Client method <c>NLDBPDCNNCF.LIHFBBCDAGO(long, string)</c>
/// ("save player room data") moves verb ordinal 3 = PUT into r8 and posts a
/// single form field <c>data</c>
/// (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 <c>Task</c>, so no response body is parsed.
///
/// <para>Only the GET was registered before, so every save 405'd and per-room
/// player state never persisted.</para></summary>
[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<IActionResult> 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 });
}
/// <summary>GET <c>rooms/requiring/{restriction}</c> — the restricted-rooms
/// list. The path segment is <c>mode.ToString().ToLower()</c>
/// (Matchmaking.txt ISIL 067-072 around :21263, concat onto the literal
/// <c>"rooms/requiring/"</c> at :21264).
///
/// <para><b>Element type is String, not Int64 — verified, do not "fix" this
/// to room ids.</b> The issuing method
/// <c>Matchmaking.POIMPHMONON(IOCOJFJEDCM)</c> (Matchmaking.txt:21030)
/// resolves its promise as <c>FGLDKEJLAKB&lt;List&lt;String&gt;&gt;</c>
/// (typeof load at Matchmaking.txt:21327, continuation delegate
/// <c>Action&lt;List&lt;String&gt;&gt;</c> at :21315) and hands the payload to
/// <c>&lt;RequestRoomRestrictions&gt;b__2(List&lt;String&gt; rooms)</c>
/// (Matchmaking_NestedType_PMNEOAMKLJL.txt:14), which forwards it to
/// <c>Matchmaking.IINEGHJJBAN(IEnumerable&lt;String&gt;, IOCOJFJEDCM)</c>
/// (:20758) where the strings become the KEYS of the restriction map
/// (<c>List&lt;IOCOJFJEDCM&gt;</c> 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).</para></summary>
[HttpGet("rooms/requiring/{restriction}")]
[HttpGet("roomserver/rooms/requiring/{restriction}")]
[Authorize]
public async Task<IActionResult> RoomsRequiring(string restriction)
{
var key = (restriction ?? string.Empty).Trim().TrimStart('#').ToLowerInvariant();
if (key.Length == 0) return Ok(new List<string>());
IQueryable<RoomEntity> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<RoomEntity> PublicRoomQuery() =>
db.Rooms.AsNoTracking().Where(r =>
r.State == 0 &&
r.Accessibility == 1 &&
!r.IsDormRoom &&
!r.HiddenFromBrowse);
private async Task<List<object>> BuildRoomServerListAsync(IReadOnlyCollection<RoomEntity> roomRows)
{
if (roomRows.Count == 0) return new List<object>();
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<RoomSceneEntity>)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<RoomRoleEntity>)g.ToList());
return roomRows
.Select(r => BuildRoomServerDetails(
r,
scenesByRoom.GetValueOrDefault(r.Id),
roles: rolesByRoom.GetValueOrDefault(r.Id)))
.ToList();
}
// ── Helpers ──────────────────────────────────────────────────────────
/// <summary>GET <c>api/rooms/v1/agRoomIds</c> — list of every
/// seeded AG / RR-Original room id. Used by the watch to bias the
/// room browser toward official rooms first.</summary>
// Bare array of Int64 — the 2023 issuing method GBONEKLGFFK returns
// Task<List<Int64>> (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<IActionResult> AgRoomIds() =>
Ok(await rooms.AgRoomIdsAsync());
/// <summary>GET <c>api/rooms/v1/featuredRoomGroup</c> +
/// <c>v3/featured</c> — the "Featured" carousel on the watch's
/// rooms tab. Returns the top-12 AG rooms by HotScore.</summary>
[HttpGet("api/rooms/v1/featuredRoomGroup")]
[HttpGet("api/rooms/v3/featured")]
public async Task<IActionResult> Featured() => Ok(new
{
Name = "Featured",
FeaturedRooms = await rooms.FeaturedAgRoomIdsAsync(12),
});
/// <summary>GET <c>featuredrooms/current</c> — the watch's pointer
/// to the current "Featured" room group. Caller:
/// <c>EJDCNGBEICB.CFKDADKHAGB</c>. Wire shape is <c>NMPFCIJPODA</c>
/// with required keys <c>FeaturedRoomGroupId</c> (long) and
/// <c>Name</c> (string) — a pointer record, NOT the room list
/// itself (the watch fetches the list separately via
/// <c>api/rooms/v3/featured</c>). Returning the
/// <see cref="Featured"/> shape here throws KeyNotFoundException on
/// the watch's group-id lookup.</summary>
// Same roomserver/ prefix requirement as rooms/base.
[HttpGet("featuredrooms/current")]
[HttpGet("roomserver/featuredrooms/current")]
public async Task<IActionResult> 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<string, object?>();
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,
});
}
/// <summary>POST <c>api/rooms/v1/bookmark</c> — toggle bookmark
/// state for a room. Body: <c>RoomId</c> + optional <c>Bookmark</c>
/// flag (default = add).</summary>
[HttpPost("api/rooms/v1/bookmark")]
[Authorize]
public async Task<IActionResult> 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 = "" });
}
/// <summary>POST <c>rooms/{roomId}/name</c> — rename a room.
/// Watch path: <c>EJDCNGBEICB.DNOGGKKHMHI(long roomId, string newName)</c>
/// (analytics tag "change room name"). Body shape: form-urlencoded
/// <c>Name=...</c> or a bare string in the body. Returns the
/// updated Room DTO (<c>PPENFJMFPNE : KLCOGEIGEBJ</c>).
///
/// <para>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 <c>CreateModifyRoomStatus</c>-style payload with
/// a non-zero Result so the rename toast surfaces a real reason.</para>
///
/// <para>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.</para>
///
/// <para><b>2023 client:</b> <c>NLDBPDCNNCF.LHNDKOJNKBE</c>
/// ("change room name", NLDBPDCNNCF.txt:5069) moves verb ordinal 3 = PUT,
/// sends the lowercase form key <c>name</c>
/// (NLDBPDCNNCF_NestedType_LMADOAEHOPP.txt ISIL 015) and returns
/// <c>Task&lt;FGCPNAACHIK&gt;</c> — the FULL room-details object, not a
/// status wrapper. It also carries the <c>roomserver/</c> 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 <c>[FromForm]</c> with a <c>[FromBody]</c>
/// parameter, which under <c>[ApiController]</c> makes the form PUT 415
/// before the handler runs — hence the shared FormOrJsonModelBinder.</para></summary>
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<IActionResult> 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.
var coOwner = await db.RoomRoles.AnyAsync(rr =>
rr.RoomId == roomId && rr.PlayerId == me && rr.Accepted && (rr.Role == 0 || rr.Role == 1));
if (!coOwner) return Forbid();
}
// Uniqueness — Rooms.Name has a unique index (DorkNetDbContext.cs:118).
var collision = await db.Rooms.AnyAsync(r => r.Id != roomId && r.Name == newName);
if (collision) return Conflict(new { Result = 5, error = "name_taken" });
room.Name = newName;
room.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
logger.LogInformation("[room-rename] room={Room} by={By} → '{Name}'", roomId, me, newName);
// Full room details, not the old minimal {Result,RoomId,Name,…} object:
// the 2023 issuing method returns Task<FGCPNAACHIK> (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));
}
/// <summary>POST <c>rooms/{roomId}/subrooms/{subRoomId}/name</c> —
/// rename a scene/sub-room within a multi-scene room. Watch path:
/// <c>EJDCNGBEICB.PAENPJCDLNJ(long roomId, long subRoomId, string newName)</c>.
/// Updates the matching <see cref="RoomSceneEntity.Name"/> after
/// verifying ownership and intra-room uniqueness.
///
/// <para><b>2023 client:</b> <c>NLDBPDCNNCF.EJOHFBMFEGB</c>
/// ("change subroom name", NLDBPDCNNCF.txt:8694) is verb 3 = PUT with form
/// key <c>name</c> and returns <c>Task&lt;FGCPNAACHIK&gt;</c>. Same
/// [FromForm]+[FromBody] 415 hazard as the room rename above, so it binds
/// through the shared FormOrJsonModelBinder and echoes full details.</para></summary>
[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<IActionResult> RenameSubroom(
long roomId,
long subRoomId,
[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)
{
var coOwner = await db.RoomRoles.AnyAsync(rr =>
rr.RoomId == roomId && rr.PlayerId == me && rr.Accepted && (rr.Role == 0 || rr.Role == 1));
if (!coOwner) return Forbid();
}
var scene = await db.RoomScenes.FirstOrDefaultAsync(s =>
s.RoomId == roomId && s.OrderIndex == subRoomId);
if (scene is null) return NotFound(new { Result = 4, error = "subroom_not_found", subRoomId });
// Uniqueness within this room — same scene name twice would
// make /goto/room/X/Y ambiguous against the RoomScenes index
// on (RoomId, Name) at DorkNetDbContext.cs:153.
var collision = await db.RoomScenes.AnyAsync(s =>
s.RoomId == roomId && s.OrderIndex != subRoomId && s.Name == newName);
if (collision) return Conflict(new { Result = 5, error = "subroom_name_taken" });
scene.Name = newName;
scene.DataModifiedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
logger.LogInformation("[subroom-rename] room={Room} sub={Sub} by={By} → '{Name}'", roomId, subRoomId, me, newName);
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
}
/// <summary>POST <c>api/rooms/v1/cheer</c> — cheer a room (body
/// form). Idempotent per (caller, room) — duplicate taps don't
/// inflate CheerCount.</summary>
[HttpPost("api/rooms/v1/cheer")]
[Authorize]
public async Task<IActionResult> 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 });
}
/// <summary>POST <c>api/rooms/v1/uncheer</c> — remove a previously
/// applied room cheer.</summary>
[HttpPost("api/rooms/v1/uncheer")]
[HttpPost("api/rooms/v1/uncheer/{roomId:long}")]
[Authorize]
public async Task<IActionResult> 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 });
}
/// <summary>GET <c>api/rooms/v1/cheers/{roomId}</c> — did the
/// caller cheer this room, and what's the current count?</summary>
[HttpGet("api/rooms/v1/cheers/{roomId:long}")]
[Authorize]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> BareUnfavorite(long roomId)
{
var me = this.RequireCurrentPlayerId();
await rooms.UnbookmarkAsync(me, roomId);
return Ok(new { success = true });
}
// ── Rooms-and-Playlists union (2020.12 watch) ────────────────────────
/// <summary>
/// GET <c>/roomserver/roomsandplaylists/hot</c> — the 2020.12 watch's
/// merged "Trending" / "New" tab. Two URL variants exist with
/// DIFFERENT wire shapes:
///
/// 1. <c>/roomserver/roomsandplaylists/hot</c> (this endpoint)
/// Watch caller: <c>EJDCNGBEICB.EBBHGHEGBKG</c>
/// Signature: <c>IPromise&lt;HJKAOMOICJG&gt;</c>
/// Wire shape: <c>{ TotalResults, Results: [union] }</c>
/// (verified at <c>EJDCNGBEICB.txt:2571,2645</c> +
/// <c>HJKAOMOICJG.txt:39-50</c>)
/// 2. <c>/roomserver/hot_roomsandplaylists/{tags-joined}</c>
/// Watch caller: <c>OJMCBOKJFOF.EBBHGHEGBKG</c>
/// Signature: <c>IPromise&lt;List&lt;MKAMHOIHOJK&gt;&gt;</c>
/// Wire shape: bare list, see <see cref="HotRoomsAndPlaylistsPath"/>
/// (verified at <c>OJMCBOKJFOF.txt:4607,4743</c>)
///
/// Returning a bare list here throws
/// <c>InvalidCastException: Unable to cast object of type 'List`1'
/// to type 'Dictionary`2'</c> on the watch — the wrapped
/// HJKAOMOICJG deserializer reads <c>TotalResults</c> as a top-level
/// dict key, and a JSON array body fails that cast.
///
/// Inside <c>Results</c>: union entries discriminated by lowercase
/// <c>roomId</c> key per the <c>MKAMHOIHOJK</c> factory at
/// <c>MKAMHOIHOJK.txt:621</c> — see <see cref="BuildRoomUnionEntry"/>
/// + <see cref="BuildPlaylistUnionEntry"/>.
///
/// <para><b>Why rooms-only despite the "and playlists" name:</b> 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 — <c>/api/curatedroomplaylists</c> +
/// <c>/roomserver/playlists/{id}</c>). 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.</para>
/// </summary>
[HttpGet("roomserver/roomsandplaylists/hot")]
[HttpGet("roomsandplaylists/hot")]
public async Task<IActionResult> RoomsAndPlaylistsHot([FromQuery] string? tag)
{
var (entries, total) = await BuildHotRoomsAsync(tag);
return Ok(new Dictionary<string, object>(StringComparer.Ordinal)
{
["TotalResults"] = (long)total,
["Results"] = entries,
});
}
/// <summary>
/// GET <c>/roomserver/hot_roomsandplaylists/{tagsJoined}</c> —
/// path-style sibling of <see cref="RoomsAndPlaylistsHot"/> with a
/// BARE <c>List&lt;MKAMHOIHOJK&gt;</c> shape (no TotalResults
/// wrapper). Tags are comma-joined into the URL path per
/// <c>OJMCBOKJFOF.txt:4739,4743</c> (<c>String.Join(",", tags)</c>
/// followed by <c>String.Concat("hot_roomsandplaylists/", joined)</c>).
/// Empty path is legal — equivalent to "no tag filter".
/// </summary>
[HttpGet("roomserver/hot_roomsandplaylists/{tagsJoined?}")]
[HttpGet("hot_roomsandplaylists/{tagsJoined?}")]
[HttpGet("rooms/hot_roomsandplaylists/{tagsJoined?}")]
public async Task<IActionResult> 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);
}
/// <summary>GET <c>hot_rooms/{tagsJoined?}</c> — rooms-only variant
/// of <see cref="HotRoomsAndPlaylistsPath"/>. Caller:
/// <c>OJMCBOKJFOF.DLPLPKCNLNA</c>. Same <c>MKAMHOIHOJK</c> union
/// entry shape as <c>hot_roomsandplaylists</c>, just skips playlist
/// rows so the watch's rooms-only browse tab doesn't render
/// playlist tiles that have nowhere to go.</summary>
[HttpGet("hot_rooms/{tagsJoined?}")]
[HttpGet("rooms/hot_rooms/{tagsJoined?}")]
[HttpGet("roomserver/hot_rooms/{tagsJoined?}")]
public async Task<IActionResult> 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<RoomSceneEntity>()
: 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<RoomSceneEntity>)g.ToList());
return Ok(roomList
.Select(r => BuildRoomUnionEntry(r, scenesByRoom.GetValueOrDefault(r.Id)))
.ToList());
}
/// <summary>
/// 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.
///
/// <para>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.</para>
/// </summary>
private async Task<(List<IDictionary<string, object>> 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<RoomSceneEntity>()
: 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<RoomSceneEntity>)g.ToList());
var entries = roomList
.Select(r => BuildRoomUnionEntry(r, scenesByRoom.GetValueOrDefault(r.Id)))
.ToList();
return (entries, entries.Count);
}
/// <summary>
/// GET <c>/api/curatedroomplaylists</c> — the editorial "Curated"
/// row on the watch's playlists tab.
///
/// Wire shape: <c>List&lt;Int64&gt;</c> — JUST the playlist IDs, not
/// the full playlist objects. Verified against
/// <c>FPNPBJBCMKB.EPPPMHMMGME</c> (signature
/// <c>IPromise&lt;List&lt;long&gt;&gt;</c>) and the call site at
/// <c>BrowseRoomsScreen.txt:2806</c> which constructs an
/// <c>Action&lt;List&lt;Int64&gt;&gt;</c> callback. Returning rich
/// objects here throws
/// <c>InvalidCastException: Unable to cast object of type 'List`1'
/// to type 'Dictionary`2'</c> on the watch ("Failed to get curated
/// room playlists: Malformed Response"). The watch fetches each
/// playlist's details separately via the playlist-detail endpoints.
/// </summary>
[HttpGet("api/curatedroomplaylists")]
public async Task<IActionResult> CuratedRoomPlaylists()
{
var curated = await playlists.CuratedAsync();
return Ok(curated.Select(p => p.Id).ToList());
}
/// <summary>
/// GET <c>/roomserver/playlists/{id}</c> — single-playlist details
/// with members. The watch deserializes this as <b>BMFAGMFKODA</b>
/// (verified at <c>BMFAGMFKODA.txt:182-191</c>) which:
/// 1. Calls <c>KMKPEOGJDFK.PPGFHEDFBEA</c> to read the base
/// Playlist union fields (<c>PlaylistId</c> + all
/// <c>MKAMHOIHOJK</c> base keys).
/// 2. Reads required key <c>Rooms</c> — <c>List&lt;KLCOGEIGEBJ&gt;</c>
/// (the playlist's member rooms, full Room dicts).
/// 3. Reads required key <c>Tags</c> — <c>List&lt;DPHPFLGAICI&gt;</c>
/// (tag rows with <c>Type</c> + <c>Tag</c>).
/// Returning just the <c>KMKPEOGJDFK</c> shape (no Rooms/Tags) throws
/// <c>KeyNotFoundException: Failed to find key 'Rooms' when
/// deserializing object of type KLCOGEIGEBJ</c> on the watch.
/// </summary>
[HttpGet("roomserver/playlists/{id:long}")]
[HttpGet("playlists/{id:long}")]
public async Task<IActionResult> 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<RoomEntity> memberRooms;
Dictionary<long, IReadOnlyList<RoomSceneEntity>> scenesByRoom;
if (roomIds.Count == 0)
{
memberRooms = new List<RoomEntity>();
scenesByRoom = new Dictionary<long, IReadOnlyList<RoomSceneEntity>>();
}
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<RoomSceneEntity>)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<object>()
: p.TagsCsv
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(t => (object)new
{
Type = RoomTagTypeForWire(t),
Tag = t,
IsPrimaryGenre = false,
})
.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);
}
/// <summary>
/// GET <c>/roomserver/roomsandplaylists/search?query=X</c> — search
/// surface used by the watch's friend-mention / room-search affordance.
/// Verified at <c>EJDCNGBEICB.txt:2486,2560</c> — signature
/// <c>IPromise&lt;HJKAOMOICJG&gt;</c>. Per
/// <see cref="HJKAOMOICJG.PPGFHEDFBEA"/> at <c>HJKAOMOICJG.txt:39-50</c>
/// the response object reads <c>TotalResults</c> (Int64) and
/// <c>Results</c> (List of <c>MKAMHOIHOJK</c> union entries — same
/// Room/Playlist factory used by <c>/hot</c>).
///
/// Distinct from <c>/roomserver/search_roomsandplaylists/{query}</c>
/// (path-param shape from <c>OJMCBOKJFOF.txt:4567</c>); both URLs
/// reach the same data but differ in shape — the path-param sibling
/// returns a bare <c>List&lt;MKAMHOIHOJK&gt;</c> without the
/// TotalResults wrapper.
/// </summary>
[HttpGet("roomserver/roomsandplaylists/search")]
[HttpGet("roomsandplaylists/search")]
public async Task<IActionResult> RoomsAndPlaylistsSearch([FromQuery] string? query)
{
var (merged, total) = await BuildSearchUnionAsync(query);
return Ok(new Dictionary<string, object>(StringComparer.Ordinal)
{
["TotalResults"] = (long)total,
["Results"] = merged,
});
}
/// <summary>
/// GET <c>/roomserver/search_roomsandplaylists/{query}</c> — free-text
/// search across both rooms and playlists, BARE-LIST variant. Per
/// <c>OJMCBOKJFOF.txt:4439,4567</c> the signature is
/// <c>IPromise&lt;List&lt;MKAMHOIHOJK&gt;&gt;</c> — no TotalResults
/// wrapper. URL is <c>search_roomsandplaylists/{query}</c>
/// (query in path, not query-string).
/// </summary>
[HttpGet("roomserver/search_roomsandplaylists/{query}")]
[HttpGet("search_roomsandplaylists/{query}")]
[HttpGet("rooms/search_roomsandplaylists/{query}")]
public async Task<IActionResult> SearchRoomsAndPlaylists(string query)
{
var (text, _, _) = ParsePagedPathSegment(query, defaultTake: 50);
var (merged, _) = await BuildSearchUnionAsync(text);
return Ok(merged);
}
/// <summary>GET <c>search_rooms/{query}</c> — rooms-only search
/// variant. Same <c>MKAMHOIHOJK</c> union entry shape as
/// <c>search_roomsandplaylists/{query}</c>, just skips playlist
/// rows.
///
/// The 2023 client's play-menu search (<c>IBEOONPEELF.SearchRooms</c>,
/// URL builder <c>GGDPFFDEDKN</c>) calls the <c>rooms/</c>-prefixed
/// variant and embeds paging in the SAME path segment:
/// <c>rooms/search_rooms/{query}&amp;skip={skip}&amp;take={take}</c>.
/// Missing the prefixed route = "Error refreshing rooms from source
/// ServerSearch" in the play menu.</summary>
[HttpGet("search_rooms/{query}")]
[HttpGet("rooms/search_rooms/{query}")]
[HttpGet("roomserver/search_rooms/{query}")]
public async Task<IActionResult> SearchRoomsOnly(string query)
{
var (text, skip, take) = ParsePagedPathSegment(query, defaultTake: 50);
if (string.IsNullOrWhiteSpace(text))
return Ok(Array.Empty<object>());
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<RoomSceneEntity>()
: 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<RoomSceneEntity>)g.ToList());
return Ok(roomList
.Select(r => BuildRoomUnionEntry(r, scenesByRoom.GetValueOrDefault(r.Id)))
.ToList());
}
/// <summary>Split a 2023-style browse-source path segment into its
/// value and paging. The 2023 URL builder (<c>GGDPFFDEDKN</c>)
/// formats <c>search_rooms/{0}&amp;skip={1}&amp;take={2}</c> /
/// <c>hot_rooms/{0}&amp;skip={1}&amp;take={2}</c> — the paging pairs
/// land inside the final path segment, not the query string, so
/// route params capture "pizza&amp;skip=0&amp;take=20" verbatim.
/// 2020 URLs have no '&amp;' and pass through unchanged.</summary>
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));
}
/// <summary>
/// Shared body for the two search variants. Empty/whitespace query
/// returns an empty result without round-tripping the DB.
/// </summary>
private async Task<(List<IDictionary<string, object>> Entries, int Total)> BuildSearchUnionAsync(string? query)
{
if (string.IsNullOrWhiteSpace(query))
return (new List<IDictionary<string, object>>(), 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<RoomSceneEntity>)g.ToList());
var merged = InterleaveUnion(
roomList.Select(r => BuildRoomUnionEntry(r, scenesByRoom.GetValueOrDefault(r.Id))),
playlistList.Select(BuildPlaylistUnionEntry));
return (merged, roomList.Count + playlistList.Count);
}
/// <summary>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.</summary>
private static List<IDictionary<string, object>> InterleaveUnion(
IEnumerable<IDictionary<string, object>> a,
IEnumerable<IDictionary<string, object>> b)
{
var listA = a.ToList();
var listB = b.ToList();
var output = new List<IDictionary<string, object>>(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;
}
/// <summary>
/// Build a union-shape Room entry — same payload as
/// <see cref="BuildRoomServerDetails"/> but with the additional
/// lowercase <c>roomId</c> key that the 2020.12
/// <c>MKAMHOIHOJK</c> factory uses as its Room-vs-Playlist
/// discriminator (factory at <c>MKAMHOIHOJK.txt:621</c> calls
/// <c>Dictionary.ContainsKey("roomId")</c>; the actual Room
/// deserializer at <c>KLCOGEIGEBJ.txt:208-234</c> reads
/// PascalCase <c>RoomId</c>). Must include BOTH keys.
/// </summary>
private static IDictionary<string, object> BuildRoomUnionEntry(
RoomEntity room,
IReadOnlyList<RoomSceneEntity>? 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<string, object>(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;
}
/// <summary>
/// Build a union-shape Playlist entry — the
/// <see cref="KMKPEOGJDFK"/> branch of MKAMHOIHOJK. Keys read at
/// <c>MKAMHOIHOJK.txt:516-612</c> (PascalCase base keys) and
/// <c>KMKPEOGJDFK.txt:68</c> (PascalCase <c>PlaylistId</c>).
/// Omits the lowercase <c>roomId</c> key so the factory's
/// Room-vs-Playlist branch falls through to the Playlist
/// constructor.
/// </summary>
public static IDictionary<string, object> BuildPlaylistUnionEntry(PlaylistEntity p)
{
return new Dictionary<string, object>(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; }
}
/// <summary>POST <c>rooms/{id}/subrooms</c> — append a new scene.
/// Owner-gated. OrderIndex = max+1, Name unique within room.
///
/// <para><b>2023 client:</b> <c>NLDBPDCNNCF.CMHBCOIBDME</c> ("add new
/// subroom", NLDBPDCNNCF.txt:9396) is verb 2 = POST with a single form key
/// <c>name</c> (NLDBPDCNNCF_NestedType_AIPHLHBILDN.txt ISIL 015) and returns
/// <c>Task&lt;FGCPNAACHIK&gt;</c>. It needs the roomserver/ twin, a binder
/// that accepts the form body (<c>[FromBody]</c> 415s it), and full room
/// details in the response instead of the scene row.</para></summary>
[HttpPost("rooms/{roomId:long}/subrooms")]
[HttpPost("roomserver/rooms/{roomId:long}/subrooms")]
[Authorize]
public async Task<IActionResult> 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 (room.CreatorPlayerId != pid) return Forbid();
var name = (req.Name ?? string.Empty).Trim();
if (name.Length == 0) name = "NewScene";
var collision = await db.RoomScenes.AnyAsync(s => s.RoomId == roomId && s.Name == name);
if (collision) return Conflict(new { error = "name_taken" });
var nextOrder = (await db.RoomScenes
.Where(s => s.RoomId == roomId)
.Select(s => (int?)s.OrderIndex).MaxAsync() ?? -1) + 1;
var scene = new RoomSceneEntity
{
RoomId = roomId,
OrderIndex = nextOrder,
Name = name,
RoomSceneLocationId = req.RoomSceneLocationId
?? "a75f7547-79eb-47c6-8986-6767abcb4f92",
MaxPlayers = req.MaxPlayers ?? 8,
IsSandbox = req.IsSandbox ?? false,
};
db.RoomScenes.Add(scene);
await db.SaveChangesAsync();
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
}
/// <summary>DELETE <c>rooms/{id}/subrooms/{sub}</c>. The 2023 caller
/// <c>NLDBPDCNNCF.HGNGMIMJFJL</c> ("delete subroom", NLDBPDCNNCF.txt:9827)
/// moves verb ordinal 4 = DELETE and returns
/// <c>Task&lt;FGCPNAACHIK&gt;</c>; 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 <c>{Result:0}</c> body was not the
/// details object the client re-parses.</summary>
[HttpDelete("rooms/{roomId:long}/subrooms/{subRoomId:long}")]
[HttpDelete("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}")]
[Authorize]
public async Task<IActionResult> DeleteSubRoom(long roomId, long subRoomId)
{
var pid = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
if (room.CreatorPlayerId != pid) return Forbid();
var scene = await db.RoomScenes
.FirstOrDefaultAsync(s => s.RoomId == roomId && s.OrderIndex == subRoomId);
if (scene is null) return NotFound();
if (scene.OrderIndex == 0) return BadRequest(new { error = "cannot_delete_entry_scene" });
db.RoomScenes.Remove(scene);
await db.SaveChangesAsync();
return Ok(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
{
/// <summary>Target ROOM for the scene — the 2023 "move subroom" flow
/// re-parents a scene into another room. Nullable&lt;Int64&gt; on the
/// wire (NLDBPDCNNCF.MGGALMIAMBG, NLDBPDCNNCF.txt:9659).</summary>
public long? NewRoomId { get; set; }
/// <summary>Legacy in-room reorder used by the admin/import tooling.
/// Not something the 2023 client sends.</summary>
public int? NewIndex { get; set; }
}
/// <summary>One row of the per-subroom role-permission matrix the 2023
/// client PUTs to <c>subrooms/{sub}/permissions</c>. Keys verified in the
/// writer <c>HDOJMEBCAGP.txt</c> ISIL 050/069/085/101/117.</summary>
public sealed class SubRoomPermissionEntry
{
// Field order + CLR types come from the BNLPBMJJOMM constructor
// (BNLPBMJJOMM.txt:126): (String Permission, OMMBGJMJJPN Role,
// Boolean Override, FEOJFGECKGH Type, String Value). The two enum
// members serialize as numbers (the role writer boxes them
// System.Int32 — BFKNABDHMGG.txt ISIL 015), hence int? here.
public string? Permission { get; set; }
public int? Role { get; set; }
public bool? Override { get; set; }
public int? Type { get; set; }
public string? Value { get; set; }
}
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<IActionResult> 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); });
/// <summary>PUT <c>rooms/{id}/subrooms/{sub}/accessibility</c>. Client:
/// <c>NLDBPDCNNCF.OAINKEAFPGE(long, long, JHAOFLCJNAL)</c>
/// (NLDBPDCNNCF.txt:9032, verb 3), form key <c>accessibility</c>, response
/// <c>Task&lt;FGCPNAACHIK&gt;</c>. We only model the matchmaking bit
/// (<see cref="RoomSceneEntity.CanMatchmakeInto"/>), so Private collapses to
/// "not a matchmaking target" and everything else to "is one".</summary>
[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<IActionResult> 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;
});
/// <summary>PUT <c>rooms/{id}/subrooms/{sub}/permissions</c> — the
/// role-permission matrix. Client:
/// <c>NLDBPDCNNCF.AAMEOMHKPHF(long, long, IReadOnlyList&lt;BNLPBMJJOMM&gt;)</c>
/// (NLDBPDCNNCF.txt:9198, verb 3). Unlike its siblings this one does NOT
/// form-encode: the closure calls <c>BNDIAONDFFF.FJLLPHFOOJJ(String)</c>
/// (CCNOMNNHNNG.txt ISIL 009), which stamps body-kind 4 = raw string
/// (BNDIAONDFFF.txt:275, <c>mov dword ptr [rbx+38h],4</c>) — i.e. a raw
/// JSON ARRAY. FormOrJsonModelBinder handles that (non-form → JSON
/// deserialize into the List).
///
/// <para><b>Scope note — deliberately not persisted.</b> DorkNet has no
/// per-subroom permission storage: <see cref="RoomSceneEntity"/> has no
/// column for it and there is no permissions table. Adding one means an
/// entity + DbContext + migration, which is outside this file. Until then
/// the matrix is accepted, logged, and the room details echoed back so the
/// client's promise resolves instead of 415-ing; the values do not survive
/// a round-trip. The old handler silently bound an unrelated DTO whose keys
/// the client never sends, which was strictly worse.</para></summary>
[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<IActionResult> SubRoomPermissions(
long roomId,
long subRoomId,
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] List<SubRoomPermissionEntry> req)
{
var pid = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
if (room.CreatorPlayerId != pid) return Forbid();
var scene = await GetOrCreateSceneForMutationAsync(room, subRoomId);
if (scene is null) return NotFound();
await db.SaveChangesAsync();
logger.LogWarning(
"[subroom-permissions] room={Room} sub={Sub} by={By} entries={Count} — accepted but NOT persisted (no permission storage yet)",
roomId, subRoomId, pid, req?.Count ?? 0);
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<IActionResult> PublishSubRoomSave(long roomId, long subRoomId)
{
var pid = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
if (room.CreatorPlayerId != pid) return Forbid();
var scene = await 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<FGCPNAACHIK> (NLDBPDCNNCF.txt:10476),
// so the scene row alone made a successful restore read as a failure.
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
}
private async Task<PublishSubRoomSaveRequest> 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<PublishSubRoomSaveRequest>(
Request.Body,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
return body ?? request;
}
catch (JsonException)
{
return request;
}
}
/// <summary>POST <c>rooms/{id}/subrooms/{sub}/clone</c> — duplicate
/// a scene (DataBlobName + location pointer). New scene goes at the
/// end with " Copy" suffix; per-room name uniqueness preserved.</summary>
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/clone")]
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/clone")]
[Authorize]
public async Task<IActionResult> CloneSubRoom(long roomId, long subRoomId)
{
var pid = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
if (room.CreatorPlayerId != pid) return Forbid();
var src = await db.RoomScenes
.FirstOrDefaultAsync(s => s.RoomId == roomId && s.OrderIndex == subRoomId);
if (src is null) return NotFound();
var baseName = $"{src.Name} Copy";
var newName = baseName;
int suffix = 1;
while (await db.RoomScenes.AnyAsync(s => s.RoomId == roomId && s.Name == newName))
{
suffix += 1;
newName = $"{baseName} {suffix}";
}
var nextOrder = (await db.RoomScenes.Where(s => s.RoomId == roomId)
.Select(s => (int?)s.OrderIndex).MaxAsync() ?? -1) + 1;
var clone = new RoomSceneEntity
{
RoomId = roomId,
OrderIndex = nextOrder,
Name = newName,
RoomSceneLocationId = src.RoomSceneLocationId,
DataBlobName = src.DataBlobName,
MaxPlayers = src.MaxPlayers,
IsSandbox = src.IsSandbox,
CanMatchmakeInto = src.CanMatchmakeInto,
};
db.RoomScenes.Add(clone);
await db.SaveChangesAsync();
// Task<FGCPNAACHIK> ("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<IActionResult> 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;
});
/// <summary>POST <c>rooms/{id}/subrooms/{sub}/move</c>.
///
/// <para><b>The 2023 semantics are "move to another ROOM", not "reorder".</b>
/// <c>NLDBPDCNNCF.MGGALMIAMBG(long roomId, long subRoomId,
/// Nullable&lt;Int64&gt; newRoomId)</c> ("move subroom",
/// NLDBPDCNNCF.txt:9659, verb 2 = POST) sends the single form key
/// <c>newRoomId</c> (COIOGADEBKJ.txt ISIL 013-020, boxed as
/// <c>Nullable&lt;Int64&gt;</c>) and returns
/// <c>Task&lt;FGCPNAACHIK&gt;</c>. The old handler bound
/// <c>[FromBody] {NewIndex}</c> — a key the client never sends, on a body
/// binding that 415s form posts — and reordered scenes in place instead.</para>
///
/// <para>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 <c>OrderIndex</c> stays a dense 0..n-1 sequence — the
/// wire <c>RoomSceneId</c> and matchmaking <c>SubRoomId</c> are that index.
/// Scene 0 is the room's entry scene and cannot be moved out.
/// <c>newIndex</c> (no client sends it) keeps the old in-room reorder for
/// the import tooling.</para></summary>
[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<IActionResult> MoveSubRoom(
long roomId,
long subRoomId,
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] SubRoomMoveRequest req)
{
var pid = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
if (room.CreatorPlayerId != pid) return Forbid();
var scenes = await db.RoomScenes
.Where(s => s.RoomId == roomId).OrderBy(s => s.OrderIndex).ToListAsync();
var moving = scenes.FirstOrDefault(s => s.OrderIndex == subRoomId);
if (moving is null) return NotFound();
// `newRoomId` is Nullable<Int64> 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));
}
/// <summary>Rewrite <see cref="RoomSceneEntity.OrderIndex"/> 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.</summary>
private async Task RepackSceneOrderAsync(List<RoomSceneEntity> 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();
}
/// <summary>POST <c>roomserver/rooms/{id}/subrooms/{sub}/data</c> —
/// commit the blob returned by <c>storage/upload</c> as the current
/// scene save. The 2020 watch posts this as form-urlencoded
/// (<c>filename</c>, <c>inventionUsage</c>, <c>savedByAccountId</c>),
/// while some tooling uses JSON. The 2023 client posts JSON with
/// nested blob refs — <c>{UnityAssetId, RoomData:{Filename,Hash,
/// OwnershipProof}, SubRoomData:{Filename,…}}</c> — where
/// <c>SubRoomData.Filename</c> is the scene save (FileType=1 upload)
/// and <c>RoomData.Filename</c> 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.</summary>
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/data")]
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/data")]
[Authorize]
public async Task<IActionResult> SubRoomData(long roomId, long subRoomId)
{
var body = await ReadSaveRoomSceneRequestAsync();
body.RoomSceneId = subRoomId;
return await SaveDataCore(body, roomId, wrapCreateModifyResponse: true);
}
private async Task<SaveRoomSceneRequest> 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<SaveRoomSceneRequest>(
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;
}
/// <summary>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
/// <c>Task&lt;FGCPNAACHIK&gt;</c>, so the old scene-row payload made a
/// successful mutation surface as a client-side parse failure.</summary>
private async Task<IActionResult> MutateScene(long roomId, long subRoomId, Action<RoomSceneEntity> mutator)
{
var pid = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
if (room.CreatorPlayerId != pid) return Forbid();
var scene = await 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<RoomSceneEntity?> 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<FGCPNAACHIK> and re-parses the full room details, so all of these
// handlers now go through BuildRoomServerDetailsWithRolesAsync.
// ── Bare-path roles ────────────────────────────────────────────────
/// <summary>GET <c>rooms/{id}/roles</c> — account role DTOs consumed by
/// 2023's EFHPLDPNGIM deserializer
/// (<c>NLDBPDCNNCF.LAJDCNIMBAB</c> → <c>Task&lt;List&lt;EFHPLDPNGIM&gt;&gt;</c>,
/// NLDBPDCNNCF.txt:2111, verb 0 = GET). Like every other NLDBPDCNNCF call it
/// arrives with the <c>roomserver/</c> prefix, so the bare-only registration
/// 404'd the permissions screen's list fetch.</summary>
[HttpGet("rooms/{roomId:long}/roles")]
[HttpGet("roomserver/rooms/{roomId:long}/roles")]
public async Task<IActionResult> RolesList(long roomId)
{
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
var rows = await db.RoomRoles.Where(r => r.RoomId == roomId).ToListAsync();
var list = new List<object>(rows.Count + 1)
{
BuildRoomAccountRoleWire(room.CreatorPlayerId, 30),
};
list.AddRange(rows.Select(BuildRoomRoleGrantWire));
return Ok(list);
}
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<StudioBundleInfo> 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<StudioBundleInfo>()
.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,
@"^(?<save>\d+)__bundle_t(?<target>\d+)(?:_v(?<version>\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);
}
/// <summary>GET <c>rooms/{id}/roles/{playerId}</c> — one role row
/// (<c>NLDBPDCNNCF.PCFOIPELKHN</c> → <c>Task&lt;EFHPLDPNGIM&gt;</c>,
/// NLDBPDCNNCF.txt:2192). roomserver/ twin required, same as the list
/// above.</summary>
[HttpGet("rooms/{roomId:long}/roles/{playerId:long}")]
[HttpGet("roomserver/rooms/{roomId:long}/roles/{playerId:long}")]
public async Task<IActionResult> RoleForPlayer(long roomId, long playerId)
{
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
if (room.CreatorPlayerId == playerId)
return Ok(BuildRoomAccountRoleWire(playerId, 30));
var row = await db.RoomRoles
.FirstOrDefaultAsync(r => r.RoomId == roomId && r.PlayerId == playerId);
if (row is null) return NotFound();
return Ok(BuildRoomRoleGrantWire(row));
}
public sealed class GrantRoleRequest { public int? Role { get; set; } }
/// <summary>PUT <c>rooms/{id}/roles/{playerId}</c> — owner grants
/// a role to a player. Auto-accepted (no invite step).
/// <c>NLDBPDCNNCF.LMIEIJMCEAB</c> ("change room account role",
/// NLDBPDCNNCF.txt:7380) is verb 3 = PUT, form key <c>role</c> carrying a
/// boxed Int32 (BFKNABDHMGG.txt ISIL 015), and returns the full
/// <c>FGCPNAACHIK</c> details — which this already does. Only the
/// roomserver/ twin was missing.</summary>
[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 async Task<IActionResult> GrantRole(long roomId, long playerId)
{
var me = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
if (!await CanManageRoomRolesAsync(room, me)) return Forbid();
var role = await ReadClientRoomRoleAsync();
if (role is null) return BadRequest(new { error = "missing_role" });
if (role.Value == 0)
return await ClearRoomAccountRoleAsync(room, playerId);
if (!TryMapClientRoleToRoomRole(role.Value, out var roomRole))
return BadRequest(new { error = "invalid_role", role });
var existing = await db.RoomRoles
.FirstOrDefaultAsync(r => r.RoomId == roomId && r.PlayerId == playerId && r.Role == roomRole);
if (existing is null)
{
await RemoveOtherRoomRolesAsync(roomId, playerId, roomRole);
db.RoomRoles.Add(new RoomRoleEntity
{
RoomId = roomId, PlayerId = playerId, Role = roomRole,
Accepted = true, GrantedByPlayerId = me,
});
}
else
{
existing.Accepted = true;
}
await db.SaveChangesAsync();
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
}
/// <summary>POST <c>rooms/{id}/roles/{playerId}/invite</c> — same
/// as grant but Accepted=false; the target's accept-invite flow
/// flips the flag (separate endpoint, not yet exposed). For now
/// invited rows surface in RoomDetails.InvitedCoOwners etc.
/// <c>NLDBPDCNNCF.KIJDCLINOIC</c> ("invite account to role",
/// NLDBPDCNNCF.txt:7550) is verb 3 = PUT under the roomserver/ prefix.</summary>
[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 async Task<IActionResult> InviteRole(long roomId, long playerId)
{
var me = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
if (!await CanManageRoomRolesAsync(room, me)) return Forbid();
var role = await ReadClientRoomRoleAsync();
if (role is null) return BadRequest(new { error = "missing_role" });
if (role.Value == 0)
return await ClearRoomAccountRoleAsync(room, playerId);
if (!TryMapClientRoleToRoomRole(role.Value, out var roomRole))
return BadRequest(new { error = "invalid_role", role });
var existing = await db.RoomRoles
.FirstOrDefaultAsync(r => r.RoomId == roomId && r.PlayerId == playerId && r.Role == roomRole);
if (existing is null)
{
await RemoveOtherRoomRolesAsync(roomId, playerId, roomRole);
db.RoomRoles.Add(new RoomRoleEntity
{
RoomId = roomId, PlayerId = playerId, Role = roomRole,
Accepted = false, GrantedByPlayerId = me,
});
}
else
{
existing.Accepted = false;
}
await db.SaveChangesAsync();
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
}
private async Task<IActionResult> ClearRoomAccountRoleAsync(RoomEntity room, long playerId)
{
if (room.CreatorPlayerId == playerId)
return BadRequest(new { error = "cannot_change_creator_role" });
var rows = await db.RoomRoles
.Where(r => r.RoomId == room.Id && r.PlayerId == playerId)
.ToListAsync();
if (rows.Count > 0)
db.RoomRoles.RemoveRange(rows);
await db.SaveChangesAsync();
return Ok(await BuildRoomServerDetailsWithRolesAsync(room.Id));
}
private async Task<bool> CanManageRoomRolesAsync(RoomEntity room, long playerId)
{
if (room.CreatorPlayerId == playerId) return true;
return await db.RoomRoles.AnyAsync(r =>
r.RoomId == room.Id
&& r.PlayerId == playerId
&& r.Accepted
&& r.Role == 0);
}
private async Task<int?> ReadClientRoomRoleAsync()
{
int parsed;
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
if (int.TryParse(form["role"].FirstOrDefault(), out parsed)) return parsed;
if (int.TryParse(form["Role"].FirstOrDefault(), out parsed)) return parsed;
}
if (int.TryParse(Request.Query["role"].FirstOrDefault(), out parsed)) return parsed;
if (int.TryParse(Request.Query["Role"].FirstOrDefault(), out parsed)) return parsed;
if (Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true)
{
try
{
var body = await JsonSerializer.DeserializeAsync<GrantRoleRequest>(
Request.Body,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (body?.Role is int bodyRole) return bodyRole;
}
catch (JsonException)
{
return null;
}
}
return null;
}
private async Task RemoveOtherRoomRolesAsync(long roomId, long playerId, int keepRole)
{
var rows = await db.RoomRoles
.Where(r => r.RoomId == roomId && r.PlayerId == playerId && r.Role != keepRole)
.ToListAsync();
if (rows.Count > 0)
db.RoomRoles.RemoveRange(rows);
}
private static bool TryMapClientRoleToRoomRole(int clientRole, out int roomRole)
{
roomRole = clientRole switch
{
30 => 0, // CoOwner
20 => 1, // Moderator
10 => 2, // Host
_ => -1,
};
return roomRole >= 0;
}
private static object BuildRoomRoleGrantWire(RoomRoleEntity role)
=> BuildRoomAccountRoleWire(
role.PlayerId,
ToClientRoomRole(role.Role),
role.Accepted,
role.GrantedByPlayerId);
private static object BuildRoomAccountRoleWire(
long accountId,
int role,
bool accepted = true,
long? lastChangedByAccountId = null)
=> new
{
AccountId = ToClientAccountId(accountId),
Role = accepted ? role : 0,
LastChangedByAccountId = ToClientAccountId(lastChangedByAccountId),
InvitedRole = accepted ? 0 : role,
};
private static int ToClientRoomRole(int roomRole) => roomRole switch
{
0 => 30, // CoOwner
1 => 20, // Moderator
2 => 10, // Host
_ => 0,
};
private static int ToClientAccountId(long accountId)
=> accountId > int.MaxValue
? int.MaxValue
: accountId < int.MinValue
? int.MinValue
: (int)accountId;
private static int? ToClientAccountId(long? accountId)
=> accountId.HasValue ? ToClientAccountId(accountId.Value) : null;
private async Task<object> 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);
}
/// <summary>
/// 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".
/// </summary>
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",
};
}
}