using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Services;
///
/// The 2023 room-role ladder and who may change whose role.
///
/// Wire enum. The client's room role is OMMBGJMJJPN : byte
/// (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. stores a compact kind instead
/// (0 = CoOwner, 1 = Moderator, 2 = Host); and
/// convert.
///
/// The creator is 255, not 30. The client reads the local
/// player's role straight out of the room details Roles list
/// (FGCPNAACHIK.ONECLLALJEO: 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
/// (OMJKHJLFOCO.DIIMLMKEJLC,
/// IsilDump/RecRoom.RoomPermissions.Runtime/OMJKHJLFOCO.txt:2367:
/// if role == 255 return true; if target >= role return false).
/// Advertising the creator as CoOwner (30) therefore locked the room's own
/// creator out of the Co-Owner and Creator permission rows.
///
/// Server rule (): 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.
///
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;
/// Stored kind → wire role.
public static int ToWire(int storedRole) => storedRole switch
{
StoredCoOwner => WireCoOwner,
StoredModerator => WireModerator,
StoredHost => WireHost,
_ => WireNone,
};
/// Wire role → stored kind. Only the three persistable roles
/// map; None, Banned, TemporaryCoOwner (an in-instance grant) and Creator
/// (ownership, changed through rooms/{id}/creator) do not.
public static bool TryFromWire(int wireRole, out int storedRole)
{
storedRole = wireRole switch
{
WireCoOwner => StoredCoOwner,
WireModerator => StoredModerator,
WireHost => StoredHost,
_ => -1,
};
return storedRole >= 0;
}
/// The player's accepted wire role in the room: 255 for the
/// creator, the highest accepted grant otherwise, 0 for none.
public static int AcceptedRank(RoomEntity room, IEnumerable 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();
}
/// The highest role the player has been invited to and not yet
/// accepted, 0 for none.
public static int InvitedRank(RoomEntity room, IEnumerable rows, long playerId)
=> rows
.Where(r => r.RoomId == room.Id && r.PlayerId == playerId && !r.Accepted)
.Select(r => ToWire(r.Role))
.DefaultIfEmpty(WireNone)
.Max();
/// 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.
public static bool CanEditRoom(RoomEntity room, IEnumerable rows, long playerId)
=> AcceptedRank(room, rows, playerId) >= WireCoOwner;
/// What a role write will do once allows it.
public enum ChangeKind
{
/// Grant or replace the target's role (accepted at once).
Grant,
/// Record a pending invite the target must accept.
Invite,
/// The target accepts their own pending invite.
AcceptInvite,
/// Remove the target's role and any pending invite.
Clear,
}
/// Decide whether may set
/// 's role to .
/// Returns null when allowed, otherwise an error code for the response.
/// is what the caller should then do.
public static string? CheckChange(
RoomEntity room,
IReadOnlyCollection 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;
}
/// True when may write the
/// permission row for : 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.
public static bool CanEditRolePermissions(
RoomEntity room, IEnumerable 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;
}
/// The room details / roles-list rows: one per account, with
/// the creator first as 255 and every other account's accepted role in
/// Role and pending invite in InvitedRole. Rows the
/// creator happens to own in RoomRoles are ignored so the creator is
/// never listed twice (the client takes the first match).
public static List BuildWires(RoomEntity room, IEnumerable rows)
{
var list = new List
{
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;
}
/// One EFHPLDPNGIM row: {AccountId: Int32, Role,
/// InvitedRole, LastChangedByAccountId: Int32?}.
public sealed record RoomRoleWire(int AccountId, int Role, int InvitedRole, int? LastChangedByAccountId);
/// Account ids are Int32 in the 2023 role DTO. Clamp rather than
/// wrap so an out-of-range id is obviously wrong.
public static int ToClientAccountId(long accountId)
=> accountId > int.MaxValue
? int.MaxValue
: accountId < int.MinValue
? int.MinValue
: (int)accountId;
}