mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-26 15:38:45 -07:00
fix: 2023 room instances, publish, role permissions and co-owner invites
- Room roles: list the creator as Creator (255), not CoOwner (30). The
client takes the local role from the Roles list and only lets a player
edit roles below their own (Creator excepted), so the creator could not
edit Co-Owner and Creator permissions. Enforce the role ladder on the
server (RoomRoleRules), let invitees accept (PUT roles/{me} role=30) and
reject (PUT roles/{me}/invite role=0), send message Types 62/60/61, and
push RoomUpdate after every role change.
- Permissions: persist PUT subrooms/{sub}/permissions cells in a new
RoomRolePermissions table, serve them from photon_access_token (plus a
roomserver/ twin) and push PhotonAccessToken to the sub-room.
- Publish: player-owned rooms are never IsRRO on the wire; the client
hides Publish and every room setting for an RRO.
- Sub-rooms: wire SubRoomId = OrderIndex + 1 (the client will not load a
sub-room with id 0), new rooms always get a named Home sub-room, and a
startup pass repairs rooms without one.
- Instance browser: per-room Redis presence index, live players only,
private instances for the people allowed in, real IsFull.
- Room/sub-room settings: creator or accepted co-owner everywhere.
This commit is contained in:
@@ -311,7 +311,7 @@ public class NotificationService(
|
||||
{
|
||||
["roomInstanceId"] = redacted.RoomInstanceId,
|
||||
["roomId"] = redacted.RoomId,
|
||||
["subRoomId"] = redacted.SubRoomId,
|
||||
["subRoomId"] = redacted.WireSubRoomId,
|
||||
["roomInstanceType"] = redacted.RoomInstanceType,
|
||||
["location"] = redacted.Location,
|
||||
["dataBlob"] = redacted.DataBlob,
|
||||
|
||||
@@ -105,6 +105,15 @@ public class PlayerPresenceService
|
||||
var db = mux.GetDatabase();
|
||||
db.StringSet(Key(playerId), json, PresenceTtl);
|
||||
db.KeyDelete(NoRoomKey(playerId));
|
||||
// Per-room membership index for the instance browser
|
||||
// (EnumerateActiveInstances). Members are validated against
|
||||
// the player's live presence on read, so a player who moved
|
||||
// on is pruned lazily instead of needing the old room here.
|
||||
if (room is not null && room.RoomId > 0)
|
||||
{
|
||||
db.SetAdd(RoomIndexKey(room.RoomId), playerId);
|
||||
db.KeyExpire(RoomIndexKey(room.RoomId), PresenceTtl + PresenceTtl);
|
||||
}
|
||||
_log?.LogDebug("[presence] set redis player={PlayerId} room={Room} bytes={Bytes}",
|
||||
playerId, room?.Name ?? "<null>", json.Length);
|
||||
}
|
||||
@@ -459,45 +468,97 @@ public class PlayerPresenceService
|
||||
_localNoRoomExpires[playerId] = DateTimeOffset.UtcNow.Add(PresenceTtl);
|
||||
}
|
||||
|
||||
/// <summary>List the currently-active instances of a given room,
|
||||
/// grouped by PhotonRoomId, with the player ids currently presenced
|
||||
/// in each. Drives the watch's RoomInstanceBrowser
|
||||
/// (<c>GET match.*/room/{id}/instances</c>) so players can see
|
||||
/// which instance their friends are in.
|
||||
/// <summary>List the live instances of a given room, grouped by
|
||||
/// PhotonRoomId (or RoomInstanceId when a row has none), with the player
|
||||
/// ids currently presenced in each. Drives the room instance browser
|
||||
/// (<c>GET match.*/room/{id}/instances</c>) and the event instance list.
|
||||
///
|
||||
/// Redis caveat: without a secondary index per room we'd need a
|
||||
/// SCAN over every presence key — too expensive to do on each
|
||||
/// request. For now the Redis path returns empty and the local
|
||||
/// in-process dictionary is the source of truth. Single-instance
|
||||
/// deployments work; multi-replica Redis deployments need a
|
||||
/// per-room set index to be added before the browser is reliable
|
||||
/// there.</summary>
|
||||
public IEnumerable<(RoomInstanceDto Room, List<long> PlayerIds)> EnumerateActiveInstances(long roomId)
|
||||
/// <para>Only players whose presence is still live count: an expired
|
||||
/// room presence (<see cref="PresenceTtl"/>) is dropped, and so is a
|
||||
/// player whose activity marker (<see cref="ActivityTtl"/>, refreshed by
|
||||
/// every heartbeat and join) has lapsed. A client that crashed keeps its
|
||||
/// room presence for up to ten minutes, but it is not in the instance and
|
||||
/// must not inflate the player count or keep an empty instance listed.</para>
|
||||
///
|
||||
/// <para><b>Redis.</b> <see cref="SetRoom"/> records each player in a
|
||||
/// per-room set (<c>presence-room:{roomId}</c>); this reads that set and
|
||||
/// validates every member against their current presence, pruning the
|
||||
/// ones that have moved on or expired. Without the index the Redis path
|
||||
/// had nothing to enumerate and returned an empty list on every
|
||||
/// multi-replica deployment.</para>
|
||||
///
|
||||
/// <para>Private instances are skipped unless
|
||||
/// <paramref name="includePrivate"/> is set; callers that include them
|
||||
/// must apply their own visibility rule.</para></summary>
|
||||
public IEnumerable<(RoomInstanceDto Room, List<long> PlayerIds)> EnumerateActiveInstances(
|
||||
long roomId, bool includePrivate = false)
|
||||
{
|
||||
if (_redis is not null) return Array.Empty<(RoomInstanceDto, List<long>)>();
|
||||
var groups = new Dictionary<string, (RoomInstanceDto Room, List<long> PlayerIds)>(StringComparer.Ordinal);
|
||||
foreach (var kv in _local)
|
||||
var members = new List<(long PlayerId, RoomInstanceDto Room)>();
|
||||
if (_redis is { } mux)
|
||||
{
|
||||
var room = kv.Value;
|
||||
if (room is null || room.RoomId != roomId) continue;
|
||||
// Skip private instances — those are surfaced separately
|
||||
// in the controller via PrivateInstanceService so we never
|
||||
// leak a photonRoomId for an invite-only match into the
|
||||
// public browser.
|
||||
if (room.IsPrivate) continue;
|
||||
var key = string.IsNullOrEmpty(room.PhotonRoomId)
|
||||
? $"<{room.RoomInstanceId}>"
|
||||
: room.PhotonRoomId;
|
||||
try
|
||||
{
|
||||
var db = mux.GetDatabase();
|
||||
var stale = new List<RedisValue>();
|
||||
foreach (var member in db.SetMembers(RoomIndexKey(roomId)))
|
||||
{
|
||||
if (!long.TryParse(member.ToString(), out var playerId))
|
||||
{
|
||||
stale.Add(member);
|
||||
continue;
|
||||
}
|
||||
var room = GetRoom(playerId);
|
||||
if (room is null || room.RoomId != roomId)
|
||||
{
|
||||
stale.Add(member);
|
||||
continue;
|
||||
}
|
||||
members.Add((playerId, room));
|
||||
}
|
||||
if (stale.Count > 0)
|
||||
db.SetRemove(RoomIndexKey(roomId), stale.ToArray());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log?.LogWarning(ex, "[presence] redis room index scan failed room={RoomId}", roomId);
|
||||
return Array.Empty<(RoomInstanceDto, List<long>)>();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var playerId in _local.Keys.ToList())
|
||||
{
|
||||
var room = GetRoom(playerId); // honours the room TTL
|
||||
if (room is not null && room.RoomId == roomId)
|
||||
members.Add((playerId, room));
|
||||
}
|
||||
}
|
||||
|
||||
var groups = new Dictionary<string, (RoomInstanceDto Room, List<long> PlayerIds)>(StringComparer.Ordinal);
|
||||
foreach (var (playerId, room) in members)
|
||||
{
|
||||
if (room.IsPrivate && !includePrivate) continue;
|
||||
if (!IsRecentlyActive(playerId)) continue;
|
||||
var key = InstanceKey(room);
|
||||
if (!groups.TryGetValue(key, out var entry))
|
||||
{
|
||||
entry = (room, new List<long>());
|
||||
groups[key] = entry;
|
||||
}
|
||||
entry.PlayerIds.Add(kv.Key);
|
||||
entry.PlayerIds.Add(playerId);
|
||||
}
|
||||
return groups.Values;
|
||||
}
|
||||
|
||||
/// <summary>The identity two presence rows share when they are in the
|
||||
/// same live instance: the Photon room, else the instance id.</summary>
|
||||
public static string InstanceKey(RoomInstanceDto room)
|
||||
=> string.IsNullOrEmpty(room.PhotonRoomId)
|
||||
? "<" + room.RoomInstanceId + ">"
|
||||
: room.PhotonRoomId;
|
||||
|
||||
private static string RoomIndexKey(long roomId) => "presence-room:" + roomId;
|
||||
|
||||
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}";
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DorkNet.Server.Services;
|
||||
|
||||
/// <summary>
|
||||
/// A sub-room's role-permission matrix: the cells the 2023 client reads from
|
||||
/// <c>GET photon_access_token</c> (<c>ALIIIAGAPND.Permissions</c>, a list of
|
||||
/// <c>BNLPBMJJOMM</c>) and writes with
|
||||
/// <c>PUT rooms/{id}/subrooms/{sub}/permissions</c>.
|
||||
///
|
||||
/// <para>The served list is the built-in defaults with every stored cell for
|
||||
/// the room's sub-room applied on top: a stored cell replaces the default
|
||||
/// with the same (Role, Permission), and cells with no default are appended.
|
||||
/// The list must never be empty — with no Permissions the room-permissions
|
||||
/// runtime (OMJKHJLFOCO) falls into its persisted-role migration path, which
|
||||
/// NREs during first spawn.</para>
|
||||
///
|
||||
/// <para>Cell keys (formatter RecNet.Runtime/HDOJMEBCAGP.txt):
|
||||
/// <c>Permission</c> (string), <c>Role</c> (OMMBGJMJJPN), <c>Override</c>
|
||||
/// (bool), <c>Type</c> (FEOJFGECKGH: Bool 0 / Int 1 / StringList 2),
|
||||
/// <c>Value</c> (string). Role and Type go out as numbers; requests are
|
||||
/// accepted with numbers, numeric strings or enum names.</para>
|
||||
/// </summary>
|
||||
public static class RoomPermissionMatrix
|
||||
{
|
||||
/// <summary>One matrix cell.</summary>
|
||||
public sealed record Cell(string Permission, int Role, bool Override, int Type, string Value);
|
||||
|
||||
/// <summary>The defaults every sub-room starts from: Co-Owner and Creator
|
||||
/// may use the maker pen, spawn inventions and consumables, reset the
|
||||
/// room and toggle play gizmos.</summary>
|
||||
public static IReadOnlyList<Cell> Defaults { get; } = BuildDefaults();
|
||||
|
||||
private static IReadOnlyList<Cell> BuildDefaults()
|
||||
{
|
||||
var permissions = new[]
|
||||
{
|
||||
"13", // Can Use Maker Pen
|
||||
"20", // Can Spawn Inventions
|
||||
"21", // Can Spawn Consumables
|
||||
"22", // Can Use Room Reset Button
|
||||
"23", // Can Use Play Gizmos Toggle
|
||||
};
|
||||
var list = new List<Cell>();
|
||||
foreach (var role in new[] { RoomRoleRules.WireCoOwner, RoomRoleRules.WireCreator })
|
||||
foreach (var permission in permissions)
|
||||
list.Add(new Cell(permission, role, true, 0, "true"));
|
||||
return list;
|
||||
}
|
||||
|
||||
/// <summary>Defaults merged with the stored cells.</summary>
|
||||
public static List<Cell> Merge(IEnumerable<RoomRolePermissionEntity> stored)
|
||||
{
|
||||
var cells = Defaults.ToList();
|
||||
foreach (var row in stored.OrderBy(r => r.Id))
|
||||
{
|
||||
var cell = new Cell(row.Permission, row.Role, row.Override, row.Type, row.Value);
|
||||
var at = cells.FindIndex(c =>
|
||||
c.Role == row.Role
|
||||
&& string.Equals(c.Permission, row.Permission, StringComparison.OrdinalIgnoreCase));
|
||||
if (at >= 0) cells[at] = cell;
|
||||
else cells.Add(cell);
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
|
||||
/// <summary>The served matrix for one sub-room (by OrderIndex).</summary>
|
||||
public static async Task<List<Cell>> LoadAsync(DorkNetDbContext db, long roomId, long subRoomOrderIndex)
|
||||
{
|
||||
var stored = await db.RoomRolePermissions.AsNoTracking()
|
||||
.Where(p => p.RoomId == roomId && p.SubRoomId == subRoomOrderIndex)
|
||||
.ToListAsync();
|
||||
return Merge(stored);
|
||||
}
|
||||
|
||||
/// <summary>The <c>ALIIIAGAPND</c> body: <c>{RoomInstanceId,
|
||||
/// PhotonAccessToken, Permissions}</c>. Used both by
|
||||
/// <c>GET photon_access_token</c> and the <c>PhotonAccessToken</c> hub
|
||||
/// push (IBEOONPEELF.txt:519 registers it as Action<ALIIIAGAPND>).</summary>
|
||||
public static Dictionary<string, object> AccessTokenBody(long roomInstanceId, IEnumerable<Cell> cells)
|
||||
=> new()
|
||||
{
|
||||
["RoomInstanceId"] = roomInstanceId,
|
||||
["PhotonAccessToken"] = string.Empty,
|
||||
["Permissions"] = cells.Select(ToWire).ToList(),
|
||||
};
|
||||
|
||||
public static object ToWire(Cell c) => new
|
||||
{
|
||||
c.Permission,
|
||||
c.Role,
|
||||
c.Override,
|
||||
c.Type,
|
||||
c.Value,
|
||||
};
|
||||
|
||||
/// <summary>Parse the PUT body: a JSON array of cells (an object wrapping
|
||||
/// a <c>Permissions</c> array is accepted too). Returns null when the body
|
||||
/// is not JSON or holds no array.</summary>
|
||||
public static List<Cell>? Parse(string body)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(body)) return null;
|
||||
JsonDocument doc;
|
||||
try { doc = JsonDocument.Parse(body); }
|
||||
catch (JsonException) { return null; }
|
||||
using (doc)
|
||||
{
|
||||
var root = doc.RootElement;
|
||||
if (root.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
root = root.EnumerateObject()
|
||||
.Where(p => p.Name.Equals("Permissions", StringComparison.OrdinalIgnoreCase))
|
||||
.Select(p => p.Value)
|
||||
.FirstOrDefault();
|
||||
}
|
||||
if (root.ValueKind != JsonValueKind.Array) return null;
|
||||
|
||||
var cells = new List<Cell>();
|
||||
foreach (var item in root.EnumerateArray())
|
||||
{
|
||||
if (item.ValueKind != JsonValueKind.Object) continue;
|
||||
string? permission = null;
|
||||
int? role = null;
|
||||
bool @override = false;
|
||||
int type = 0;
|
||||
string value = string.Empty;
|
||||
foreach (var prop in item.EnumerateObject())
|
||||
{
|
||||
switch (prop.Name.ToLowerInvariant())
|
||||
{
|
||||
case "permission":
|
||||
permission = Scalar(prop.Value)?.Trim();
|
||||
break;
|
||||
case "role":
|
||||
role = EnumValue(prop.Value, RoleName);
|
||||
break;
|
||||
case "type":
|
||||
type = EnumValue(prop.Value, TypeName) ?? 0;
|
||||
break;
|
||||
case "override":
|
||||
@override = prop.Value.ValueKind == JsonValueKind.True
|
||||
|| (prop.Value.ValueKind == JsonValueKind.String
|
||||
&& bool.TryParse(prop.Value.GetString(), out var b) && b);
|
||||
break;
|
||||
case "value":
|
||||
value = Scalar(prop.Value) ?? string.Empty;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (string.IsNullOrEmpty(permission) || role is null) continue;
|
||||
if (permission.Length > 128 || value.Length > 4096) continue;
|
||||
cells.Add(new Cell(permission, role.Value, @override, type, value));
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? Scalar(JsonElement e) => e.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => e.GetString(),
|
||||
JsonValueKind.Number => e.GetRawText(),
|
||||
JsonValueKind.True => "true",
|
||||
JsonValueKind.False => "false",
|
||||
JsonValueKind.Null or JsonValueKind.Undefined => null,
|
||||
_ => e.GetRawText(),
|
||||
};
|
||||
|
||||
private static int? EnumValue(JsonElement e, Func<string, int?> byName)
|
||||
{
|
||||
if (e.ValueKind == JsonValueKind.Number && e.TryGetInt32(out var n)) return n;
|
||||
if (e.ValueKind != JsonValueKind.String) return null;
|
||||
var s = e.GetString() ?? string.Empty;
|
||||
return int.TryParse(s, out n) ? n : byName(s);
|
||||
}
|
||||
|
||||
private static int? RoleName(string s) => s.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"none" => RoomRoleRules.WireNone,
|
||||
"banned" => RoomRoleRules.WireBanned,
|
||||
"host" => RoomRoleRules.WireHost,
|
||||
"moderator" => RoomRoleRules.WireModerator,
|
||||
"coowner" => RoomRoleRules.WireCoOwner,
|
||||
"temporarycoowner" => RoomRoleRules.WireTemporaryCoOwner,
|
||||
"creator" => RoomRoleRules.WireCreator,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static int? TypeName(string s) => s.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"bool" => 0,
|
||||
"int" => 1,
|
||||
"stringlist" => 2,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
using DorkNet.Server.Data.Entities;
|
||||
|
||||
namespace DorkNet.Server.Services;
|
||||
|
||||
/// <summary>
|
||||
/// The 2023 room-role ladder and who may change whose role.
|
||||
///
|
||||
/// <para><b>Wire enum.</b> The client's room role is <c>OMMBGJMJJPN : byte</c>
|
||||
/// (recnet-runtime-decomp/OMMBGJMJJPN.cs): None 0, Banned 1, Host 10,
|
||||
/// Moderator 20, CoOwner 30, TemporaryCoOwner 31, Creator 255. It is
|
||||
/// gap-skipping, like the club MembershipType enum, so never treat it as an
|
||||
/// index. <see cref="RoomRoleEntity.Role"/> stores a compact kind instead
|
||||
/// (0 = CoOwner, 1 = Moderator, 2 = Host); <see cref="ToWire"/> and
|
||||
/// <see cref="TryFromWire"/> convert.</para>
|
||||
///
|
||||
/// <para><b>The creator is 255, not 30.</b> The client reads the local
|
||||
/// player's role straight out of the room details <c>Roles</c> list
|
||||
/// (<c>FGCPNAACHIK.ONECLLALJEO</c>: first row whose AccountId matches), and
|
||||
/// the room-permissions runtime only lets a player edit a role strictly
|
||||
/// below their own, with Creator (255) the one unconditional pass
|
||||
/// (<c>OMJKHJLFOCO.DIIMLMKEJLC</c>,
|
||||
/// IsilDump/RecRoom.RoomPermissions.Runtime/OMJKHJLFOCO.txt:2367:
|
||||
/// <c>if role == 255 return true; if target >= role return false</c>).
|
||||
/// Advertising the creator as CoOwner (30) therefore locked the room's own
|
||||
/// creator out of the Co-Owner and Creator permission rows.</para>
|
||||
///
|
||||
/// <para><b>Server rule</b> (<see cref="CheckChange"/>): the creator may set
|
||||
/// any role on anyone but themselves. Otherwise the actor must hold an
|
||||
/// accepted CoOwner role, and both the target's current role (accepted or
|
||||
/// still invited) and the requested role must be strictly below the
|
||||
/// actor's. A player may always drop their own role or decline their own
|
||||
/// invite (role 0), and may accept exactly the role they were invited to.</para>
|
||||
/// </summary>
|
||||
public static class RoomRoleRules
|
||||
{
|
||||
public const int WireNone = 0;
|
||||
public const int WireBanned = 1;
|
||||
public const int WireHost = 10;
|
||||
public const int WireModerator = 20;
|
||||
public const int WireCoOwner = 30;
|
||||
public const int WireTemporaryCoOwner = 31;
|
||||
public const int WireCreator = 255;
|
||||
|
||||
public const int StoredCoOwner = 0;
|
||||
public const int StoredModerator = 1;
|
||||
public const int StoredHost = 2;
|
||||
|
||||
/// <summary>Stored kind → wire role.</summary>
|
||||
public static int ToWire(int storedRole) => storedRole switch
|
||||
{
|
||||
StoredCoOwner => WireCoOwner,
|
||||
StoredModerator => WireModerator,
|
||||
StoredHost => WireHost,
|
||||
_ => WireNone,
|
||||
};
|
||||
|
||||
/// <summary>Wire role → stored kind. Only the three persistable roles
|
||||
/// map; None, Banned, TemporaryCoOwner (an in-instance grant) and Creator
|
||||
/// (ownership, changed through <c>rooms/{id}/creator</c>) do not.</summary>
|
||||
public static bool TryFromWire(int wireRole, out int storedRole)
|
||||
{
|
||||
storedRole = wireRole switch
|
||||
{
|
||||
WireCoOwner => StoredCoOwner,
|
||||
WireModerator => StoredModerator,
|
||||
WireHost => StoredHost,
|
||||
_ => -1,
|
||||
};
|
||||
return storedRole >= 0;
|
||||
}
|
||||
|
||||
/// <summary>The player's accepted wire role in the room: 255 for the
|
||||
/// creator, the highest accepted grant otherwise, 0 for none.</summary>
|
||||
public static int AcceptedRank(RoomEntity room, IEnumerable<RoomRoleEntity> rows, long playerId)
|
||||
{
|
||||
if (room.CreatorPlayerId == playerId) return WireCreator;
|
||||
return rows
|
||||
.Where(r => r.RoomId == room.Id && r.PlayerId == playerId && r.Accepted)
|
||||
.Select(r => ToWire(r.Role))
|
||||
.DefaultIfEmpty(WireNone)
|
||||
.Max();
|
||||
}
|
||||
|
||||
/// <summary>The highest role the player has been invited to and not yet
|
||||
/// accepted, 0 for none.</summary>
|
||||
public static int InvitedRank(RoomEntity room, IEnumerable<RoomRoleEntity> rows, long playerId)
|
||||
=> rows
|
||||
.Where(r => r.RoomId == room.Id && r.PlayerId == playerId && !r.Accepted)
|
||||
.Select(r => ToWire(r.Role))
|
||||
.DefaultIfEmpty(WireNone)
|
||||
.Max();
|
||||
|
||||
/// <summary>True when the player may change room-level settings and the
|
||||
/// sub-room settings that hang off them: the creator or an accepted
|
||||
/// co-owner. This is the same gate as the bare room mutations
|
||||
/// (RoomsModerationController.RequireOwnedRoomAsync) and room saves.</summary>
|
||||
public static bool CanEditRoom(RoomEntity room, IEnumerable<RoomRoleEntity> rows, long playerId)
|
||||
=> AcceptedRank(room, rows, playerId) >= WireCoOwner;
|
||||
|
||||
/// <summary>What a role write will do once <see cref="CheckChange"/> allows it.</summary>
|
||||
public enum ChangeKind
|
||||
{
|
||||
/// <summary>Grant or replace the target's role (accepted at once).</summary>
|
||||
Grant,
|
||||
/// <summary>Record a pending invite the target must accept.</summary>
|
||||
Invite,
|
||||
/// <summary>The target accepts their own pending invite.</summary>
|
||||
AcceptInvite,
|
||||
/// <summary>Remove the target's role and any pending invite.</summary>
|
||||
Clear,
|
||||
}
|
||||
|
||||
/// <summary>Decide whether <paramref name="actorId"/> may set
|
||||
/// <paramref name="targetId"/>'s role to <paramref name="requestedWireRole"/>.
|
||||
/// Returns null when allowed, otherwise an error code for the response.
|
||||
/// <paramref name="kind"/> is what the caller should then do.</summary>
|
||||
public static string? CheckChange(
|
||||
RoomEntity room,
|
||||
IReadOnlyCollection<RoomRoleEntity> rows,
|
||||
long actorId,
|
||||
long targetId,
|
||||
int requestedWireRole,
|
||||
bool isInvite,
|
||||
out ChangeKind kind)
|
||||
{
|
||||
kind = requestedWireRole == WireNone
|
||||
? ChangeKind.Clear
|
||||
: isInvite ? ChangeKind.Invite : ChangeKind.Grant;
|
||||
|
||||
if (targetId == room.CreatorPlayerId)
|
||||
return "cannot_change_creator_role";
|
||||
|
||||
if (requestedWireRole != WireNone && !TryFromWire(requestedWireRole, out _))
|
||||
return "invalid_role";
|
||||
|
||||
var actorRank = AcceptedRank(room, rows, actorId);
|
||||
|
||||
// Self-service: anyone may give up their own role or decline their
|
||||
// own invite, and accept the exact role they were invited to.
|
||||
if (actorId == targetId && actorRank != WireCreator)
|
||||
{
|
||||
if (requestedWireRole == WireNone) return null;
|
||||
if (!isInvite && requestedWireRole == InvitedRank(room, rows, targetId))
|
||||
{
|
||||
kind = ChangeKind.AcceptInvite;
|
||||
return null;
|
||||
}
|
||||
return "forbidden";
|
||||
}
|
||||
|
||||
if (actorRank == WireCreator) return null;
|
||||
if (actorRank < WireCoOwner) return "forbidden";
|
||||
|
||||
var targetRank = Math.Max(
|
||||
AcceptedRank(room, rows, targetId),
|
||||
InvitedRank(room, rows, targetId));
|
||||
if (targetRank >= actorRank) return "forbidden";
|
||||
if (requestedWireRole >= actorRank) return "forbidden";
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>True when <paramref name="playerId"/> may write the
|
||||
/// permission row for <paramref name="wireRole"/>: the creator may edit
|
||||
/// every role (including CoOwner and Creator); a co-owner only the roles
|
||||
/// below their own. Mirrors the client's OMJKHJLFOCO.DIIMLMKEJLC.</summary>
|
||||
public static bool CanEditRolePermissions(
|
||||
RoomEntity room, IEnumerable<RoomRoleEntity> rows, long playerId, int wireRole)
|
||||
{
|
||||
var rank = AcceptedRank(room, rows, playerId);
|
||||
if (rank == WireCreator) return true;
|
||||
if (rank < WireCoOwner) return false;
|
||||
// TemporaryCoOwner collapses to CoOwner on the client (ONECLLALJEO).
|
||||
var target = wireRole == WireTemporaryCoOwner ? WireCoOwner : wireRole;
|
||||
return target < rank;
|
||||
}
|
||||
|
||||
/// <summary>The room details / roles-list rows: one per account, with
|
||||
/// the creator first as 255 and every other account's accepted role in
|
||||
/// <c>Role</c> and pending invite in <c>InvitedRole</c>. Rows the
|
||||
/// creator happens to own in RoomRoles are ignored so the creator is
|
||||
/// never listed twice (the client takes the first match).</summary>
|
||||
public static List<RoomRoleWire> BuildWires(RoomEntity room, IEnumerable<RoomRoleEntity> rows)
|
||||
{
|
||||
var list = new List<RoomRoleWire>
|
||||
{
|
||||
new(ToClientAccountId(room.CreatorPlayerId), WireCreator, WireNone, null),
|
||||
};
|
||||
foreach (var group in rows
|
||||
.Where(r => r.RoomId == room.Id && r.PlayerId != room.CreatorPlayerId)
|
||||
.GroupBy(r => r.PlayerId)
|
||||
.OrderBy(g => g.Key))
|
||||
{
|
||||
var accepted = group.Where(r => r.Accepted).ToList();
|
||||
var invited = group.Where(r => !r.Accepted).ToList();
|
||||
var role = accepted.Select(r => ToWire(r.Role)).DefaultIfEmpty(WireNone).Max();
|
||||
var invitedRole = invited.Select(r => ToWire(r.Role)).DefaultIfEmpty(WireNone).Max();
|
||||
var lastChangedBy = group
|
||||
.OrderByDescending(r => r.GrantedAt)
|
||||
.Select(r => r.GrantedByPlayerId)
|
||||
.FirstOrDefault();
|
||||
list.Add(new RoomRoleWire(
|
||||
ToClientAccountId(group.Key),
|
||||
role,
|
||||
invitedRole,
|
||||
lastChangedBy is long by ? ToClientAccountId(by) : null));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
/// <summary>One <c>EFHPLDPNGIM</c> row: <c>{AccountId: Int32, Role,
|
||||
/// InvitedRole, LastChangedByAccountId: Int32?}</c>.</summary>
|
||||
public sealed record RoomRoleWire(int AccountId, int Role, int InvitedRole, int? LastChangedByAccountId);
|
||||
|
||||
/// <summary>Account ids are Int32 in the 2023 role DTO. Clamp rather than
|
||||
/// wrap so an out-of-range id is obviously wrong.</summary>
|
||||
public static int ToClientAccountId(long accountId)
|
||||
=> accountId > int.MaxValue
|
||||
? int.MaxValue
|
||||
: accountId < int.MinValue
|
||||
? int.MinValue
|
||||
: (int)accountId;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
namespace DorkNet.Server.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Converts between a sub-room's position in the room
|
||||
/// (<see cref="Data.Entities.RoomSceneEntity.OrderIndex"/>, 0-based, what the
|
||||
/// server stores and indexes on) and the <c>SubRoomId</c> the 2023 client
|
||||
/// sees on the wire.
|
||||
///
|
||||
/// <para><b>Why they differ.</b> The client refuses to load a sub-room whose
|
||||
/// id is not positive: <c>SubroomModel.DOKCPLJOKIE</c>
|
||||
/// (IsilDump/Assembly-CSharp/RRUI/Data/SubroomModel.txt:3) is
|
||||
/// <c>RoomId > 0 && SubroomId > 0</c>, and
|
||||
/// <c>Model`1.LNPEMMMGCPC</c> skips the load when it is false. With the
|
||||
/// first sub-room advertised as id 0, its Room Settings > Subrooms row
|
||||
/// never fetched its details (<c>SubroomModel.KBOJBJNCOGK</c> →
|
||||
/// <c>FGCPNAACHIK.OBFPEGLLELP(long?)</c>), so the row showed no name, and
|
||||
/// every per-row action (rename, max players, permissions, duplicate) ran
|
||||
/// against a null sub-room. Rec Room's own ids were large unique numbers;
|
||||
/// the client needs only "positive and stable per room".</para>
|
||||
///
|
||||
/// <para><b>Mapping.</b> Wire id = OrderIndex + 1, so a room's first
|
||||
/// sub-room is 1. Incoming ids go back through <see cref="ToOrderIndex"/>;
|
||||
/// 0 (and anything negative) is accepted as an alias of the first sub-room,
|
||||
/// which keeps cached client state, old presence rows and bare
|
||||
/// <c>matchmake/room/{id}</c> joins working.</para>
|
||||
///
|
||||
/// <para><b>Where the conversion happens.</b> Only at the wire: the room
|
||||
/// details <c>SubRooms[]</c>, the sub-room routes
|
||||
/// (<c>rooms/{id}/subrooms/{sub}/*</c>), <c>matchmake/room/{id}/{sub}</c>,
|
||||
/// the room-instance JSON (<c>RoomInstanceDto.subRoomId</c>), the instance
|
||||
/// browser, sub-room save DTOs and permission rows. Everything stored
|
||||
/// (RoomScenes, PrivateInstances, RoomDataBlobs, presence) keeps the
|
||||
/// OrderIndex.</para>
|
||||
/// </summary>
|
||||
public static class RoomSceneIds
|
||||
{
|
||||
/// <summary>OrderIndex → wire SubRoomId (always ≥ 1).</summary>
|
||||
public static long ToWire(long orderIndex) => Math.Max(0, orderIndex) + 1;
|
||||
|
||||
/// <summary>Wire SubRoomId → OrderIndex. 0 and negatives map to the first
|
||||
/// sub-room.</summary>
|
||||
public static int ToOrderIndex(long wireSubRoomId)
|
||||
=> wireSubRoomId <= 1 ? 0 : (int)Math.Min(int.MaxValue, wireSubRoomId - 1);
|
||||
}
|
||||
@@ -546,6 +546,71 @@ public class RoomService(DorkNetDbContext db)
|
||||
return existing;
|
||||
}
|
||||
|
||||
/// <summary>The name a sub-room gets when it has none: "Home" for the
|
||||
/// entry sub-room, "Subroom N" (1-based, matching its wire id) for the
|
||||
/// rest.</summary>
|
||||
public static string DefaultSubRoomName(int orderIndex)
|
||||
=> orderIndex <= 0 ? "Home" : $"Subroom {orderIndex + 1}";
|
||||
|
||||
/// <summary>A real entry sub-room row for a room that has none, built
|
||||
/// from the room's own location, data blob and capacity. Callers add it
|
||||
/// to the context.</summary>
|
||||
public static RoomSceneEntity NewHomeScene(RoomEntity room) => new()
|
||||
{
|
||||
RoomId = room.Id,
|
||||
OrderIndex = 0,
|
||||
Name = DefaultSubRoomName(0),
|
||||
RoomSceneLocationId = string.IsNullOrWhiteSpace(room.LocationReplicationId)
|
||||
? new RoomSceneEntity().RoomSceneLocationId
|
||||
: room.LocationReplicationId,
|
||||
DataBlobName = room.CurrentDataBlobName ?? string.Empty,
|
||||
MaxPlayers = room.MaxCapacity > 0 ? room.MaxCapacity : 8,
|
||||
IsSandbox = false,
|
||||
CanMatchmakeInto = true,
|
||||
DataModifiedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
/// <summary>Startup self-heal for sub-rooms (idempotent).
|
||||
///
|
||||
/// <para>Every non-dorm room must own at least one real RoomScenes row,
|
||||
/// and every row must have a name. A room created by cloning a template
|
||||
/// that had no scene rows (the 2023 "create room" flow clones a base room
|
||||
/// such as MakerRoom) ended up with none, so the details builder served a
|
||||
/// synthesised fallback and every sub-room mutation had to create the row
|
||||
/// on the fly; a blank Name renders as an empty row in Room Settings >
|
||||
/// Subrooms. This inserts the missing entry sub-room (OrderIndex 0, the
|
||||
/// room's own location / blob / capacity) and names unnamed rows. Dorms
|
||||
/// are left alone: their details are shaped per player and their single
|
||||
/// scene is synthesised on purpose.</para>
|
||||
///
|
||||
/// <para>Scene ids are assigned explicitly (max + 1) for the same reason
|
||||
/// the clone handler does: seeded rows were inserted with manual ids
|
||||
/// without advancing the Postgres identity sequence.</para>
|
||||
/// Returns (rooms given a scene, scenes renamed).</summary>
|
||||
public async Task<(int ScenesAdded, int ScenesRenamed)> RepairRoomScenesAsync()
|
||||
{
|
||||
var roomsWithoutScenes = await db.Rooms
|
||||
.Where(r => !r.IsDormRoom && !db.RoomScenes.Any(s => s.RoomId == r.Id))
|
||||
.ToListAsync();
|
||||
var nextSceneId = await db.RoomScenes.MaxAsync(s => (long?)s.Id) ?? 0L;
|
||||
foreach (var room in roomsWithoutScenes)
|
||||
{
|
||||
var scene = NewHomeScene(room);
|
||||
scene.Id = ++nextSceneId;
|
||||
db.RoomScenes.Add(scene);
|
||||
}
|
||||
|
||||
var unnamed = await db.RoomScenes
|
||||
.Where(s => s.Name == null || s.Name.Trim() == string.Empty)
|
||||
.ToListAsync();
|
||||
foreach (var scene in unnamed)
|
||||
scene.Name = DefaultSubRoomName(scene.OrderIndex);
|
||||
|
||||
if (roomsWithoutScenes.Count > 0 || unnamed.Count > 0)
|
||||
await db.SaveChangesAsync();
|
||||
return (roomsWithoutScenes.Count, unnamed.Count);
|
||||
}
|
||||
|
||||
/// <summary>Run once at startup to backfill <see cref="RoomEntity"/>
|
||||
/// + <see cref="DormStateEntity"/> rows for every account that
|
||||
/// pre-dates the auto-create-on-signup work. Idempotent — calls
|
||||
@@ -1068,6 +1133,10 @@ public class RoomService(DorkNetDbContext db)
|
||||
};
|
||||
|
||||
db.Rooms.Add(clone);
|
||||
// Every room owns a real entry sub-room (see RepairRoomScenesAsync).
|
||||
var home = NewHomeScene(clone);
|
||||
home.Id = (await db.RoomScenes.MaxAsync(s => (long?)s.Id) ?? 0L) + 1;
|
||||
db.RoomScenes.Add(home);
|
||||
await db.SaveChangesAsync();
|
||||
return new CloneResult(0 /* Success */, clone);
|
||||
}
|
||||
@@ -1081,6 +1150,25 @@ public class RoomService(DorkNetDbContext db)
|
||||
/// AllowsJuniors, RoomWarningMask, CustomRoomWarning
|
||||
/// Optional: IsDormRoom, SupportsVRLow, SupportsMobile, DisableMicAutoMute.
|
||||
/// </summary>
|
||||
/// <summary>The <c>IsRRO</c> the 2023 client is told: only a first-party
|
||||
/// room (baked/AG and still owned by the system account) is a Rec Room
|
||||
/// Original. A player's room is never one, even when it was cloned from an
|
||||
/// RRO template and keeps <see cref="RoomEntity.IsAGRoom"/> for the baked
|
||||
/// blob handling.
|
||||
///
|
||||
/// <para>The client gates the whole owner UI on it: RoomModel
|
||||
/// get_CanPublishRoom, CanUnpublishRoom, CanDeleteRoom,
|
||||
/// CanChangeRoomCreator, CanChangeRoomSetting, CanChangeCustomWarning,
|
||||
/// CanChangeLoadingScreen and CanCreateNewSubroom all require
|
||||
/// <c>!IsRRO</c> (IsilDump/Assembly-CSharp/RRUI/Data/RoomModel.txt:275,
|
||||
/// 429, 560, 682, 1002, 1473, 1680, 1816), so a clone advertised as RRO
|
||||
/// could never be published ("Cannot publish room") or have its settings
|
||||
/// changed. The room-load pipeline (RecRoom.RoomLoading.Runtime) never
|
||||
/// reads the flag: the scene comes from the sub-room's UnitySceneId and
|
||||
/// the blob step from whether DataBlob is set.</para></summary>
|
||||
public static bool WireIsRRO(RoomEntity r)
|
||||
=> r.IsAGRoom && r.CreatorPlayerId == PlayerService.SystemAccountId;
|
||||
|
||||
public static object ToWireRoom(RoomEntity r) => new
|
||||
{
|
||||
RoomId = r.Id,
|
||||
@@ -1093,7 +1181,7 @@ public class RoomService(DorkNetDbContext db)
|
||||
Accessibility = r.Accessibility,
|
||||
SupportsLevelVoting = r.SupportsLevelVoting,
|
||||
IsAGRoom = r.IsAGRoom,
|
||||
IsRRO = r.IsAGRoom,
|
||||
IsRRO = WireIsRRO(r),
|
||||
// 2020.12 Room.Deserialize (KLCOGEIGEBJ.PPGFHEDFBEA, the Room base)
|
||||
// reads required keys: RoomId, IsDorm, CloningAllowed, DisableMicAutoMute,
|
||||
// DisableRoomComments, EncryptVoiceChat. The 2020.03 key was IsDormRoom;
|
||||
|
||||
Reference in New Issue
Block a user