Refactor room data blob handling and permissions

- Updated the RoomsController to utilize synthetic CDN keys for data blobs.
- Introduced methods for determining current or synthetic data blob names.
- Adjusted room role permissions to align with new game role definitions.
- Enhanced the SanitizeController with a new endpoint for purity checks.
- Modified Match and PlayerPresence services to handle deferred presence commits.
- Improved RoomDataBlobService to support new game role and tool tag settings.
- Added support for boot dorm grace period in PlayerPresenceService.
- Updated protobuf definitions to reflect new room role and game role structures.

You are now able to go into dorm and the rec center!
This commit is contained in:
Alexa
2026-06-24 12:02:13 +03:00
parent 5274da03b4
commit 323c17bedb
11 changed files with 733 additions and 81 deletions
@@ -1185,15 +1185,13 @@ public class RoomsController(
// proceeds with default-zero permissions.
//
// If a room has a saved blob, use it. Otherwise point at
// room_<id>_v1.dat, which misses S3 and is served by
// 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 = !string.IsNullOrEmpty(room.CurrentDataBlobName)
? room.CurrentDataBlobName
: $"room_{room.Id}_v1.dat";
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
@@ -1215,9 +1213,7 @@ public class RoomsController(
DataBlobName = !string.IsNullOrWhiteSpace(overrideDataBlobName) &&
(overrideSceneId == s.OrderIndex || sceneRows.Count == 1)
? overrideDataBlobName
: !string.IsNullOrWhiteSpace(s.DataBlobName)
? s.DataBlobName
: dataBlobName,
: SceneOrSyntheticDataBlobName(room, s.DataBlobName, dataBlobName),
MaxPlayers = s.MaxPlayers,
CanMatchmakeInto = s.CanMatchmakeInto,
DataModifiedAt = (s.DataModifiedAt == default ? DateTime.UtcNow : s.DataModifiedAt)
@@ -1309,10 +1305,8 @@ public class RoomsController(
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 room_<id>_v1.dat CDN path.
var dataBlobName = !string.IsNullOrEmpty(room.CurrentDataBlobName)
? room.CurrentDataBlobName
: $"room_{room.Id}_v1.dat";
// 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");
@@ -1324,9 +1318,7 @@ public class RoomsController(
Name = s.Name,
DataBlob = !string.IsNullOrWhiteSpace(overrideDataBlobName) && sceneRows.Count == 1
? overrideDataBlobName
: !string.IsNullOrWhiteSpace(s.DataBlobName)
? s.DataBlobName
: dataBlobName,
: SceneOrSyntheticDataBlobName(room, s.DataBlobName, dataBlobName),
DataSavedAt = (s.DataModifiedAt == default ? DateTime.UtcNow : s.DataModifiedAt)
.ToString("yyyy-MM-ddTHH:mm:ssZ"),
IsSandbox = s.IsSandbox,
@@ -1352,7 +1344,7 @@ public class RoomsController(
var wireRoles = new List<object>
{
BuildRoomAccountRoleWire(room.CreatorPlayerId, 255),
BuildRoomAccountRoleWire(room.CreatorPlayerId, 30),
};
wireRoles.AddRange(roleList.Select(BuildRoomRoleGrantWire));
@@ -1522,6 +1514,7 @@ public class RoomsController(
[HttpGet("api/rooms/v2/myrecent")]
public IActionResult MyOtherTabs() => Ok(Array.Empty<object>());
[HttpGet("player_room_data/{roomId:long}")]
[HttpGet("rooms/{roomId:long}/playerdata/me")]
[Authorize]
public async Task<IActionResult> PlayerDataForMe(long roomId)
@@ -1545,6 +1538,7 @@ public class RoomsController(
return Ok(new
{
Data = "CAE=",
RoomId = room?.Id ?? roomId,
PlayerId = pid.Value,
Favorite = favorited,
@@ -2690,19 +2684,38 @@ public class RoomsController(
var rows = await db.RoomRoles.Where(r => r.RoomId == roomId).ToListAsync();
var list = new List<object>(rows.Count + 1)
{
BuildRoomAccountRoleWire(room.CreatorPlayerId, 255),
BuildRoomAccountRoleWire(room.CreatorPlayerId, 30),
};
list.AddRange(rows.Select(BuildRoomRoleGrantWire));
return Ok(list);
}
private static string CurrentOrSyntheticDataBlobName(RoomEntity room)
{
if (room.IsDormRoom)
return RoomService.ResolveWireRoomDataBlobName(room.Id, room.CurrentDataBlobName);
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;
}
[HttpGet("rooms/{roomId:long}/roles/{playerId:long}")]
public async Task<IActionResult> RoleForPlayer(long roomId, long playerId)
{
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
if (room.CreatorPlayerId == playerId)
return Ok(BuildRoomAccountRoleWire(playerId, 255));
return Ok(BuildRoomAccountRoleWire(playerId, 30));
var row = await db.RoomRoles
.FirstOrDefaultAsync(r => r.RoomId == roomId && r.PlayerId == playerId);
if (row is null) return NotFound();
@@ -84,6 +84,11 @@ public class SanitizeController(ILogger<SanitizeController> logger) : Controller
public string? Text { get; set; }
public string? Input { get; set; }
public string? Value { get; set; }
public string? Context { get; set; }
public int? Intent { get; set; }
[System.Text.Json.Serialization.JsonPropertyName("ruleset")]
public int? Ruleset { get; set; }
}
/// <summary>POST <c>api/sanitize/v1/purifyString</c> — returns
@@ -116,6 +121,32 @@ public class SanitizeController(ILogger<SanitizeController> logger) : Controller
return Ok(ProfanityFilter.IsClean(input));
}
/// <summary>POST <c>api/sanitize/v1/isPure</c> — 2023 route used
/// by CV2 maker pen text such as port names. It imports an object
/// field named <c>IsPure</c>.</summary>
[HttpPost("api/sanitize/v1/isPure")]
public IActionResult IsPureV1(
[FromBody] SanitizeBody? body,
[FromForm(Name = "Text")] string? textForm,
[FromForm(Name = "Input")] string? inputForm,
[FromForm(Name = "Value")] string? valueForm,
[FromQuery(Name = "text")] string? textQuery)
{
var input = body?.Text ?? body?.Input ?? body?.Value
?? textForm ?? inputForm ?? valueForm ?? textQuery ?? string.Empty;
var isPure = ProfanityFilter.IsClean(input);
logger.LogInformation(
"[sanitize] isPure context={Context} intent={Intent} ruleset={Ruleset} valueLength={ValueLength} isPure={IsPure}",
body?.Context ?? string.Empty,
body?.Intent,
body?.Ruleset,
input.Length,
isPure);
var json = System.Text.Json.JsonSerializer.Serialize(new { IsPure = isPure });
return Content(json, "application/json");
}
/// <summary>POST <c>api/sanitize/isPure</c> — December 2020 route
/// used by room/settings forms. Its importer reads an <c>IsPure</c>
/// object field, unlike the newer bare-bool route above.</summary>
@@ -1289,7 +1289,7 @@ public class GoToController(
if (!string.IsNullOrWhiteSpace(room.CurrentDataBlobName)) return room.CurrentDataBlobName;
return $"room_{room.Id}_v1.dat";
return RoomService.SyntheticDefaultRoomDataBlobName(room.Id);
}
/// <summary>
@@ -37,8 +37,8 @@ public class MatchController(
/// we mostly use this for visibility into Photon-side issues —
/// without it, all we see in the catch-all is an opaque "200, empty
/// reply" and no clue why a player crashed back to dorm. Failure
/// reports also clear the pending /goto room and push a ModerationKick
/// so the watch leaves the "Joining room" overlay instead of softlocking.</summary>
/// reports also clear the pending /goto room so the next matchmaking
/// attempt starts from a clean presence state.</summary>
[HttpPost("/roominstance/{instanceId:long}/reportjoinresult")]
public async Task<IActionResult> ReportJoinResult(long instanceId, [FromForm(Name = "result")] int? result)
{
@@ -59,6 +59,7 @@ public class MatchController(
{
presence.SetRoom(pid, completed.Value.TargetRoom);
presence.MarkActive(pid);
presence.EndBootDormGrace(pid);
logger.LogInformation(
"[joinresult] committed presence player={Player} room={RoomId}/{RoomName} instance={Instance} deferred={Deferred}",
pid,
@@ -71,6 +72,7 @@ public class MatchController(
{
presence.MarkActive(pid);
presence.TouchRoom(pid);
presence.EndBootDormGrace(pid);
}
logger.LogInformation(
"[joinresult] player={Player} instance={Instance} OK ({Label})",
@@ -80,7 +82,7 @@ public class MatchController(
{
await joinTimeouts.MarkFailedAsync(pid, instanceId, label);
logger.LogWarning(
"[joinresult] player={Player} instance={Instance} FAILED code={Code} ({Label}) — Photon-side error, check Photon AppId/region/auth",
"[joinresult] player={Player} instance={Instance} FAILED code={Code} ({Label})",
pid, instanceId, code, label);
}
return Ok();
@@ -92,6 +94,22 @@ public class MatchController(
var pid = this.CurrentPlayerId() ?? 0;
if (pid == 0) return Unauthorized();
var pending = joinTimeouts.GetPending(pid);
if (pending is { DeferPresenceCommit: true })
{
presence.MarkActive(pid);
logger.LogInformation(
"[matchmake-none] player={Player} has deferred pending room={Room} instance={Instance}; not committing presence",
pid,
pending.Value.TargetRoom.RoomId,
pending.Value.TargetRoom.RoomInstanceId);
return Ok(new MatchmakingResponseDto
{
ErrorCode = 0,
RoomInstance = pending.Value.TargetRoom,
});
}
var current = presence.GetRoom(pid) ?? await BuildDormRoomInstanceAsync(pid);
presence.SetRoom(pid, current);
presence.MarkActive(pid);
@@ -110,8 +128,22 @@ public class MatchController(
var appId = config["Photon:AppId"] ?? string.Empty;
var voiceAppId = config["Photon:VoiceAppId"] ?? string.Empty;
var region = (config["Photon:CloudRegion"] ?? "us").ToLowerInvariant();
var currentRoom = presence.GetRoom(pid);
var permissions = BuildPhotonPermissionSeed();
logger.LogInformation(
"[photon-token] player={PlayerId} roomInstance={RoomInstanceId} permissions={PermissionCount}",
pid, currentRoom?.RoomInstanceId ?? 0L, permissions.Length);
return Ok(new Dictionary<string, object>
{
// 2023 deserializes this endpoint as ALIIIAGAPND before room
// permissions initialization. If Permissions is missing/empty,
// OMJKHJLFOCO falls into the persisted-role migration path that
// currently NREs during first spawn.
["RoomInstanceId"] = currentRoom?.RoomInstanceId ?? 0L,
["PhotonAccessToken"] = string.Empty,
["Permissions"] = permissions,
// Existing custom-auth/app-id shape kept for Photon auth callers.
["ResultCode"] = 1,
["Message"] = string.Empty,
["UserId"] = pid.ToString(System.Globalization.CultureInfo.InvariantCulture),
@@ -125,6 +157,30 @@ public class MatchController(
});
}
private static object[] BuildPhotonPermissionSeed()
=>
[
BuildPhotonPermission(13, 30), // Can Use Maker Pen
BuildPhotonPermission(20, 30), // Can Spawn Inventions
BuildPhotonPermission(21, 30), // Can Spawn Consumables
BuildPhotonPermission(22, 30), // Can Use Room Reset Button
BuildPhotonPermission(23, 30), // Can Use Play Gizmos Toggle
BuildPhotonPermission(13, 255), // Creator alias
BuildPhotonPermission(20, 255),
BuildPhotonPermission(21, 255),
BuildPhotonPermission(22, 255),
BuildPhotonPermission(23, 255),
];
private static object BuildPhotonPermission(int permission, int role) => new
{
Permission = permission.ToString(System.Globalization.CultureInfo.InvariantCulture),
Role = role,
Override = true,
Type = 0,
Value = "true",
};
[HttpPost("/matchmake/dorm")]
public async Task<IActionResult> MatchmakeDorm()
{
@@ -132,11 +188,11 @@ public class MatchController(
if (pid == 0) return Unauthorized();
var room = await BuildDormRoomInstanceAsync(pid);
presence.SetRoom(pid, room);
joinTimeouts.MarkPending(pid, room, deferPresenceCommit: true);
presence.MarkActive(pid);
await RecordVisitAsync(pid, room.RoomId);
logger.LogInformation(
"[matchmake-dorm] player={Player} room={Room} instance={Instance} photon={Photon}",
"[matchmake-dorm] player={Player} room={Room} instance={Instance} photon={Photon} deferredPresence=True",
pid, room.RoomId, room.RoomInstanceId, room.PhotonRoomId);
return Ok(new MatchmakingResponseDto
{
@@ -23,6 +23,7 @@ public class MatchPlayerController(
NotificationService notifications,
RoomService rooms,
PrivateInstanceService privateInstances,
JoinTimeoutService joinTimeouts,
IConfiguration config,
DorkNetDbContext db,
ILogger<MatchPlayerController> log) : ControllerBase
@@ -61,6 +62,8 @@ public class MatchPlayerController(
var playerId = TryGetCurrentPlayerId();
if (playerId != 0 && !string.IsNullOrWhiteSpace(loginLock))
{
presence.Clear(playerId);
presence.BeginBootDormGrace(playerId);
var replaced = presence.SetLock(playerId, loginLock);
if (replaced)
{
@@ -276,8 +279,27 @@ public class MatchPlayerController(
if (playerId == 0) return null;
var room = presence.GetRoom(playerId);
var pending = joinTimeouts.GetPending(playerId);
if (pending is { DeferPresenceCommit: true })
{
log.LogInformation(
"[player-heartbeat] player={PlayerId} pending deferred room={RoomId} instance={InstanceId}; returning null until join result",
playerId,
pending.Value.TargetRoom.RoomId,
pending.Value.TargetRoom.RoomInstanceId);
return null;
}
if (room is null)
{
if (presence.IsBootDormGraceActive(playerId))
{
log.LogInformation(
"[player-heartbeat] player={PlayerId} in boot dorm grace; returning null roomInstance",
playerId);
return null;
}
// Boot-time race: the watch hits /player/heartbeat before
// /goto/room/DormRoom has finished recording presence. The
// watch's OnPresenceHeartbeatResponse compares
@@ -434,7 +456,7 @@ public class MatchPlayerController(
if (!string.IsNullOrWhiteSpace(room.CurrentDataBlobName)) return room.CurrentDataBlobName;
return $"room_{room.Id}_v1.dat";
return RoomService.SyntheticDefaultRoomDataBlobName(room.Id);
}
}