Extract game user admin service

This commit is contained in:
2026-04-04 23:51:33 +02:00
parent 3d7f3d1ee4
commit 17b049d2ca
4 changed files with 255 additions and 247 deletions

View File

@@ -11,22 +11,14 @@ public sealed class GameService : IGameService
public GameService(IDbContextFactory<RpgRollerDbContext> dbContextFactory, IPasswordHasher<UserAccount> passwordHasher, IDiceRoller diceRoller)
{
m_StateStore = new();
m_CampaignsById = m_StateStore.CampaignsById;
m_CampaignStateById = m_StateStore.CampaignStateById;
m_CharactersById = m_StateStore.CharactersById;
m_Gate = m_StateStore.Gate;
m_RollLog = m_StateStore.RollLog;
m_SessionsByToken = m_StateStore.SessionsByToken;
m_SkillGroupsById = m_StateStore.SkillGroupsById;
m_SkillsById = m_StateStore.SkillsById;
m_UserIdsByUsername = m_StateStore.UserIdsByUsername;
m_UsersById = m_StateStore.UsersById;
m_PersistenceService = new(dbContextFactory, m_StateStore);
m_AuthService = new(m_StateStore, passwordHasher, m_PersistenceService);
m_CampaignService = new(m_StateStore, m_PersistenceService);
m_CharacterService = new(m_StateStore, m_PersistenceService);
m_RollService = new(m_StateStore, m_PersistenceService, diceRoller);
m_SkillService = new(m_StateStore, m_PersistenceService);
m_UserAdministrationService = new(m_StateStore, m_PersistenceService);
LoadStateFromDatabase();
}
@@ -87,109 +79,22 @@ public sealed class GameService : IGameService
public ServiceResult<IReadOnlyList<string>> GetUsernames(string sessionToken)
{
lock (m_Gate)
{
var user = ResolveUserLocked(sessionToken);
if (user is null)
return ServiceResult<IReadOnlyList<string>>.Failure("unauthorized", "You must be logged in.");
var usernames = m_UsersById.Values.Select(account => account.Username).OrderBy(username => username, StringComparer.OrdinalIgnoreCase).ToArray();
return ServiceResult<IReadOnlyList<string>>.Success(usernames);
}
return m_UserAdministrationService.GetUsernames(sessionToken);
}
public ServiceResult<IReadOnlyList<AdminUserSummary>> GetUsers(string sessionToken)
{
lock (m_Gate)
{
var user = ResolveUserLocked(sessionToken);
if (user is null)
return ServiceResult<IReadOnlyList<AdminUserSummary>>.Failure("unauthorized", "You must be logged in.");
if (!UserHasRoleLocked(user, UserRoles.Admin))
return ServiceResult<IReadOnlyList<AdminUserSummary>>.Failure("forbidden", "Admin role is required.");
var users = m_UsersById.Values
.OrderBy(account => account.Username, StringComparer.OrdinalIgnoreCase)
.Select(ToAdminUserSummary)
.ToArray();
return ServiceResult<IReadOnlyList<AdminUserSummary>>.Success(users);
}
return m_UserAdministrationService.GetUsers(sessionToken);
}
public ServiceResult<AdminUserSummary> UpdateUserRoles(string sessionToken, Guid userId, IReadOnlyList<string> roles)
{
lock (m_Gate)
{
var user = ResolveUserLocked(sessionToken);
if (user is null)
return ServiceResult<AdminUserSummary>.Failure("unauthorized", "You must be logged in.");
if (!UserHasRoleLocked(user, UserRoles.Admin))
return ServiceResult<AdminUserSummary>.Failure("forbidden", "Admin role is required.");
if (!m_UsersById.TryGetValue(userId, out var targetUser))
return ServiceResult<AdminUserSummary>.Failure("user_not_found", "User was not found.");
var normalizedRoles = RoleSerializer.Normalize(roles);
if (normalizedRoles.Any(role => !string.Equals(role, UserRoles.Admin, StringComparison.Ordinal)))
return ServiceResult<AdminUserSummary>.Failure("invalid_role", "Unsupported role.");
if (user.Id == targetUser.Id && !normalizedRoles.Contains(UserRoles.Admin, StringComparer.Ordinal))
return ServiceResult<AdminUserSummary>.Failure("forbidden", "You cannot remove your own admin role.");
targetUser.Roles = RoleSerializer.Serialize(normalizedRoles);
PersistStateLocked();
return ServiceResult<AdminUserSummary>.Success(ToAdminUserSummary(targetUser));
}
return m_UserAdministrationService.UpdateUserRoles(sessionToken, userId, roles);
}
public ServiceResult<bool> DeleteUser(string sessionToken, Guid userId)
{
lock (m_Gate)
{
var user = ResolveUserLocked(sessionToken);
if (user is null)
return ServiceResult<bool>.Failure("unauthorized", "You must be logged in.");
if (!UserHasRoleLocked(user, UserRoles.Admin))
return ServiceResult<bool>.Failure("forbidden", "Admin role is required.");
if (user.Id == userId)
return ServiceResult<bool>.Failure("forbidden", "You cannot delete your own account.");
if (!m_UsersById.TryGetValue(userId, out var targetUser))
return ServiceResult<bool>.Failure("user_not_found", "User was not found.");
var gmCampaignIds = m_CampaignsById.Values.Where(campaign => campaign.GmUserId == targetUser.Id).Select(campaign => campaign.Id).ToArray();
var gmCampaignIdSet = gmCampaignIds.ToHashSet();
var preservedCharacterIds = m_CharactersById.Values
.Where(character => character.CampaignId.HasValue && gmCampaignIdSet.Contains(character.CampaignId.Value))
.Select(character => character.Id)
.ToHashSet();
foreach (var campaignId in gmCampaignIds)
DeleteCampaignLocked(campaignId);
var ownedCharacterIds = m_CharactersById.Values
.Where(character => character.OwnerUserId == targetUser.Id && !preservedCharacterIds.Contains(character.Id))
.Select(character => character.Id)
.ToArray();
foreach (var characterId in ownedCharacterIds)
DeleteCharacterLocked(characterId);
m_RollLog.RemoveAll(entry => entry.RollerUserId == targetUser.Id);
var staleSessions = m_SessionsByToken.Values.Where(session => session.UserId == targetUser.Id).Select(session => session.Token).ToArray();
foreach (var token in staleSessions)
m_SessionsByToken.Remove(token);
m_UsersById.Remove(targetUser.Id);
m_UserIdsByUsername.Remove(targetUser.UsernameNormalized);
PersistStateLocked();
return ServiceResult<bool>.Success(true);
}
return m_UserAdministrationService.DeleteUser(sessionToken, userId);
}
public ServiceResult<CharacterSummary> CreateCharacter(string sessionToken, string name, Guid campaignId)
@@ -282,110 +187,12 @@ public sealed class GameService : IGameService
return m_RollService.GetCampaignStateSnapshot(sessionToken, campaignId);
}
private static UserSummary ToUserSummary(UserAccount user)
{
return new(user.Id, user.Username, user.DisplayName, RoleSerializer.Parse(user.Roles));
}
private static AdminUserSummary ToAdminUserSummary(UserAccount user)
{
return new(user.Id, user.Username, user.DisplayName, RoleSerializer.Parse(user.Roles));
}
private bool TryGetCurrentCampaignIdLocked(UserAccount user, out Guid campaignId)
{
campaignId = Guid.Empty;
if (user.ActiveCharacterId is not Guid activeCharacterId)
return false;
if (!m_CharactersById.TryGetValue(activeCharacterId, out var character))
{
user.ActiveCharacterId = null;
PersistStateLocked();
return false;
}
if (!character.CampaignId.HasValue)
return false;
campaignId = character.CampaignId.Value;
return true;
}
private bool TryResolveCharacterCampaignLocked(Character character, out Campaign campaign, out ServiceError? error)
{
campaign = default!;
if (!character.CampaignId.HasValue || !m_CampaignsById.TryGetValue(character.CampaignId.Value, out var resolvedCampaign) || resolvedCampaign is null)
{
error = new ServiceError("character_not_in_campaign", "Character is not linked to a campaign.");
return false;
}
campaign = resolvedCampaign;
error = null;
return true;
}
private void DeleteCampaignLocked(Guid campaignId)
{
if (!m_CampaignsById.Remove(campaignId))
return;
var affectedCharacterIds = m_CharactersById.Values.Where(character => character.CampaignId == campaignId).Select(character => character.Id).ToArray();
foreach (var characterId in affectedCharacterIds)
m_CharactersById[characterId].CampaignId = null;
m_RollLog.RemoveAll(entry => entry.CampaignId == campaignId);
m_CampaignStateById.Remove(campaignId);
}
private void DeleteCharacterLocked(Guid characterId)
{
if (!m_CharactersById.TryGetValue(characterId, out var character))
return;
var campaignId = character.CampaignId;
m_CharactersById.Remove(characterId);
var skillGroupIds = m_SkillGroupsById.Values.Where(group => group.CharacterId == characterId).Select(group => group.Id).ToHashSet();
foreach (var skillGroupId in skillGroupIds)
m_SkillGroupsById.Remove(skillGroupId);
var skillIds = m_SkillsById.Values.Where(skill => skill.CharacterId == characterId).Select(skill => skill.Id).ToHashSet();
foreach (var skillId in skillIds)
m_SkillsById.Remove(skillId);
m_RollLog.RemoveAll(entry => entry.CharacterId == characterId || skillIds.Contains(entry.SkillId));
foreach (var user in m_UsersById.Values.Where(account => account.ActiveCharacterId == characterId))
user.ActiveCharacterId = null;
RemoveCharacterStateLocked(campaignId, characterId);
TouchRosterLocked(campaignId);
}
private static bool UserHasRoleLocked(UserAccount user, string role)
{
return RoleSerializer.HasRole(user.Roles, role);
}
private UserAccount? ResolveUserLocked(string sessionToken)
{
if (string.IsNullOrWhiteSpace(sessionToken))
return null;
if (!m_SessionsByToken.TryGetValue(sessionToken, out var session))
return null;
return m_UsersById.GetValueOrDefault(session.UserId);
}
private GameCampaignStateTracker GetOrCreateCampaignStateLocked(Guid campaignId)
{
if (!m_CampaignStateById.TryGetValue(campaignId, out var state))
if (!m_StateStore.CampaignStateById.TryGetValue(campaignId, out var state))
{
state = new GameCampaignStateTracker();
m_CampaignStateById[campaignId] = state;
m_StateStore.CampaignStateById[campaignId] = state;
}
return state;
@@ -393,49 +200,21 @@ public sealed class GameService : IGameService
private void AddCharacterStateLocked(Guid? campaignId, Guid characterId)
{
if (!campaignId.HasValue || !m_CampaignsById.ContainsKey(campaignId.Value))
if (!campaignId.HasValue || !m_StateStore.CampaignsById.ContainsKey(campaignId.Value))
return;
var state = GetOrCreateCampaignStateLocked(campaignId.Value);
state.CharacterVersions[characterId] = 1;
}
private void RemoveCharacterStateLocked(Guid? campaignId, Guid characterId)
{
if (!campaignId.HasValue || !m_CampaignStateById.TryGetValue(campaignId.Value, out var state))
return;
state.CharacterVersions.Remove(characterId);
}
private void TouchRosterLocked(Guid? campaignId)
{
if (!campaignId.HasValue || !m_CampaignsById.ContainsKey(campaignId.Value))
return;
var state = GetOrCreateCampaignStateLocked(campaignId.Value);
state.TotalVersion += 1;
state.RosterVersion += 1;
}
private void TouchLogLocked(Guid? campaignId)
{
if (!campaignId.HasValue || !m_CampaignsById.ContainsKey(campaignId.Value))
return;
var state = GetOrCreateCampaignStateLocked(campaignId.Value);
state.TotalVersion += 1;
state.LogVersion += 1;
}
private void RebuildCampaignStateLocked()
{
m_CampaignStateById.Clear();
m_StateStore.CampaignStateById.Clear();
foreach (var campaignId in m_CampaignsById.Keys)
m_CampaignStateById[campaignId] = new GameCampaignStateTracker();
foreach (var campaignId in m_StateStore.CampaignsById.Keys)
m_StateStore.CampaignStateById[campaignId] = new GameCampaignStateTracker();
foreach (var character in m_CharactersById.Values.Where(character => character.CampaignId.HasValue))
foreach (var character in m_StateStore.CharactersById.Values.Where(character => character.CampaignId.HasValue))
AddCharacterStateLocked(character.CampaignId, character.Id);
}
@@ -446,26 +225,13 @@ public sealed class GameService : IGameService
RebuildCampaignStateLocked();
}
private void PersistStateLocked()
{
m_PersistenceService.PersistStateLocked();
}
private readonly Dictionary<Guid, Campaign> m_CampaignsById;
private readonly Dictionary<Guid, GameCampaignStateTracker> m_CampaignStateById;
private readonly GameCampaignService m_CampaignService;
private readonly GameCharacterService m_CharacterService;
private readonly Dictionary<Guid, Character> m_CharactersById;
private readonly GameAuthService m_AuthService;
private readonly object m_Gate;
private readonly GamePersistenceService m_PersistenceService;
private readonly List<RollLogEntry> m_RollLog;
private readonly GameRollService m_RollService;
private readonly Dictionary<string, UserSession> m_SessionsByToken;
private readonly GameSkillService m_SkillService;
private readonly Dictionary<Guid, SkillGroup> m_SkillGroupsById;
private readonly Dictionary<Guid, Skill> m_SkillsById;
private readonly GameStateStore m_StateStore;
private readonly Dictionary<string, Guid> m_UserIdsByUsername;
private readonly Dictionary<Guid, UserAccount> m_UsersById;
private readonly GameUserAdministrationService m_UserAdministrationService;
}

View File

@@ -0,0 +1,231 @@
using RpgRoller.Contracts;
using RpgRoller.Domain;
namespace RpgRoller.Services;
public sealed class GameUserAdministrationService
{
public GameUserAdministrationService(GameStateStore stateStore, GamePersistenceService persistenceService)
{
m_StateStore = stateStore;
m_PersistenceService = persistenceService;
}
public ServiceResult<IReadOnlyList<string>> GetUsernames(string sessionToken)
{
lock (m_StateStore.Gate)
{
var user = ResolveUserLocked(sessionToken);
if (user is null)
return ServiceResult<IReadOnlyList<string>>.Failure("unauthorized", "You must be logged in.");
var usernames = m_StateStore.UsersById.Values
.Select(account => account.Username)
.OrderBy(username => username, StringComparer.OrdinalIgnoreCase)
.ToArray();
return ServiceResult<IReadOnlyList<string>>.Success(usernames);
}
}
public ServiceResult<IReadOnlyList<AdminUserSummary>> GetUsers(string sessionToken)
{
lock (m_StateStore.Gate)
{
var user = ResolveUserLocked(sessionToken);
if (user is null)
return ServiceResult<IReadOnlyList<AdminUserSummary>>.Failure("unauthorized", "You must be logged in.");
if (!UserHasRoleLocked(user, UserRoles.Admin))
return ServiceResult<IReadOnlyList<AdminUserSummary>>.Failure("forbidden", "Admin role is required.");
var users = m_StateStore.UsersById.Values
.OrderBy(account => account.Username, StringComparer.OrdinalIgnoreCase)
.Select(ToAdminUserSummary)
.ToArray();
return ServiceResult<IReadOnlyList<AdminUserSummary>>.Success(users);
}
}
public ServiceResult<AdminUserSummary> UpdateUserRoles(string sessionToken, Guid userId, IReadOnlyList<string> roles)
{
lock (m_StateStore.Gate)
{
var user = ResolveUserLocked(sessionToken);
if (user is null)
return ServiceResult<AdminUserSummary>.Failure("unauthorized", "You must be logged in.");
if (!UserHasRoleLocked(user, UserRoles.Admin))
return ServiceResult<AdminUserSummary>.Failure("forbidden", "Admin role is required.");
if (!m_StateStore.UsersById.TryGetValue(userId, out var targetUser))
return ServiceResult<AdminUserSummary>.Failure("user_not_found", "User was not found.");
var normalizedRoles = RoleSerializer.Normalize(roles);
if (normalizedRoles.Any(role => !string.Equals(role, UserRoles.Admin, StringComparison.Ordinal)))
return ServiceResult<AdminUserSummary>.Failure("invalid_role", "Unsupported role.");
if (user.Id == targetUser.Id && !normalizedRoles.Contains(UserRoles.Admin, StringComparer.Ordinal))
return ServiceResult<AdminUserSummary>.Failure("forbidden", "You cannot remove your own admin role.");
targetUser.Roles = RoleSerializer.Serialize(normalizedRoles);
m_PersistenceService.PersistStateLocked();
return ServiceResult<AdminUserSummary>.Success(ToAdminUserSummary(targetUser));
}
}
public ServiceResult<bool> DeleteUser(string sessionToken, Guid userId)
{
lock (m_StateStore.Gate)
{
var user = ResolveUserLocked(sessionToken);
if (user is null)
return ServiceResult<bool>.Failure("unauthorized", "You must be logged in.");
if (!UserHasRoleLocked(user, UserRoles.Admin))
return ServiceResult<bool>.Failure("forbidden", "Admin role is required.");
if (user.Id == userId)
return ServiceResult<bool>.Failure("forbidden", "You cannot delete your own account.");
if (!m_StateStore.UsersById.TryGetValue(userId, out var targetUser))
return ServiceResult<bool>.Failure("user_not_found", "User was not found.");
var gmCampaignIds = m_StateStore.CampaignsById.Values
.Where(campaign => campaign.GmUserId == targetUser.Id)
.Select(campaign => campaign.Id)
.ToArray();
var gmCampaignIdSet = gmCampaignIds.ToHashSet();
var preservedCharacterIds = m_StateStore.CharactersById.Values
.Where(character => character.CampaignId.HasValue && gmCampaignIdSet.Contains(character.CampaignId.Value))
.Select(character => character.Id)
.ToHashSet();
foreach (var campaignId in gmCampaignIds)
DeleteCampaignLocked(campaignId);
var ownedCharacterIds = m_StateStore.CharactersById.Values
.Where(character => character.OwnerUserId == targetUser.Id && !preservedCharacterIds.Contains(character.Id))
.Select(character => character.Id)
.ToArray();
foreach (var characterId in ownedCharacterIds)
DeleteCharacterLocked(characterId);
m_StateStore.RollLog.RemoveAll(entry => entry.RollerUserId == targetUser.Id);
var staleSessions = m_StateStore.SessionsByToken.Values
.Where(session => session.UserId == targetUser.Id)
.Select(session => session.Token)
.ToArray();
foreach (var token in staleSessions)
m_StateStore.SessionsByToken.Remove(token);
m_StateStore.UsersById.Remove(targetUser.Id);
m_StateStore.UserIdsByUsername.Remove(targetUser.UsernameNormalized);
m_PersistenceService.PersistStateLocked();
return ServiceResult<bool>.Success(true);
}
}
private UserAccount? ResolveUserLocked(string sessionToken)
{
if (string.IsNullOrWhiteSpace(sessionToken))
return null;
if (!m_StateStore.SessionsByToken.TryGetValue(sessionToken, out var session))
return null;
return m_StateStore.UsersById.GetValueOrDefault(session.UserId);
}
private static bool UserHasRoleLocked(UserAccount user, string role)
{
return RoleSerializer.HasRole(user.Roles, role);
}
private static AdminUserSummary ToAdminUserSummary(UserAccount user)
{
return new(user.Id, user.Username, user.DisplayName, RoleSerializer.Parse(user.Roles));
}
private void DeleteCampaignLocked(Guid campaignId)
{
if (!m_StateStore.CampaignsById.Remove(campaignId))
return;
var affectedCharacterIds = m_StateStore.CharactersById.Values
.Where(character => character.CampaignId == campaignId)
.Select(character => character.Id)
.ToArray();
foreach (var characterId in affectedCharacterIds)
m_StateStore.CharactersById[characterId].CampaignId = null;
m_StateStore.RollLog.RemoveAll(entry => entry.CampaignId == campaignId);
m_StateStore.CampaignStateById.Remove(campaignId);
}
private void DeleteCharacterLocked(Guid characterId)
{
if (!m_StateStore.CharactersById.TryGetValue(characterId, out var character))
return;
var campaignId = character.CampaignId;
m_StateStore.CharactersById.Remove(characterId);
var skillGroupIds = m_StateStore.SkillGroupsById.Values
.Where(group => group.CharacterId == characterId)
.Select(group => group.Id)
.ToHashSet();
foreach (var skillGroupId in skillGroupIds)
m_StateStore.SkillGroupsById.Remove(skillGroupId);
var skillIds = m_StateStore.SkillsById.Values
.Where(skill => skill.CharacterId == characterId)
.Select(skill => skill.Id)
.ToHashSet();
foreach (var skillId in skillIds)
m_StateStore.SkillsById.Remove(skillId);
m_StateStore.RollLog.RemoveAll(entry => entry.CharacterId == characterId || skillIds.Contains(entry.SkillId));
foreach (var account in m_StateStore.UsersById.Values.Where(account => account.ActiveCharacterId == characterId))
account.ActiveCharacterId = null;
RemoveCharacterStateLocked(campaignId, characterId);
TouchRosterLocked(campaignId);
}
private void RemoveCharacterStateLocked(Guid? campaignId, Guid characterId)
{
if (!campaignId.HasValue || !m_StateStore.CampaignStateById.TryGetValue(campaignId.Value, out var state))
return;
state.CharacterVersions.Remove(characterId);
}
private void TouchRosterLocked(Guid? campaignId)
{
if (!campaignId.HasValue || !m_StateStore.CampaignsById.ContainsKey(campaignId.Value))
return;
var state = GetOrCreateCampaignStateLocked(campaignId.Value);
state.TotalVersion += 1;
state.RosterVersion += 1;
}
private GameCampaignStateTracker GetOrCreateCampaignStateLocked(Guid campaignId)
{
if (!m_StateStore.CampaignStateById.TryGetValue(campaignId, out var state))
{
state = new GameCampaignStateTracker();
m_StateStore.CampaignStateById[campaignId] = state;
}
return state;
}
private readonly GamePersistenceService m_PersistenceService;
private readonly GameStateStore m_StateStore;
}