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 99d3faec5b
commit 38fbd0faf1
11 changed files with 733 additions and 81 deletions
+11 -5
View File
@@ -19,7 +19,7 @@ public sealed class JoinTimeoutService(
{
if (room is null) return;
if (IsDorm(room))
if (IsDorm(room) && !deferPresenceCommit)
{
ClearForPlayer(playerId, "dorm");
return;
@@ -40,6 +40,12 @@ public sealed class JoinTimeoutService(
_ = WatchAsync(playerId, pending);
}
public (RoomInstanceDto TargetRoom, bool DeferPresenceCommit)? GetPending(long playerId)
{
if (!_pending.TryGetValue(playerId, out var pending)) return null;
return (pending.TargetRoom, pending.DeferPresenceCommit);
}
public (RoomInstanceDto TargetRoom, bool DeferPresenceCommit)? MarkCompleted(long playerId, long instanceId, string outcome)
{
if (!_pending.TryGetValue(playerId, out var pending) || pending.InstanceId != instanceId)
@@ -55,7 +61,7 @@ public sealed class JoinTimeoutService(
return (pending.TargetRoom, pending.DeferPresenceCommit);
}
public async Task MarkFailedAsync(long playerId, long instanceId, string outcome)
public Task MarkFailedAsync(long playerId, long instanceId, string outcome)
{
var completed = MarkCompleted(playerId, instanceId, outcome);
@@ -66,14 +72,14 @@ public sealed class JoinTimeoutService(
logger.LogInformation(
"[join-watchdog] failure ignored player={PlayerId} instance={InstanceId} current={CurrentInstance}",
playerId, instanceId, current?.RoomInstanceId);
return;
return Task.CompletedTask;
}
presence.Clear(playerId);
await notifications.KickPlayerAsync(playerId, "Room join failed. Returning to dorm.");
logger.LogWarning(
"[join-watchdog] kicked failed join player={PlayerId} room={RoomId}/{RoomName} instance={InstanceId} outcome={Outcome}",
"[join-watchdog] cleared failed join player={PlayerId} room={RoomId}/{RoomName} instance={InstanceId} outcome={Outcome}",
playerId, failedRoom.RoomId, failedRoom.Name, instanceId, outcome);
return Task.CompletedTask;
}
private async Task WatchAsync(long playerId, PendingJoin pending)
@@ -47,6 +47,7 @@ public class PlayerPresenceService
private readonly ConcurrentDictionary<long, RoomInstanceDto> _local = new();
private readonly ConcurrentDictionary<long, DateTimeOffset> _localRoomExpires = new();
private readonly ConcurrentDictionary<long, DateTimeOffset> _localActiveExpires = new();
private readonly ConcurrentDictionary<long, DateTimeOffset> _localBootDormGraceExpires = new();
private readonly IConnectionMultiplexer? _redis;
private readonly ILogger<PlayerPresenceService>? _log;
@@ -68,6 +69,15 @@ public class PlayerPresenceService
/// </summary>
public static TimeSpan ActivityTtl { get; } = TimeSpan.FromSeconds(90);
/// <summary>
/// Right after login the 2023 watch may boot into its cached offline
/// DormRoom before the live Photon join finishes. During that window
/// a null roomInstance is safer than returning stale/live presence:
/// the client treats the offline placeholder as invalid and accepts
/// null, but a live private dorm forces a resync.
/// </summary>
public static TimeSpan BootDormGraceTtl { get; } = TimeSpan.FromSeconds(45);
private static readonly JsonSerializerOptions JsonOpts = new()
{
// Match the wire format used by the matchmaking response so
@@ -150,6 +160,63 @@ public class PlayerPresenceService
_localActiveExpires[playerId] = DateTimeOffset.UtcNow.Add(ActivityTtl);
}
public void BeginBootDormGrace(long playerId)
{
if (_redis is { } mux)
{
try
{
mux.GetDatabase().StringSet(BootDormGraceKey(playerId), "1", BootDormGraceTtl);
}
catch (Exception ex)
{
_log?.LogWarning(ex, "[presence] redis boot grace SET failed for player={PlayerId}", playerId);
}
return;
}
_localBootDormGraceExpires[playerId] = DateTimeOffset.UtcNow.Add(BootDormGraceTtl);
}
public bool IsBootDormGraceActive(long playerId)
{
if (_redis is { } mux)
{
try
{
return mux.GetDatabase().KeyExists(BootDormGraceKey(playerId));
}
catch (Exception ex)
{
_log?.LogWarning(ex, "[presence] redis boot grace GET failed for player={PlayerId}", playerId);
return false;
}
}
if (!_localBootDormGraceExpires.TryGetValue(playerId, out var expires)) return false;
if (expires > DateTimeOffset.UtcNow) return true;
_localBootDormGraceExpires.TryRemove(playerId, out _);
return false;
}
public void EndBootDormGrace(long playerId)
{
if (_redis is { } mux)
{
try
{
mux.GetDatabase().KeyDelete(BootDormGraceKey(playerId));
}
catch (Exception ex)
{
_log?.LogWarning(ex, "[presence] redis boot grace DEL failed for player={PlayerId}", playerId);
}
return;
}
_localBootDormGraceExpires.TryRemove(playerId, out _);
}
public IReadOnlyCollection<long> RecentlyActivePlayerIds()
{
if (_redis is { } mux)
@@ -265,6 +332,7 @@ public class PlayerPresenceService
var db = mux.GetDatabase();
db.KeyDelete(Key(playerId));
db.KeyDelete(ActiveKey(playerId));
db.KeyDelete(BootDormGraceKey(playerId));
db.SetRemove(ActiveSetKey, playerId);
}
else
@@ -272,6 +340,7 @@ public class PlayerPresenceService
_local.TryRemove(playerId, out _);
_localRoomExpires.TryRemove(playerId, out _);
_localActiveExpires.TryRemove(playerId, out _);
_localBootDormGraceExpires.TryRemove(playerId, out _);
}
}
@@ -316,6 +385,7 @@ public class PlayerPresenceService
private static string Key(long playerId) => $"presence:{playerId}";
private static string ActiveKey(long playerId) => $"presence-active:{playerId}";
private static string BootDormGraceKey(long playerId) => $"presence-boot-dorm-grace:{playerId}";
private const string ActiveSetKey = "presence-active";
// ── LoginLock single-session enforcement ─────────────────────────
+70 -23
View File
@@ -12,7 +12,11 @@ namespace DorkNet.Server.Services;
/// </summary>
public class RoomDataBlobService
{
private const int December2020PersistenceVersion = 26;
private const DEPRECATED_RoomPersistenceVersion LatestDeprecatedPersistenceVersion =
DEPRECATED_RoomPersistenceVersion.LatestRoomPersistenceVersion;
private const PersistedRoomVersion LatestPersistenceVersion =
PersistedRoomVersion.LatestVersion;
private readonly RoomRoleCollectionData _allPermsRoleData = BuildAllPermsRoleData();
private readonly byte[] _rroEditableBlob = BuildRroEditableBlob();
@@ -46,8 +50,10 @@ public class RoomDataBlobService
if (bytes.Length > 0)
{
var msg = PersistedRoomData.Parser.ParseFrom(StripTopLevelField(bytes, 2));
EnsureEditablePersistenceVersion(msg);
Ensure2023PersistenceHeader(msg);
msg.RoomRoleData = BuildDefaultRoomRoleData();
msg.GameRoleData ??= BuildDefaultGameRoleData();
msg.ToolTagSettingsData ??= BuildDefaultToolTagSettingsData();
return msg.ToByteArray();
}
}
@@ -118,16 +124,20 @@ public class RoomDataBlobService
public byte[] OverlayAllPermsRoleData(byte[] existingBlob)
{
var msg = PersistedRoomData.Parser.ParseFrom(existingBlob);
EnsureEditablePersistenceVersion(msg);
Ensure2023PersistenceHeader(msg);
msg.RoomRoleData = _allPermsRoleData.Clone();
msg.GameRoleData ??= BuildDefaultGameRoleData();
msg.ToolTagSettingsData ??= BuildDefaultToolTagSettingsData();
return msg.ToByteArray();
}
public byte[] OverlayRroEditableRoleData(byte[] existingBlob)
{
var msg = PersistedRoomData.Parser.ParseFrom(existingBlob);
EnsureEditablePersistenceVersion(msg);
Ensure2023PersistenceHeader(msg);
msg.RoomRoleData = _rroEditableRoleData.Clone();
msg.GameRoleData ??= BuildDefaultGameRoleData();
msg.ToolTagSettingsData ??= BuildDefaultToolTagSettingsData();
return msg.ToByteArray();
}
@@ -135,7 +145,11 @@ public class RoomDataBlobService
{
var msg = new PersistedRoomData
{
DEPRECATEDVersion = December2020PersistenceVersion,
DEPRECATEDVersion = LatestDeprecatedPersistenceVersion,
Version = LatestPersistenceVersion,
CreativeRolesEnabled = true,
ToolTagSettingsData = BuildDefaultToolTagSettingsData(),
GameRoleData = BuildDefaultGameRoleData(),
RoomRoleData = BuildAllPermsRoleData(),
};
@@ -146,7 +160,11 @@ public class RoomDataBlobService
{
var msg = new PersistedRoomData
{
DEPRECATEDVersion = December2020PersistenceVersion,
DEPRECATEDVersion = LatestDeprecatedPersistenceVersion,
Version = LatestPersistenceVersion,
CreativeRolesEnabled = true,
ToolTagSettingsData = BuildDefaultToolTagSettingsData(),
GameRoleData = BuildDefaultGameRoleData(),
RoomRoleData = BuildDefaultRoomRoleData(),
};
@@ -157,7 +175,7 @@ public class RoomDataBlobService
{
var collection = new RoomRoleCollectionData
{
RecNetMigrationVersion = 1,
RecNetMigrationVersion = MigratedToRecNetVersion.MigratedToRecNet,
};
collection.RoomRoles.Add(BuildPermissiveRole(0, 100, "Creator"));
@@ -166,20 +184,18 @@ public class RoomDataBlobService
private static RoomRoleCollectionData BuildDefaultRoomRoleData()
{
var collection = new RoomRoleCollectionData
{
RecNetMigrationVersion = 1,
};
collection.RoomRoles.Add(BuildPermissiveRole(2_097_152, 100, "Creator")); // AG_CREATOR
return collection;
return BuildRroEditableRoleData();
}
private static byte[] BuildRroEditableBlob()
{
var msg = new PersistedRoomData
{
DEPRECATEDVersion = December2020PersistenceVersion,
DEPRECATEDVersion = LatestDeprecatedPersistenceVersion,
Version = LatestPersistenceVersion,
CreativeRolesEnabled = true,
ToolTagSettingsData = BuildDefaultToolTagSettingsData(),
GameRoleData = BuildDefaultGameRoleData(),
RoomRoleData = BuildRroEditableRoleData(),
};
@@ -190,8 +206,18 @@ public class RoomDataBlobService
{
var collection = new RoomRoleCollectionData
{
RecNetMigrationVersion = 1,
RecNetMigrationVersion = MigratedToRecNetVersion.MigratedToRecNet,
};
// The 2023 room-permissions runtime looks up roles by RecNet account-role
// values during load. Keep the AG role bitmasks below, but also provide
// compatibility aliases for the keys OMJKHJLFOCO.ONECLLALJEO can return.
collection.RoomRoles.Add(BuildPermissiveRole(0, 10, "Default"));
collection.RoomRoles.Add(BuildPermissiveRole(10, 60, "Player"));
collection.RoomRoles.Add(BuildPermissiveRole(20, 80, "Host"));
collection.RoomRoles.Add(BuildPermissiveRole(30, 90, "Co-owner"));
collection.RoomRoles.Add(BuildPermissiveRole(255, 100, "Creator"));
collection.RoomRoles.Add(BuildPermissiveRole(2_097_152, 100, "Creator")); // AG_CREATOR
collection.RoomRoles.Add(BuildPermissiveRole(4_194_304, 90, "Co-owner")); // AG_COOWNER
collection.RoomRoles.Add(BuildPermissiveRole(8_388_608, 80, "Host")); // AG_HOST
@@ -250,10 +276,10 @@ public class RoomDataBlobService
CanSaveInventions = OverrideTrue(),
DisableMicAutoMute = OverrideTrue(),
CanUseShareCam = OverrideTrue(),
UnknownPermission20 = OverrideTrue(),
UnknownPermission21 = OverrideTrue(),
UnknownPermission22 = OverrideTrue(),
UnknownPermission23 = OverrideTrue(),
CanSpawnInventions = OverrideTrue(),
CanSpawnConsumables = OverrideTrue(),
CanUseRoomResetButton = OverrideTrue(),
CanUsePlayGizmosToggle = OverrideTrue(),
// Vote-kick permission — int field, set to a permissive value.
// 0 = AnyoneCanVoteKickAnyone in the 2020 enum (verified via
@@ -268,12 +294,29 @@ public class RoomDataBlobService
};
}
private static void EnsureEditablePersistenceVersion(PersistedRoomData msg)
private static GameRoleCollectionData BuildDefaultGameRoleData()
{
if (msg.DEPRECATEDVersion < 20)
return new GameRoleCollectionData();
}
private static ToolTagSettingsData BuildDefaultToolTagSettingsData()
{
return new ToolTagSettingsData
{
msg.DEPRECATEDVersion = December2020PersistenceVersion;
RuntimeTagData = new TagData(),
};
}
private static void Ensure2023PersistenceHeader(PersistedRoomData msg)
{
if ((int)msg.DEPRECATEDVersion < 20)
{
msg.DEPRECATEDVersion = LatestDeprecatedPersistenceVersion;
}
msg.Version = LatestPersistenceVersion;
msg.CreativeRolesEnabled = true;
msg.ToolTagSettingsData ??= BuildDefaultToolTagSettingsData();
}
private static string StableRoleGuid(int roleId) =>
@@ -284,6 +327,10 @@ public class RoomDataBlobService
8_388_608 => "BD0F2F3A-F931-419E-B50C-ECDFF3F56B52",
16_777_216 => "32300035-3BEA-457E-95C0-1630AFDFA6BD",
0 => Guid.Empty.ToString(),
10 => "3C66F53A-6B76-4DB1-A93F-76F1F59E03B8",
20 => "1E8890ED-D729-446B-835B-3C96D8C7D939",
30 => "FA1825F9-8F41-54E8-8DB4-530C6B24B3E5",
255 => "6F31F2B8-AC6E-549F-8F8C-F8344C9BAE1E",
_ => GuidUtility.Create(GuidUtility.UrlNamespace, $"dorknet-room-role:{roleId}").ToString(),
};
}
+13 -2
View File
@@ -577,7 +577,7 @@ public class RoomService(DorkNetDbContext db)
/// Resolve the saved room-data blob for a personal dorm and repair stale
/// pointers left by older builds. Some existing dorm rows only have the
/// real save in RoomDataBlobs or RoomScenes while DormStates/Rooms still
/// point at the synthetic room_{id}_v1.dat fallback.
/// point at an old synthetic fallback blob.
/// </summary>
public async Task<string> ResolveDormDataBlobNameAsync(long playerId, long dormRoomId)
{
@@ -627,9 +627,20 @@ public class RoomService(DorkNetDbContext db)
return latestDormBlob;
}
return string.Empty;
return SyntheticDefaultRoomDataBlobName(dormRoomId);
}
public static string SyntheticDefaultRoomDataBlobName(long roomId) =>
$"room_{roomId}_dorknet_v8.dat";
public static bool IsLegacySyntheticDefaultRoomDataBlobName(long roomId, string? blobName) =>
string.Equals(blobName, $"room_{roomId}_v1.dat", StringComparison.OrdinalIgnoreCase);
public static string ResolveWireRoomDataBlobName(long roomId, string? blobName) =>
!string.IsNullOrWhiteSpace(blobName) && !IsLegacySyntheticDefaultRoomDataBlobName(roomId, blobName)
? blobName
: SyntheticDefaultRoomDataBlobName(roomId);
private async Task<bool> IsUsableDormBlobNameAsync(string? blobName, long dormRoomId)
{
if (string.IsNullOrWhiteSpace(blobName)) return false;