mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
bf0f09ce84
Every rooms-client mutation (roles, invite, accessibility/publish, room
fields, sub-room ops, permissions, playlists) goes through
NLDBPDCNNCF.BDBHCEGKGDC<TResult>, the same helper and instantiation as
"clone room", which reads {success, value, error}. A bare room-details body
has no success, so the client rejected it with the message-less
"Exception of type 'CEMNLBKJABA' was thrown" (seen on co-owner invite)
although the server had applied the change.
RecNetEnvelopeAttribute wraps those handlers: 2xx becomes
{success:true, value, error:""}, refusals become 200
{success:false, value:null, error:<message>}.
4749 lines
220 KiB
C#
4749 lines
220 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,
|
|
SystemNotificationService systemNotifications,
|
|
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<FFCPFGBNLHN></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<GAPJAGDNFEO></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<Room></c></b>; the watch's
|
|
/// <c>EJDCNGBEICB.JDKLBNBIEAH</c> at <c>EJDCNGBEICB.txt:1349,1430</c>
|
|
/// returns <c>IPromise<List<ANNKHNFLMNP>></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<Room></c>),
|
|
/// this one's client contract is a PAGED wrapper object
|
|
/// <c>{ Results, TotalResults }</c> (IDLBPALJJDJ : PagedResult<Room>);
|
|
/// returning a bare array crashes the watch's key-based reader.
|
|
/// Confirmed for 2023 too: <c>NLDBPDCNNCF.MOBJJDBNBMF</c> returns
|
|
/// <c>Task<FFCPFGBNLHN></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/<scene>/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<RoomDataHistoryDTO></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)
|
|
{
|
|
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
|
|
var blobs = await db.RoomDataBlobs
|
|
.Where(b => b.RoomId == roomId
|
|
&& (b.SubRoomId == subRoomId || b.SubRoomId == null))
|
|
// Skip the canonical per-scene blob from the import — the watch
|
|
// is asking for HISTORY, and serving the current `room_<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 = RoomSceneIds.ToWire(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)
|
|
{
|
|
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
|
|
var room = await rooms.GetByIdAsync(roomId);
|
|
if (room is null) return NotFound(new { error = "room_not_found", roomId });
|
|
|
|
var sceneRows = await db.RoomScenes
|
|
.Where(s => s.RoomId == roomId)
|
|
.OrderBy(s => s.OrderIndex)
|
|
.ToListAsync();
|
|
var scene = sceneRows.FirstOrDefault(s => s.OrderIndex == (int)subRoomId)
|
|
?? sceneRows.FirstOrDefault(s => s.StudioSubRoomDataSaveId == saveId);
|
|
if (scene is null) return NotFound(new { error = "subroom_not_found", roomId, subRoomId });
|
|
|
|
return Ok(BuildStudioSaveWire(room, scene, subRoomId, saveId, unityAssetTarget, unityAssetVersion));
|
|
}
|
|
|
|
/// <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)
|
|
{
|
|
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
|
|
var room = await rooms.GetByIdAsync(roomId);
|
|
if (room is null) return NotFound(new { error = "room_not_found", roomId });
|
|
|
|
var scene = await db.RoomScenes
|
|
.Where(s => s.RoomId == roomId && s.OrderIndex == (int)subRoomId)
|
|
.FirstOrDefaultAsync();
|
|
// A single-scene room has no RoomScene row; subRoom 0 is the
|
|
// synthesised "Home" scene.
|
|
if (scene is null && subRoomId != 0)
|
|
return NotFound(new { error = "subroom_not_found", roomId, subRoomId });
|
|
|
|
return Ok(BuildSubRoomWire(room, scene));
|
|
}
|
|
|
|
/// <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)
|
|
{
|
|
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
|
|
var room = await rooms.GetByIdAsync(roomId);
|
|
if (room is null) return NotFound(new { error = "room_not_found", roomId });
|
|
|
|
var scene = await db.RoomScenes
|
|
.Where(s => s.RoomId == roomId && s.OrderIndex == (int)subRoomId)
|
|
.FirstOrDefaultAsync();
|
|
if (scene is null && subRoomId != 0)
|
|
return NotFound(new { error = "subroom_not_found", roomId, subRoomId });
|
|
|
|
var saveId = scene?.StudioSubRoomDataSaveId ?? 0L;
|
|
return Ok(BuildStudioSaveWire(room, scene, subRoomId, saveId, unityAssetTarget, unityAssetVersion));
|
|
}
|
|
|
|
/// <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<SubRoomDataSaveDTO></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)
|
|
{
|
|
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
|
|
var room = await rooms.GetByIdAsync(roomId);
|
|
if (room is null) return NotFound(new { error = "room_not_found", roomId });
|
|
|
|
var scene = await db.RoomScenes
|
|
.Where(s => s.RoomId == roomId && s.OrderIndex == (int)subRoomId)
|
|
.FirstOrDefaultAsync();
|
|
if (scene is null && subRoomId != 0)
|
|
return NotFound(new { error = "subroom_not_found", roomId, subRoomId });
|
|
|
|
var currentSaveId = scene?.StudioSubRoomDataSaveId ?? 0L;
|
|
var saves = new List<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 = RoomSceneIds.ToWire(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 = RoomSceneIds.ToWire(orderIndex),
|
|
RoomId = room.Id,
|
|
Name = scene?.Name ?? "Home",
|
|
DataBlob = dataBlob,
|
|
DataSavedAt = (modified == default ? DateTime.UtcNow : modified)
|
|
.ToString("yyyy-MM-ddTHH:mm:ssZ"),
|
|
IsSandbox = scene?.IsSandbox ?? false,
|
|
MaxPlayers = scene?.MaxPlayers ?? 8,
|
|
Accessibility = room.Accessibility,
|
|
UnitySceneId = scene?.RoomSceneLocationId ?? room.LocationReplicationId,
|
|
UnityAssetId = scene?.StudioUnityAssetId ?? string.Empty,
|
|
SubRoomUnityAssetId = scene?.StudioUnityAssetId ?? string.Empty,
|
|
CurrentSave = new
|
|
{
|
|
SubRoomDataSaveId = scene?.StudioSubRoomDataSaveId ?? 0L,
|
|
SubRoomId = RoomSceneIds.ToWire(orderIndex),
|
|
UnityAssetId = scene?.StudioUnityAssetId ?? string.Empty,
|
|
SubRoomUnityAssetId = scene?.StudioUnityAssetId ?? string.Empty,
|
|
ReferencedUnityAssetIds = Array.Empty<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 = RoomSceneIds.ToWire(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)
|
|
{
|
|
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
|
|
if (string.IsNullOrWhiteSpace(filename))
|
|
return BadRequest(new { error = "missing_filename" });
|
|
|
|
var pid = this.RequireCurrentPlayerId();
|
|
var room = await rooms.GetByIdAsync(roomId);
|
|
if (room is null) return NotFound(new { error = "room_not_found" });
|
|
if (!await CanEditRoomAsync(room, pid)) return Forbid();
|
|
|
|
var blob = await db.RoomDataBlobs
|
|
.FirstOrDefaultAsync(b => b.BlobName == filename && b.RoomId == roomId);
|
|
if (blob is null) return NotFound(new { error = "blob_not_found", filename });
|
|
|
|
var sceneRows = await db.RoomScenes
|
|
.Where(s => s.RoomId == roomId)
|
|
.OrderBy(s => s.OrderIndex)
|
|
.ToListAsync();
|
|
var sceneRow = sceneRows.FirstOrDefault(s => s.OrderIndex == (int)subRoomId);
|
|
if (sceneRow is null) return NotFound(new { error = "subroom_not_found", subRoomId });
|
|
|
|
var restoredAt = DateTime.UtcNow;
|
|
sceneRow.DataBlobName = filename;
|
|
sceneRow.DataModifiedAt = restoredAt;
|
|
|
|
// Only stamp the room-level CurrentDataBlobName when restoring the
|
|
// entry scene (OrderIndex 0). Restoring a non-entry sub-room
|
|
// shouldn't rewrite what the room loads at the front door.
|
|
if (sceneRow.OrderIndex == 0)
|
|
{
|
|
await db.Rooms
|
|
.Where(r => r.Id == roomId)
|
|
.ExecuteUpdateAsync(s => s
|
|
.SetProperty(r => r.CurrentDataBlobName, filename)
|
|
.SetProperty(r => r.UpdatedAt, restoredAt));
|
|
room.CurrentDataBlobName = filename;
|
|
room.UpdatedAt = restoredAt;
|
|
}
|
|
await db.SaveChangesAsync();
|
|
|
|
var pushRoom = room.IsDormRoom
|
|
? CloneWithCreator(room, pid, room.CurrentDataBlobName)
|
|
: CloneWithCreator(room, room.CreatorPlayerId, room.CurrentDataBlobName);
|
|
var roomDetailsPayload = BuildRoomDetails(pushRoom, sceneRows, sceneRow.OrderIndex, filename);
|
|
await notifications.NotifyAsync(pid, PushNotificationId.SubscriptionUpdateRoom, roomDetailsPayload);
|
|
|
|
logger.LogInformation(
|
|
"[rooms-restore] player={PlayerId} room={RoomId} subRoom={SubRoomId} scene={Scene} blob={Blob}",
|
|
pid, roomId, subRoomId, sceneRow.Name, filename);
|
|
|
|
// Watch's response deserializer is RecNetResult<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&name=Y</c> or
|
|
/// <c>?id=1&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,
|
|
})
|
|
.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");
|
|
|
|
// The blob the room actually loads from: sub-room 0's, override and
|
|
// all. `dataBlobName` above is the per-ROOM name, which is the wrong
|
|
// answer whenever a caller passes an override — a dorm's data is
|
|
// per-PLAYER (dorm_p{id}_v*.dat) and arrives that way.
|
|
var primarySubRoom = sceneRows is { Count: > 0 }
|
|
? sceneRows.FirstOrDefault(s => s.OrderIndex == 0) ?? sceneRows[0]
|
|
: null;
|
|
var primarySubRoomDataBlob =
|
|
!string.IsNullOrWhiteSpace(overrideDataBlobName) && (sceneRows?.Count ?? 0) <= 1
|
|
? overrideDataBlobName!
|
|
: primarySubRoom is not null
|
|
? SceneOrSyntheticDataBlobName(room, primarySubRoom.DataBlobName, dataBlobName)
|
|
: dataBlobName;
|
|
|
|
object[] subRooms = sceneRows is { Count: > 0 }
|
|
? sceneRows.Select(s =>
|
|
{
|
|
var dataBlob = !string.IsNullOrWhiteSpace(overrideDataBlobName) && sceneRows.Count == 1
|
|
? overrideDataBlobName
|
|
: SceneOrSyntheticDataBlobName(room, s.DataBlobName, dataBlobName);
|
|
// A scene with a baked Studio asset (non-empty UnityAssetId)
|
|
// must advertise a Studio-era PersistenceVersion on its
|
|
// CurrentSave. The 2023 client gates the baked-Addressables
|
|
// load path on this: a version of 0 (the old default) makes
|
|
// it treat the save as a pre-baked legacy blob and it never
|
|
// fetches unity_assets/{id}/{target}/{version}, so the
|
|
// persistence views reference prefabs that were never loaded
|
|
// ("Expected a prefab, but found none"). 41 matches the real
|
|
// RecRocks export (room.json PersistenceVersion=41).
|
|
var baked = !string.IsNullOrWhiteSpace(s.StudioUnityAssetId);
|
|
var saveVersion = baked ? StudioPersistenceVersion : 0;
|
|
var bakedAssets = BuildStudioBakedAssets(
|
|
room,
|
|
s,
|
|
s.StudioSubRoomDataSaveId ?? 0L,
|
|
unityAssetTarget,
|
|
unityAssetVersion);
|
|
var unityAsset = bakedAssets.FirstOrDefault();
|
|
var unityAssetParent = BuildStudioUnityAsset(room, s, bakedAssets);
|
|
return (object)new
|
|
{
|
|
ReplicationId = s.RoomSceneLocationId,
|
|
PersistenceVersion = saveVersion,
|
|
SupportsJoinInProgress = true,
|
|
UseLevelBasedMatchmaking = false,
|
|
UseAgeBasedMatchmaking = false,
|
|
UseRecRoyaleMatchmaking = false,
|
|
SubRoomId = RoomSceneIds.ToWire(s.OrderIndex),
|
|
RoomId = room.Id,
|
|
Name = s.Name,
|
|
UnityAssetId = s.StudioUnityAssetId,
|
|
UnityAsset = unityAsset?.Filename ?? string.Empty,
|
|
UnityAssetHash = string.Empty,
|
|
DataBlob = dataBlob,
|
|
DataBlobHash = (string?)null,
|
|
DataSavedAt = (s.DataModifiedAt == default ? DateTime.UtcNow : s.DataModifiedAt)
|
|
.ToString("yyyy-MM-ddTHH:mm:ssZ"),
|
|
IsSandbox = s.IsSandbox,
|
|
MaxPlayers = s.MaxPlayers,
|
|
Accessibility = room.Accessibility,
|
|
UnitySceneId = s.RoomSceneLocationId,
|
|
};
|
|
}).ToArray()
|
|
: new object[]
|
|
{
|
|
new
|
|
{
|
|
ReplicationId = room.LocationReplicationId,
|
|
PersistenceVersion = 0,
|
|
SupportsJoinInProgress = true,
|
|
UseLevelBasedMatchmaking = false,
|
|
UseAgeBasedMatchmaking = false,
|
|
UseRecRoyaleMatchmaking = false,
|
|
SubRoomId = RoomSceneIds.ToWire(0),
|
|
RoomId = room.Id,
|
|
Name = "Home",
|
|
UnityAssetId = string.Empty,
|
|
UnityAsset = string.Empty,
|
|
UnityAssetHash = string.Empty,
|
|
// Same rule as the populated branch: honour the override,
|
|
// so a dorm with no scene rows still points at that
|
|
// player's own save rather than a per-room name.
|
|
DataBlob = primarySubRoomDataBlob,
|
|
DataBlobHash = (string?)null,
|
|
DataSavedAt = updatedAt,
|
|
IsSandbox = false,
|
|
MaxPlayers = 8,
|
|
Accessibility = room.Accessibility,
|
|
UnitySceneId = room.LocationReplicationId,
|
|
},
|
|
};
|
|
|
|
// The creator is Creator (255), never CoOwner (30): the client reads
|
|
// the local player's role from this list and only lets a player edit
|
|
// roles strictly below their own (see RoomRoleRules).
|
|
var wireRoles = RoomRoleRules.BuildWires(room, roleList);
|
|
|
|
var tags = string.IsNullOrEmpty(room.TagsCsv)
|
|
? Array.Empty<object>()
|
|
: room.TagsCsv
|
|
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
|
.Select(t => (object)new
|
|
{
|
|
Type = RoomTagTypeForWire(t),
|
|
Tag = t,
|
|
})
|
|
.ToArray();
|
|
|
|
return new
|
|
{
|
|
RoomId = room.Id,
|
|
Name = room.Name,
|
|
Description = room.Description,
|
|
ImageName = room.ImageName,
|
|
WarningMask = room.RoomWarningMask,
|
|
CustomWarning = room.CustomRoomWarning,
|
|
CreatorAccountId = room.CreatorPlayerId,
|
|
State = room.State,
|
|
Accessibility = room.Accessibility,
|
|
SupportsLevelVoting = room.SupportsLevelVoting,
|
|
// Player rooms are never RRO on the wire (see RoomService.WireIsRRO):
|
|
// the client refuses publish and room settings for an RRO.
|
|
IsRRO = RoomService.WireIsRRO(room),
|
|
SupportsScreens = room.SupportsScreens,
|
|
SupportsWalkVR = room.SupportsWalkVR,
|
|
SupportsTeleportVR = room.SupportsTeleportVR,
|
|
SupportsVRLow = room.SupportsVRLow,
|
|
SupportsQuest2 = true,
|
|
SupportsMobile = room.SupportsMobile,
|
|
SupportsJuniors = room.AllowsJuniors,
|
|
MinLevel = room.MinLevel,
|
|
CreatedAt = (room.CreatedAt == default ? DateTime.UtcNow : room.CreatedAt)
|
|
.ToString("yyyy-MM-ddTHH:mm:ssZ"),
|
|
Stats = new
|
|
{
|
|
CheerCount = room.CheerCount,
|
|
FavoriteCount = room.FavoriteCount,
|
|
VisitorCount = room.VisitorCount,
|
|
VisitCount = room.VisitCount,
|
|
},
|
|
IsDorm = room.IsDormRoom,
|
|
CloningAllowed = room.CloningAllowed,
|
|
DisableMicAutoMute = room.DisableMicAutoMute,
|
|
DisableRoomComments = false,
|
|
EncryptVoiceChat = false,
|
|
LoadScreenLocked = false,
|
|
PersistenceVersion = room.IsStudioRoom ? StudioPersistenceVersion : 0,
|
|
IsDeveloperOwned = room.IsStudioRoom || HasRoomTag(room, "developer"),
|
|
MaxPlayerCalculationMode = room.MaxPlayerCalculationMode,
|
|
SubRooms = subRooms,
|
|
Roles = wireRoles,
|
|
LoadScreens = JsonArrayOrEmpty(room.LoadScreensJson),
|
|
PromoImages = JsonStringArrayOrEmpty(room.PromoImagesJson),
|
|
PromoExternalContent = JsonArrayOrEmpty(room.PromoExternalContentJson),
|
|
Tags = tags,
|
|
// The client's room-details reader (GLEGPFFPDBE) registers these
|
|
// five alongside the rest. A registered slot wants a value, and
|
|
// leaving one absent is not the same as sending null — it is what
|
|
// makes the reader throw mid-deserialise on a response the server
|
|
// already answered 200 to. Kept last so the additions are visible
|
|
// against the reader's own list; key ORDER does not matter to a
|
|
// name-keyed reader, presence does.
|
|
// DataBlob / DataBlobHash are deliberately NOT sent at room level,
|
|
// even though the reader registers them.
|
|
//
|
|
// The room a player actually loads is chosen per INSTANCE: the
|
|
// matchmake response carries the blob (a dorm's is per-player,
|
|
// dorm_p{id}_v*.dat), and each sub-room carries its own. Adding a
|
|
// room-level name on top gave the client a second, room-scoped
|
|
// answer for the same question, and it loaded that one — a file
|
|
// that for a dorm was never written — then rejected the room with
|
|
// "the data in the room you are trying to load is corrupt".
|
|
//
|
|
// A registered slot does NOT oblige us to fill it: Newtonsoft
|
|
// leaves an absent key at its default, which is the state every
|
|
// room loaded fine in. Sending it is what broke the dorm, so the
|
|
// absence here is the fix, not an omission to tidy up later.
|
|
// The room-level cap is the room's own advertised capacity, which is
|
|
// what RoomEntity.MaxCapacity exists for and what matchmaking hands
|
|
// to RoomInstance.MaxCapacity. Each sub-room carries its own
|
|
// MaxPlayers in the SubRooms array — they are separate settings, so
|
|
// this must not be read off sub-room 0.
|
|
MaxPlayers = room.MaxCapacity,
|
|
// ToxMod is Rec Room's hosted voice moderation. There is no such
|
|
// service here, so it is off — the client only uses this to decide
|
|
// whether to show the indicator.
|
|
ToxmodEnabled = false,
|
|
RankingContext = (object?)null,
|
|
};
|
|
}
|
|
|
|
private static int RoomTagTypeForWire(string tag)
|
|
{
|
|
if (string.Equals(tag, "beta", StringComparison.OrdinalIgnoreCase)) return 1;
|
|
return tag.ToLowerInvariant() switch
|
|
{
|
|
"rrstudio" or "community" or "screen" or "walkvr" or "teleportvr" or "junior" or "pickup" => 2,
|
|
_ => 0,
|
|
};
|
|
}
|
|
|
|
private static bool HasRoomTag(RoomEntity room, string tag)
|
|
{
|
|
return !string.IsNullOrWhiteSpace(room.TagsCsv) &&
|
|
room.TagsCsv
|
|
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
|
.Any(t => string.Equals(t, tag, StringComparison.OrdinalIgnoreCase));
|
|
}
|
|
|
|
private static JsonElement[] JsonArrayOrEmpty(string? json)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(json)) return Array.Empty<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<List<System.Int64>></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><GetRoomsShowcase>b__0</c> is typed
|
|
/// <c>Task<IReadOnlyList<Int64>></c>
|
|
/// (IBEOONPEELF_NestedType_HHDHJPIJBJB.txt:14) and the outer
|
|
/// <c>IBEOONPEELF.EJPNHLIJNPM</c> resolves those ids through the room cache
|
|
/// to <c>FGLDKEJLAKB<IReadOnlyList<NEMINAEBALC>></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<ILKMFMCOPPO></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<List<String>></c>
|
|
/// (typeof load at Matchmaking.txt:21327, continuation delegate
|
|
/// <c>Action<List<String>></c> at :21315) and hands the payload to
|
|
/// <c><RequestRoomRestrictions>b__2(List<String> rooms)</c>
|
|
/// (Matchmaking_NestedType_PMNEOAMKLJL.txt:14), which forwards it to
|
|
/// <c>Matchmaking.IINEGHJJBAN(IEnumerable<String>, IOCOJFJEDCM)</c>
|
|
/// (:20758) where the strings become the KEYS of the restriction map
|
|
/// (<c>List<IOCOJFJEDCM></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<FGCPNAACHIK></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]
|
|
[RecNetEnvelope]
|
|
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.
|
|
// Room settings are creator / co-owner only; moderators and
|
|
// hosts moderate players, they don't edit the room.
|
|
if (!await CanEditRoomAsync(room, me)) return Forbid();
|
|
}
|
|
|
|
// Uniqueness — Rooms.Name has a unique index (DorkNetDbContext.cs:118).
|
|
var collision = await db.Rooms.AnyAsync(r => r.Id != roomId && r.Name == newName);
|
|
if (collision) return Conflict(new { Result = 5, error = "name_taken" });
|
|
|
|
room.Name = newName;
|
|
room.UpdatedAt = DateTime.UtcNow;
|
|
await db.SaveChangesAsync();
|
|
logger.LogInformation("[room-rename] room={Room} by={By} → '{Name}'", roomId, me, newName);
|
|
|
|
// Full room details, not the old minimal {Result,RoomId,Name,…} object:
|
|
// the 2023 issuing method returns Task<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<FGCPNAACHIK></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]
|
|
[RecNetEnvelope]
|
|
public async Task<IActionResult> RenameSubroom(
|
|
long roomId,
|
|
long subRoomId,
|
|
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] RenameBody body)
|
|
{
|
|
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
|
|
var newName = body?.Name;
|
|
if (string.IsNullOrWhiteSpace(newName))
|
|
return BadRequest(new { Result = 1, error = "empty_name" });
|
|
newName = newName.Trim();
|
|
if (newName.Length is < 1 or > 40)
|
|
return BadRequest(new { Result = 1, error = "invalid_length", length = newName.Length });
|
|
|
|
var me = this.RequireCurrentPlayerId();
|
|
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
|
if (room is null) return NotFound(new { Result = 4, error = "room_not_found" });
|
|
if (room.CreatorPlayerId != me)
|
|
{
|
|
// Room settings are creator / co-owner only; moderators and
|
|
// hosts moderate players, they don't edit the room.
|
|
if (!await CanEditRoomAsync(room, me)) return Forbid();
|
|
}
|
|
|
|
var scene = await db.RoomScenes.FirstOrDefaultAsync(s =>
|
|
s.RoomId == roomId && s.OrderIndex == subRoomId);
|
|
if (scene is null) return NotFound(new { Result = 4, error = "subroom_not_found", subRoomId });
|
|
|
|
// Uniqueness within this room — same scene name twice would
|
|
// make /goto/room/X/Y ambiguous against the RoomScenes index
|
|
// on (RoomId, Name) at DorkNetDbContext.cs:153.
|
|
var collision = await db.RoomScenes.AnyAsync(s =>
|
|
s.RoomId == roomId && s.OrderIndex != subRoomId && s.Name == newName);
|
|
if (collision) return Conflict(new { Result = 5, error = "subroom_name_taken" });
|
|
|
|
scene.Name = newName;
|
|
scene.DataModifiedAt = DateTime.UtcNow;
|
|
await db.SaveChangesAsync();
|
|
logger.LogInformation("[subroom-rename] room={Room} sub={Sub} by={By} → '{Name}'", roomId, subRoomId, me, newName);
|
|
|
|
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
|
|
}
|
|
|
|
/// <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<HJKAOMOICJG></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<List<MKAMHOIHOJK>></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<MKAMHOIHOJK></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<Int64></c> — JUST the playlist IDs, not
|
|
/// the full playlist objects. Verified against
|
|
/// <c>FPNPBJBCMKB.EPPPMHMMGME</c> (signature
|
|
/// <c>IPromise<List<long>></c>) and the call site at
|
|
/// <c>BrowseRoomsScreen.txt:2806</c> which constructs an
|
|
/// <c>Action<List<Int64>></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<KLCOGEIGEBJ></c>
|
|
/// (the playlist's member rooms, full Room dicts).
|
|
/// 3. Reads required key <c>Tags</c> — <c>List<DPHPFLGAICI></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,
|
|
})
|
|
.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<HJKAOMOICJG></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<MKAMHOIHOJK></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<List<MKAMHOIHOJK>></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}&skip={skip}&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}&skip={1}&take={2}</c> /
|
|
/// <c>hot_rooms/{0}&skip={1}&take={2}</c> — the paging pairs
|
|
/// land inside the final path segment, not the query string, so
|
|
/// route params capture "pizza&skip=0&take=20" verbatim.
|
|
/// 2020 URLs have no '&' and pass through unchanged.</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<FGCPNAACHIK></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]
|
|
[RecNetEnvelope]
|
|
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 (!await CanEditRoomAsync(room, pid)) return Forbid();
|
|
|
|
var name = (req.Name ?? string.Empty).Trim();
|
|
if (name.Length == 0) name = "NewScene";
|
|
var collision = await db.RoomScenes.AnyAsync(s => s.RoomId == roomId && s.Name == name);
|
|
if (collision) return Conflict(new { error = "name_taken" });
|
|
|
|
var nextOrder = (await db.RoomScenes
|
|
.Where(s => s.RoomId == roomId)
|
|
.Select(s => (int?)s.OrderIndex).MaxAsync() ?? -1) + 1;
|
|
var scene = new RoomSceneEntity
|
|
{
|
|
RoomId = roomId,
|
|
OrderIndex = nextOrder,
|
|
Name = name,
|
|
RoomSceneLocationId = req.RoomSceneLocationId
|
|
?? "a75f7547-79eb-47c6-8986-6767abcb4f92",
|
|
MaxPlayers = req.MaxPlayers ?? 8,
|
|
IsSandbox = req.IsSandbox ?? false,
|
|
};
|
|
db.RoomScenes.Add(scene);
|
|
await db.SaveChangesAsync();
|
|
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
|
|
}
|
|
|
|
/// <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<FGCPNAACHIK></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]
|
|
[RecNetEnvelope]
|
|
public async Task<IActionResult> DeleteSubRoom(long roomId, long subRoomId)
|
|
{
|
|
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
|
|
var pid = this.RequireCurrentPlayerId();
|
|
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
|
if (room is null) return NotFound();
|
|
if (!await CanEditRoomAsync(room, pid)) return Forbid();
|
|
var scene = await db.RoomScenes
|
|
.FirstOrDefaultAsync(s => s.RoomId == roomId && s.OrderIndex == subRoomId);
|
|
if (scene is null) return NotFound();
|
|
if (scene.OrderIndex == 0) return BadRequest(new { error = "cannot_delete_entry_scene" });
|
|
db.RoomScenes.Remove(scene);
|
|
await db.SaveChangesAsync();
|
|
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
|
|
}
|
|
|
|
// Subroom mutation DTOs. Property names mirror the LOWERCASE form keys the
|
|
// 2023 client actually sends (FormOrJsonModelBinder matches case-insensitively):
|
|
// maxPlayers NLDBPDCNNCF_NestedType_BLNOGFGHIIF.txt ISIL 020
|
|
// accessibility NLDBPDCNNCF_NestedType_GNCLJBNJEKP.txt ISIL 020
|
|
// name/accessibility/maxPlayers ..._JEEACJHABAF.txt ISIL 021/035/048
|
|
// newRoomId NLDBPDCNNCF_NestedType_COIOGADEBKJ.txt ISIL 020
|
|
// The older {Value:…} DTOs matched no client key at all, and being bound
|
|
// [FromBody] they 415'd the form body before the handler ran.
|
|
public sealed class SubRoomMaxPlayersRequest { public int? MaxPlayers { get; set; } }
|
|
|
|
// Accessibility is typed string, not int, on purpose — the two client call
|
|
// sites disagree about how they stringify it. The standalone
|
|
// "change subroom accessibility" closure boxes the value as
|
|
// typeof(JHAOFLCJNAL) (GNCLJBNJEKP.txt ISIL 015), and the form writer's
|
|
// object overload falls through to a virtual ToString() for anything that
|
|
// isn't String/DateTime (BNDIAONDFFF.txt ISIL 107-110) — i.e. an enum
|
|
// MEMBER NAME goes on the wire. The bulk "modify" closure boxes the very
|
|
// same value as typeof(System.Int32) (JEEACJHABAF.txt ISIL 031) and sends a
|
|
// number. ParseSubRoomAccessibility below accepts both spellings.
|
|
public sealed class SubRoomAccessibilityRequest { public string? Accessibility { get; set; } }
|
|
|
|
public sealed class SubRoomModifyRequest
|
|
{
|
|
public string? Name { get; set; }
|
|
public string? Accessibility { get; set; }
|
|
public int? MaxPlayers { get; set; }
|
|
public bool? IsSandbox { get; set; }
|
|
public bool? CanMatchmakeInto { get; set; }
|
|
public string? RoomSceneLocationId { get; set; }
|
|
}
|
|
|
|
public sealed class SubRoomMoveRequest
|
|
{
|
|
/// <summary>Target ROOM for the scene — the 2023 "move subroom" flow
|
|
/// re-parents a scene into another room. Nullable<Int64> on the
|
|
/// wire (NLDBPDCNNCF.MGGALMIAMBG, NLDBPDCNNCF.txt:9659).</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 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]
|
|
[RecNetEnvelope]
|
|
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<FGCPNAACHIK></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]
|
|
[RecNetEnvelope]
|
|
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> — "change
|
|
/// subroom permissions", the role-permission matrix. Client:
|
|
/// <c>NLDBPDCNNCF.AAMEOMHKPHF(long, long, IReadOnlyList<BNLPBMJJOMM>)</c>
|
|
/// (NLDBPDCNNCF.txt:9198, verb 3, same typed helper as every other room
|
|
/// mutation, so the response is the full <c>FGCPNAACHIK</c> details).
|
|
/// Unlike its siblings the body is NOT form-encoded: the closure calls
|
|
/// <c>BNDIAONDFFF.FJLLPHFOOJJ(String)</c> (CCNOMNNHNNG.txt ISIL 009), a raw
|
|
/// JSON array of cells. The editor sends one changed cell per call
|
|
/// (OMJKHJLFOCO.AFHJLBBFAGP.<LocalRequestModifyRolePermission>g__ChangeAndRefresh
|
|
/// builds a one-element BNLPBMJJOMM[]), so each cell is upserted; cells not
|
|
/// sent are left alone.
|
|
///
|
|
/// <para>Read back through <c>GET photon_access_token</c> (its
|
|
/// <c>Permissions</c> list), and pushed to everyone in that sub-room as
|
|
/// the <c>PhotonAccessToken</c> hub message so the change takes effect
|
|
/// without a rejoin.</para>
|
|
///
|
|
/// <para>Authorization mirrors the client's own rule
|
|
/// (OMJKHJLFOCO.DIIMLMKEJLC): the creator may edit every role, including
|
|
/// Co-Owner and Creator; a co-owner only the roles below their own;
|
|
/// nobody else may edit (<see cref="RoomRoleRules.CanEditRolePermissions"/>).</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]
|
|
[RecNetEnvelope]
|
|
public async Task<IActionResult> SubRoomPermissions(long roomId, long subRoomId)
|
|
{
|
|
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
|
|
var pid = this.RequireCurrentPlayerId();
|
|
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
|
if (room is null) return NotFound();
|
|
var roleRows = await db.RoomRoles.Where(r => r.RoomId == roomId).ToListAsync();
|
|
if (!RoomRoleRules.CanEditRoom(room, roleRows, pid)) return Forbid();
|
|
|
|
string body;
|
|
using (var reader = new StreamReader(Request.Body))
|
|
body = await reader.ReadToEndAsync();
|
|
var cells = RoomPermissionMatrix.Parse(body);
|
|
if (cells is null) return BadRequest(new { error = "invalid_permissions_body" });
|
|
|
|
var denied = cells.Where(c => !RoomRoleRules.CanEditRolePermissions(room, roleRows, pid, c.Role)).ToList();
|
|
if (denied.Count > 0)
|
|
{
|
|
logger.LogWarning(
|
|
"[subroom-permissions] denied room={Room} sub={Sub} by={By} roles={Roles}",
|
|
roomId, subRoomId, pid, string.Join(",", denied.Select(d => d.Role).Distinct()));
|
|
return Forbid();
|
|
}
|
|
|
|
var scene = await GetOrCreateSceneForMutationAsync(room, subRoomId);
|
|
if (scene is null) return NotFound();
|
|
|
|
var existing = await db.RoomRolePermissions
|
|
.Where(p => p.RoomId == roomId && p.SubRoomId == subRoomId)
|
|
.ToListAsync();
|
|
var now = DateTime.UtcNow;
|
|
foreach (var cell in cells)
|
|
{
|
|
var row = existing.FirstOrDefault(p =>
|
|
p.Role == cell.Role
|
|
&& string.Equals(p.Permission, cell.Permission, StringComparison.OrdinalIgnoreCase));
|
|
if (row is null)
|
|
{
|
|
row = new RoomRolePermissionEntity
|
|
{
|
|
RoomId = roomId,
|
|
SubRoomId = subRoomId,
|
|
Role = cell.Role,
|
|
Permission = cell.Permission,
|
|
};
|
|
db.RoomRolePermissions.Add(row);
|
|
existing.Add(row);
|
|
}
|
|
row.Type = cell.Type;
|
|
row.Override = cell.Override;
|
|
row.Value = cell.Value;
|
|
row.UpdatedByPlayerId = pid;
|
|
row.UpdatedAt = now;
|
|
}
|
|
scene.DataModifiedAt = now;
|
|
await db.SaveChangesAsync();
|
|
|
|
logger.LogInformation(
|
|
"[subroom-permissions] room={Room} sub={Sub} by={By} cells={Count}",
|
|
roomId, subRoomId, pid, cells.Count);
|
|
|
|
// Everyone in that sub-room gets the new matrix; the client's
|
|
// PhotonAccessToken handler re-initialises its permissions from it.
|
|
var matrix = await RoomPermissionMatrix.LoadAsync(db, roomId, subRoomId);
|
|
foreach (var (instance, players) in presence.EnumerateActiveInstances(roomId, includePrivate: true))
|
|
{
|
|
if (instance.SubRoomId != subRoomId) continue;
|
|
var tokenBody = RoomPermissionMatrix.AccessTokenBody(instance.RoomInstanceId, matrix);
|
|
foreach (var other in players)
|
|
await notifications.NotifyTypedAsync(other, "PhotonAccessToken", tokenBody);
|
|
}
|
|
|
|
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
|
|
}
|
|
|
|
// JHAOFLCJNAL member values. The numeric leg is verified (the "modify"
|
|
// closure boxes the same value as System.Int32 — JEEACJHABAF.txt ISIL 031);
|
|
// the NAMES are inferred from the room-accessibility enum in the nearest
|
|
// dump with real names (RecRoom-2023.06.21 dump.cs:1293997-1294004
|
|
// MKDFFJGGKDL { Private = 0, Public = 1, Unlisted = 2 }), which is the enum
|
|
// Enum.ToString() would spell out on the standalone accessibility call.
|
|
private const int AccessibilityPrivate = 0;
|
|
|
|
private static int? ParseSubRoomAccessibility(string? raw)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(raw)) return null;
|
|
var value = raw.Trim();
|
|
if (int.TryParse(value, out var numeric)) return numeric;
|
|
if (value.StartsWith("priv", StringComparison.OrdinalIgnoreCase)) return 0;
|
|
if (value.StartsWith("pub", StringComparison.OrdinalIgnoreCase)) return 1;
|
|
if (value.StartsWith("unlist", StringComparison.OrdinalIgnoreCase)) return 2;
|
|
return null;
|
|
}
|
|
|
|
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/publish_save")]
|
|
[HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/publish_save")]
|
|
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/publish_save")]
|
|
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/publish_save")]
|
|
[Authorize]
|
|
[RecNetEnvelope]
|
|
public async Task<IActionResult> PublishSubRoomSave(long roomId, long subRoomId)
|
|
{
|
|
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
|
|
var pid = this.RequireCurrentPlayerId();
|
|
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
|
if (room is null) return NotFound();
|
|
if (!await CanEditRoomAsync(room, pid)) return Forbid();
|
|
|
|
var scene = await GetOrCreateSceneForMutationAsync(room, subRoomId);
|
|
if (scene is null) return NotFound();
|
|
|
|
var request = await ReadPublishSubRoomSaveRequestAsync();
|
|
var saveId = request.SubRoomDataSaveId ?? request.SaveId ?? request.StagedSubRoomDataSaveId;
|
|
var dataBlob = request.DataBlobName ?? request.DataBlob ?? request.Filename;
|
|
|
|
if (!string.IsNullOrWhiteSpace(dataBlob))
|
|
{
|
|
var blobExists = await db.RoomDataBlobs.AnyAsync(b => b.RoomId == roomId && b.BlobName == dataBlob);
|
|
if (!blobExists) return NotFound(new { error = "blob_not_found", dataBlob });
|
|
scene.DataBlobName = dataBlob;
|
|
if (scene.OrderIndex == 0) room.CurrentDataBlobName = dataBlob;
|
|
}
|
|
|
|
if (saveId is > 0)
|
|
scene.StudioSubRoomDataSaveId = saveId.Value;
|
|
|
|
scene.DataModifiedAt = DateTime.UtcNow;
|
|
room.UpdatedAt = scene.DataModifiedAt;
|
|
await db.SaveChangesAsync();
|
|
// "restore subroom save" returns Task<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]
|
|
[RecNetEnvelope]
|
|
public async Task<IActionResult> CloneSubRoom(long roomId, long subRoomId)
|
|
{
|
|
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
|
|
var pid = this.RequireCurrentPlayerId();
|
|
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
|
if (room is null) return NotFound();
|
|
if (!await CanEditRoomAsync(room, pid)) return Forbid();
|
|
var src = await db.RoomScenes
|
|
.FirstOrDefaultAsync(s => s.RoomId == roomId && s.OrderIndex == subRoomId);
|
|
if (src is null) return NotFound();
|
|
|
|
var baseName = $"{src.Name} Copy";
|
|
var newName = baseName;
|
|
int suffix = 1;
|
|
while (await db.RoomScenes.AnyAsync(s => s.RoomId == roomId && s.Name == newName))
|
|
{
|
|
suffix += 1;
|
|
newName = $"{baseName} {suffix}";
|
|
}
|
|
var nextOrder = (await db.RoomScenes.Where(s => s.RoomId == roomId)
|
|
.Select(s => (int?)s.OrderIndex).MaxAsync() ?? -1) + 1;
|
|
var clone = new RoomSceneEntity
|
|
{
|
|
RoomId = roomId,
|
|
OrderIndex = nextOrder,
|
|
Name = newName,
|
|
RoomSceneLocationId = src.RoomSceneLocationId,
|
|
DataBlobName = src.DataBlobName,
|
|
MaxPlayers = src.MaxPlayers,
|
|
IsSandbox = src.IsSandbox,
|
|
CanMatchmakeInto = src.CanMatchmakeInto,
|
|
};
|
|
db.RoomScenes.Add(clone);
|
|
await db.SaveChangesAsync();
|
|
// Task<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]
|
|
[RecNetEnvelope]
|
|
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<Int64> 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<Int64></c>) and returns
|
|
/// <c>Task<FGCPNAACHIK></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]
|
|
[RecNetEnvelope]
|
|
public async Task<IActionResult> MoveSubRoom(
|
|
long roomId,
|
|
long subRoomId,
|
|
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] SubRoomMoveRequest req)
|
|
{
|
|
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
|
|
var pid = this.RequireCurrentPlayerId();
|
|
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
|
if (room is null) return NotFound();
|
|
if (room.CreatorPlayerId != pid) return Forbid();
|
|
var scenes = await db.RoomScenes
|
|
.Where(s => s.RoomId == roomId).OrderBy(s => s.OrderIndex).ToListAsync();
|
|
var moving = scenes.FirstOrDefault(s => s.OrderIndex == subRoomId);
|
|
if (moving is null) return NotFound();
|
|
|
|
// `newRoomId` is Nullable<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)
|
|
{
|
|
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
|
|
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<FGCPNAACHIK></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)
|
|
{
|
|
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
|
|
var pid = this.RequireCurrentPlayerId();
|
|
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
|
if (room is null) return NotFound();
|
|
if (!await CanEditRoomAsync(room, pid)) return Forbid();
|
|
var scene = await GetOrCreateSceneForMutationAsync(room, subRoomId);
|
|
if (scene is null) return NotFound();
|
|
mutator(scene);
|
|
scene.DataModifiedAt = DateTime.UtcNow;
|
|
await db.SaveChangesAsync();
|
|
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
|
|
}
|
|
|
|
private async Task<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<List<EFHPLDPNGIM>></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();
|
|
return Ok(RoomRoleRules.BuildWires(room, rows));
|
|
}
|
|
|
|
private static string CurrentOrSyntheticDataBlobName(RoomEntity room)
|
|
{
|
|
if (room.IsDormRoom)
|
|
return RoomService.ResolveWireRoomDataBlobName(room.Id, room.CurrentDataBlobName);
|
|
|
|
if (RoomService.IsBakedOriginalRoom(room) && string.IsNullOrWhiteSpace(room.CurrentDataBlobName))
|
|
return string.Empty;
|
|
|
|
return !string.IsNullOrWhiteSpace(room.CurrentDataBlobName)
|
|
? room.CurrentDataBlobName
|
|
: RoomService.SyntheticDefaultRoomDataBlobName(room.Id);
|
|
}
|
|
|
|
private static string SceneOrSyntheticDataBlobName(RoomEntity room, string? sceneBlobName, string fallbackBlobName)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(sceneBlobName) &&
|
|
!(room.IsDormRoom && RoomService.IsLegacySyntheticDefaultRoomDataBlobName(room.Id, sceneBlobName)))
|
|
return sceneBlobName;
|
|
|
|
return fallbackBlobName;
|
|
}
|
|
|
|
private sealed record StudioBundleInfo(long SaveId, int Target, int Version, string Filename);
|
|
|
|
private sealed record BakedUnityAssetWire(
|
|
string UnityAssetId,
|
|
long CreatedByAccountId,
|
|
string Filename,
|
|
int Target,
|
|
int Version,
|
|
string Hash);
|
|
|
|
private sealed record UnityAssetWire(
|
|
string UnityAssetId,
|
|
long CreatedByAccountId,
|
|
string Filename,
|
|
BakedUnityAssetWire[] BakedUnityAssets);
|
|
|
|
private static BakedUnityAssetWire[] BuildStudioBakedAssets(
|
|
RoomEntity room,
|
|
RoomSceneEntity? scene,
|
|
long saveId,
|
|
int? unityAssetTarget,
|
|
int? unityAssetVersion)
|
|
{
|
|
var bundles = StudioBundlesForScene(scene, saveId);
|
|
if (unityAssetTarget is int target)
|
|
bundles = bundles.Where(b => b.Target == target).ToList();
|
|
if (unityAssetVersion is int requestedVersion && bundles.Any(b => b.Version == requestedVersion))
|
|
bundles = bundles.Where(b => b.Version == requestedVersion).ToList();
|
|
|
|
var unityAssetId = scene?.StudioUnityAssetId ?? string.Empty;
|
|
return bundles
|
|
.OrderBy(b => b.Target)
|
|
.ThenByDescending(b => b.Version)
|
|
.Select(b => new BakedUnityAssetWire(
|
|
unityAssetId,
|
|
room.CreatorPlayerId,
|
|
b.Filename,
|
|
b.Target,
|
|
b.Version,
|
|
string.Empty))
|
|
.ToArray();
|
|
}
|
|
|
|
private static UnityAssetWire? BuildStudioUnityAsset(
|
|
RoomEntity room,
|
|
RoomSceneEntity? scene,
|
|
BakedUnityAssetWire[] bakedAssets)
|
|
{
|
|
var unityAssetId = scene?.StudioUnityAssetId ?? string.Empty;
|
|
if (string.IsNullOrWhiteSpace(unityAssetId))
|
|
return null;
|
|
|
|
var filename = bakedAssets.FirstOrDefault()?.Filename ?? string.Empty;
|
|
return new UnityAssetWire(unityAssetId, room.CreatorPlayerId, filename, bakedAssets);
|
|
}
|
|
|
|
private static List<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<EFHPLDPNGIM></c>,
|
|
/// NLDBPDCNNCF.txt:2192). roomserver/ twin required, same as the list
|
|
/// above. The creator answers as Creator (255).</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();
|
|
var rows = await db.RoomRoles
|
|
.Where(r => r.RoomId == roomId && r.PlayerId == playerId)
|
|
.ToListAsync();
|
|
var wire = RoomRoleRules.BuildWires(room, rows)
|
|
.FirstOrDefault(w => w.AccountId == RoomRoleRules.ToClientAccountId(playerId));
|
|
if (wire is null) return NotFound();
|
|
return Ok(wire);
|
|
}
|
|
|
|
/// <summary>PUT <c>rooms/{id}/roles/{playerId}</c> — "change room account
|
|
/// role" (<c>NLDBPDCNNCF.LMIEIJMCEAB</c>, NLDBPDCNNCF.txt:7516, verb 3 =
|
|
/// PUT, single form key <c>role</c> boxed as Int32 —
|
|
/// NLDBPDCNNCF_NestedType_BFKNABDHMGG.txt ISIL 015-024), response the full
|
|
/// <c>FGCPNAACHIK</c> details. Three callers:
|
|
/// <list type="bullet">
|
|
/// <item>the owner's role button (AddRemovePlayerRoomRoleConditionalButton
|
|
/// .EEGJBELCBDI): Host/Moderator are set directly, and "Remove" sends
|
|
/// role 0;</item>
|
|
/// <item>the invitee accepting a co-owner invite (message Type 62,
|
|
/// KPFKPIKDBFJ.ELKAEBMMIGL / KLNLKDJJDLE Accept): their OWN account id
|
|
/// with role 30;</item>
|
|
/// <item>a co-owner rejecting a direct add (Type 60, COJCABEJDFN Reject):
|
|
/// their own id with role 0.</item>
|
|
/// </list>
|
|
/// Authorization is <see cref="RoomRoleRules.CheckChange"/>.</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]
|
|
[RecNetEnvelope]
|
|
public Task<IActionResult> GrantRole(long roomId, long playerId)
|
|
=> ChangeRoomAccountRoleAsync(roomId, playerId, isInvite: false);
|
|
|
|
/// <summary>PUT <c>rooms/{id}/roles/{playerId}/invite</c> — "invite account
|
|
/// to role" (<c>NLDBPDCNNCF.KIJDCLINOIC</c>, NLDBPDCNNCF.txt:7686, verb 3,
|
|
/// form key <c>role</c> as Int32 — NLDBPDCNNCF_NestedType_ENHMMCAOING.txt).
|
|
/// Only Co-Owner goes through an invite in this client: the owner's button
|
|
/// sends role 30 here, the invitee is told with a Type 62
|
|
/// (RoomCoOwnerInvited) message carrying RoomId, and either accepts with
|
|
/// <c>PUT roles/{me}</c> role 30 or rejects by sending role 0 back to THIS
|
|
/// route on their own id (KPFKPIKDBFJ.OGONAKHNMGN, KLNLKDJJDLE Reject),
|
|
/// which clears the pending invite but leaves any role they already
|
|
/// hold.</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]
|
|
[RecNetEnvelope]
|
|
public Task<IActionResult> InviteRole(long roomId, long playerId)
|
|
=> ChangeRoomAccountRoleAsync(roomId, playerId, isInvite: true);
|
|
|
|
private async Task<IActionResult> ChangeRoomAccountRoleAsync(long roomId, long playerId, bool isInvite)
|
|
{
|
|
var me = this.RequireCurrentPlayerId();
|
|
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
|
if (room is null) return NotFound();
|
|
var requested = await ReadClientRoomRoleAsync();
|
|
if (requested is null) return BadRequest(new { error = "missing_role" });
|
|
if (playerId <= 0 || !await db.Players.AnyAsync(p => p.Id == playerId))
|
|
return NotFound(new { error = "unknown_player" });
|
|
|
|
var rows = await db.RoomRoles.Where(r => r.RoomId == roomId).ToListAsync();
|
|
var error = RoomRoleRules.CheckChange(
|
|
room, rows, me, playerId, requested.Value, isInvite, out var kind);
|
|
if (error is not null)
|
|
{
|
|
logger.LogWarning(
|
|
"[room-roles] denied room={Room} actor={Actor} target={Target} role={Role} invite={Invite} error={Error}",
|
|
roomId, me, playerId, requested.Value, isInvite, error);
|
|
return error == "cannot_change_creator_role" || error == "invalid_role"
|
|
? BadRequest(new { error, role = requested.Value })
|
|
: Forbid();
|
|
}
|
|
|
|
var targetRows = rows.Where(r => r.PlayerId == playerId).ToList();
|
|
var wasCoOwner = targetRows.Any(r => r.Accepted && r.Role == RoomRoleRules.StoredCoOwner);
|
|
var hadCoOwnerInvite = targetRows.Any(r => !r.Accepted && r.Role == RoomRoleRules.StoredCoOwner);
|
|
RoomRoleRules.TryFromWire(requested.Value, out var storedRole);
|
|
|
|
switch (kind)
|
|
{
|
|
case RoomRoleRules.ChangeKind.Clear when isInvite:
|
|
// Reject / withdraw an invite: only the pending rows go.
|
|
db.RoomRoles.RemoveRange(targetRows.Where(r => !r.Accepted));
|
|
break;
|
|
|
|
case RoomRoleRules.ChangeKind.Clear:
|
|
db.RoomRoles.RemoveRange(targetRows);
|
|
break;
|
|
|
|
case RoomRoleRules.ChangeKind.Invite:
|
|
{
|
|
// Already holding the role: nothing to invite to. Otherwise
|
|
// replace any other pending invite but keep the role the
|
|
// player holds until they accept.
|
|
if (targetRows.Any(r => r.Accepted && r.Role == storedRole)) break;
|
|
db.RoomRoles.RemoveRange(targetRows.Where(r => !r.Accepted && r.Role != storedRole));
|
|
var pending = targetRows.FirstOrDefault(r => !r.Accepted && r.Role == storedRole);
|
|
if (pending is null)
|
|
{
|
|
db.RoomRoles.Add(new RoomRoleEntity
|
|
{
|
|
RoomId = roomId, PlayerId = playerId, Role = storedRole,
|
|
Accepted = false, GrantedByPlayerId = me, GrantedAt = DateTime.UtcNow,
|
|
});
|
|
}
|
|
else
|
|
{
|
|
pending.GrantedByPlayerId = me;
|
|
pending.GrantedAt = DateTime.UtcNow;
|
|
}
|
|
break;
|
|
}
|
|
|
|
case RoomRoleRules.ChangeKind.AcceptInvite:
|
|
case RoomRoleRules.ChangeKind.Grant:
|
|
{
|
|
// One role per account: the new one replaces the rest
|
|
// (an accepted invite replaces the role held meanwhile).
|
|
var keep = targetRows.FirstOrDefault(r => r.Role == storedRole);
|
|
db.RoomRoles.RemoveRange(targetRows.Where(r => !ReferenceEquals(r, keep)));
|
|
if (keep is null)
|
|
{
|
|
db.RoomRoles.Add(new RoomRoleEntity
|
|
{
|
|
RoomId = roomId, PlayerId = playerId, Role = storedRole,
|
|
Accepted = true,
|
|
GrantedByPlayerId = kind == RoomRoleRules.ChangeKind.AcceptInvite
|
|
? targetRows.FirstOrDefault(r => !r.Accepted)?.GrantedByPlayerId ?? me
|
|
: me,
|
|
GrantedAt = DateTime.UtcNow,
|
|
});
|
|
}
|
|
else
|
|
{
|
|
keep.Accepted = true;
|
|
if (kind == RoomRoleRules.ChangeKind.Grant) keep.GrantedByPlayerId = me;
|
|
keep.GrantedAt = DateTime.UtcNow;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
await db.SaveChangesAsync();
|
|
|
|
var isCoOwnerNow = await db.RoomRoles.AnyAsync(r =>
|
|
r.RoomId == roomId && r.PlayerId == playerId && r.Accepted && r.Role == RoomRoleRules.StoredCoOwner);
|
|
var hasCoOwnerInviteNow = await db.RoomRoles.AnyAsync(r =>
|
|
r.RoomId == roomId && r.PlayerId == playerId && !r.Accepted && r.Role == RoomRoleRules.StoredCoOwner);
|
|
logger.LogInformation(
|
|
"[room-roles] room={Room} actor={Actor} target={Target} role={Role} invite={Invite} kind={Kind} coOwner={Was}->{Now}",
|
|
roomId, me, playerId, requested.Value, isInvite, kind, wasCoOwner, isCoOwnerNow);
|
|
|
|
// The invitee's notifications are how the role reaches them: the
|
|
// 2023 client renders message Types 60/61/62 (factory
|
|
// OCGKOJMFLAI.ALEHDHAEFOK, enum KJECOLODAFA.MEPCALGGMJC) and reads
|
|
// only RoomId from them. Self-service changes (accept / reject /
|
|
// leave) notify nobody.
|
|
if (playerId != me)
|
|
{
|
|
if (!hadCoOwnerInvite && hasCoOwnerInviteNow)
|
|
await systemNotifications.SendAsync(playerId,
|
|
SystemNotificationService.MessageType.RoomCoOwnerInvited, me, roomId: roomId);
|
|
if (!wasCoOwner && isCoOwnerNow)
|
|
await systemNotifications.SendAsync(playerId,
|
|
SystemNotificationService.MessageType.RoomCoOwnerAdded, me, roomId: roomId);
|
|
if (wasCoOwner && !isCoOwnerNow)
|
|
await systemNotifications.SendAsync(playerId,
|
|
SystemNotificationService.MessageType.RoomCoOwnerRemoved, me, roomId: roomId);
|
|
}
|
|
|
|
if (hadCoOwnerInvite && !hasCoOwnerInviteNow)
|
|
await MarkCoOwnerInviteMessagesReadAsync(playerId, roomId);
|
|
|
|
var details = await BuildRoomServerDetailsWithRolesAsync(roomId);
|
|
await PushRoomUpdateAsync(roomId, details, playerId, me);
|
|
return Ok(details);
|
|
}
|
|
|
|
/// <summary>Once an invite is answered (or withdrawn) its Type 62 message
|
|
/// must stop offering Accept/Reject in the inbox.</summary>
|
|
private async Task MarkCoOwnerInviteMessagesReadAsync(long playerId, long roomId)
|
|
{
|
|
var stale = await db.Messages
|
|
.Where(m => m.RecipientPlayerId == playerId
|
|
&& m.RoomId == roomId
|
|
&& m.Type == SystemNotificationService.MessageType.RoomCoOwnerInvited
|
|
&& m.ReadAt == null)
|
|
.ToListAsync();
|
|
if (stale.Count == 0) return;
|
|
foreach (var m in stale) m.ReadAt = DateTime.UtcNow;
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
/// <summary>Push the fresh room details as <c>RoomUpdate</c> (handler
|
|
/// IBEOONPEELF.txt:507, Action<FGCPNAACHIK>) to everyone currently
|
|
/// in the room plus the players whose view changed, so the role list,
|
|
/// the role-gated buttons and the local player's own role refresh
|
|
/// without leaving the room.</summary>
|
|
private async Task PushRoomUpdateAsync(long roomId, object details, params long[] alsoNotify)
|
|
{
|
|
var recipients = presence.EnumerateActiveInstances(roomId, includePrivate: true)
|
|
.SelectMany(i => i.PlayerIds)
|
|
.Concat(alsoNotify)
|
|
.Where(pid => pid > 0)
|
|
.Distinct()
|
|
.ToList();
|
|
foreach (var pid in recipients)
|
|
await notifications.NotifyAsync(pid, PushNotificationId.SubscriptionUpdateRoom, details);
|
|
}
|
|
|
|
private async Task<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 (TryParseRoleName(form["role"].FirstOrDefault() ?? form["Role"].FirstOrDefault(), out parsed))
|
|
return parsed;
|
|
}
|
|
|
|
if (int.TryParse(Request.Query["role"].FirstOrDefault(), out parsed)) return parsed;
|
|
if (int.TryParse(Request.Query["Role"].FirstOrDefault(), out parsed)) return parsed;
|
|
|
|
if (Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true)
|
|
{
|
|
try
|
|
{
|
|
using var doc = await JsonDocument.ParseAsync(Request.Body);
|
|
if (doc.RootElement.ValueKind == JsonValueKind.Object)
|
|
{
|
|
foreach (var prop in doc.RootElement.EnumerateObject())
|
|
{
|
|
if (!prop.Name.Equals("role", StringComparison.OrdinalIgnoreCase)) continue;
|
|
if (prop.Value.ValueKind == JsonValueKind.Number && prop.Value.TryGetInt32(out parsed))
|
|
return parsed;
|
|
if (prop.Value.ValueKind == JsonValueKind.String
|
|
&& (int.TryParse(prop.Value.GetString(), out parsed)
|
|
|| TryParseRoleName(prop.Value.GetString(), out parsed)))
|
|
return parsed;
|
|
}
|
|
}
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/// <summary>OMMBGJMJJPN member names, for callers that box the enum
|
|
/// itself (Enum.ToString) instead of an Int32.</summary>
|
|
internal static bool TryParseRoleName(string? raw, out int role)
|
|
{
|
|
role = (raw ?? string.Empty).Trim().ToLowerInvariant() switch
|
|
{
|
|
"none" => RoomRoleRules.WireNone,
|
|
"banned" => RoomRoleRules.WireBanned,
|
|
"host" => RoomRoleRules.WireHost,
|
|
"moderator" => RoomRoleRules.WireModerator,
|
|
"coowner" => RoomRoleRules.WireCoOwner,
|
|
"temporarycoowner" => RoomRoleRules.WireTemporaryCoOwner,
|
|
"creator" => RoomRoleRules.WireCreator,
|
|
_ => -1,
|
|
};
|
|
return role >= 0;
|
|
}
|
|
|
|
/// <summary>Creator or accepted co-owner (see
|
|
/// <see cref="RoomRoleRules.CanEditRoom"/>).</summary>
|
|
private async Task<bool> CanEditRoomAsync(RoomEntity room, long playerId)
|
|
{
|
|
if (room.CreatorPlayerId == playerId) return true;
|
|
var rows = await db.RoomRoles
|
|
.Where(r => r.RoomId == room.Id && r.PlayerId == playerId)
|
|
.ToListAsync();
|
|
return RoomRoleRules.CanEditRoom(room, rows, playerId);
|
|
}
|
|
|
|
private async Task<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",
|
|
};
|
|
}
|
|
}
|