diff --git a/README.md b/README.md index 24197fb..c479204 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ Backend: - `RpgRoller/Services/GameStateStore.cs`, `GameStateCloneFactory.cs`, and `GamePersistenceService.cs`: extracted runtime-state ownership and SQLite load/save boundaries used by `GameService` - `RpgRoller/Services/GameAuthService.cs`: extracted auth/session workflow ownership while `GameService` stays on the existing `IGameService` contract - `RpgRoller/Services/GameCampaignService.cs`: extracted campaign creation, visibility reads, roster reads, and deletion workflows behind the same facade contract +- `RpgRoller/Services/GameCharacterService.cs`: extracted character creation, transfer, activation, deletion, and owner-scoped listing workflows behind the same facade contract Frontend: diff --git a/RpgRoller/Services/GameCharacterService.cs b/RpgRoller/Services/GameCharacterService.cs new file mode 100644 index 0000000..f2b440f --- /dev/null +++ b/RpgRoller/Services/GameCharacterService.cs @@ -0,0 +1,264 @@ +using RpgRoller.Contracts; +using RpgRoller.Domain; + +namespace RpgRoller.Services; + +public sealed class GameCharacterService +{ + public GameCharacterService(GameStateStore stateStore, GamePersistenceService persistenceService) + { + m_StateStore = stateStore; + m_PersistenceService = persistenceService; + } + + public ServiceResult CreateCharacter(string sessionToken, string name, Guid campaignId) + { + if (string.IsNullOrWhiteSpace(name)) + return ServiceResult.Failure("invalid_character_name", "Character name is required."); + + lock (m_StateStore.Gate) + { + var user = ResolveUserLocked(sessionToken); + if (user is null) + return ServiceResult.Failure("unauthorized", "You must be logged in."); + + if (!m_StateStore.CampaignsById.ContainsKey(campaignId)) + return ServiceResult.Failure("campaign_not_found", "Campaign was not found."); + + var character = new Character + { + Id = Guid.NewGuid(), + OwnerUserId = user.Id, + CampaignId = campaignId, + Name = name.Trim() + }; + + m_StateStore.CharactersById[character.Id] = character; + AddCharacterStateLocked(character.CampaignId, character.Id); + TouchRosterLocked(character.CampaignId); + + m_PersistenceService.PersistStateLocked(); + return ServiceResult.Success(ToCharacterSummary(character)); + } + } + + public ServiceResult UpdateCharacter(string sessionToken, Guid characterId, string name, Guid? campaignId, string? ownerUsername = null) + { + if (string.IsNullOrWhiteSpace(name)) + return ServiceResult.Failure("invalid_character_name", "Character name is required."); + + lock (m_StateStore.Gate) + { + var user = ResolveUserLocked(sessionToken); + if (user is null) + return ServiceResult.Failure("unauthorized", "You must be logged in."); + + if (!m_StateStore.CharactersById.TryGetValue(characterId, out var character)) + return ServiceResult.Failure("character_not_found", "Character was not found."); + + Campaign? targetCampaign = null; + if (campaignId.HasValue && !m_StateStore.CampaignsById.TryGetValue(campaignId.Value, out targetCampaign)) + return ServiceResult.Failure("campaign_not_found", "Campaign was not found."); + + var isOwner = character.OwnerUserId == user.Id; + var isAdmin = RoleSerializer.HasRole(user.Roles, UserRoles.Admin); + var isSourceGm = character.CampaignId.HasValue && + m_StateStore.CampaignsById.TryGetValue(character.CampaignId.Value, out var sourceCampaign) && + sourceCampaign.GmUserId == user.Id; + var isTargetGm = targetCampaign is not null && targetCampaign.GmUserId == user.Id; + if (!isOwner && !isAdmin && !isSourceGm && !isTargetGm) + return ServiceResult.Failure("forbidden", "Only the owner, GM, or admin can edit this character."); + + var sourceCampaignId = character.CampaignId; + var previousOwnerUserId = character.OwnerUserId; + character.Name = name.Trim(); + character.CampaignId = campaignId; + + if (!string.IsNullOrWhiteSpace(ownerUsername)) + { + var trimmedOwnerUsername = ownerUsername.Trim(); + var normalizedOwnerUsername = NormalizeUsername(trimmedOwnerUsername); + if (!m_StateStore.UserIdsByUsername.TryGetValue(normalizedOwnerUsername, out var targetOwnerUserId)) + return ServiceResult.Failure("owner_not_found", "Owner username was not found."); + + if (targetOwnerUserId != character.OwnerUserId && !isAdmin && !isSourceGm && !isTargetGm) + return ServiceResult.Failure("forbidden", "Only the GM or admin can change character owner."); + + character.OwnerUserId = targetOwnerUserId; + if (character.OwnerUserId != previousOwnerUserId && + m_StateStore.UsersById.TryGetValue(previousOwnerUserId, out var previousOwner) && + previousOwner.ActiveCharacterId == character.Id) + { + previousOwner.ActiveCharacterId = null; + } + } + + if (sourceCampaignId != character.CampaignId) + { + RemoveCharacterStateLocked(sourceCampaignId, character.Id); + AddCharacterStateLocked(character.CampaignId, character.Id); + } + + TouchRosterLocked(sourceCampaignId); + if (sourceCampaignId != character.CampaignId) + TouchRosterLocked(character.CampaignId); + + m_PersistenceService.PersistStateLocked(); + return ServiceResult.Success(ToCharacterSummary(character)); + } + } + + public ServiceResult DeleteCharacter(string sessionToken, Guid characterId) + { + lock (m_StateStore.Gate) + { + var user = ResolveUserLocked(sessionToken); + if (user is null) + return ServiceResult.Failure("unauthorized", "You must be logged in."); + + if (!m_StateStore.CharactersById.TryGetValue(characterId, out var character)) + return ServiceResult.Failure("character_not_found", "Character was not found."); + + var isOwner = character.OwnerUserId == user.Id; + var isAdmin = RoleSerializer.HasRole(user.Roles, UserRoles.Admin); + if (!isOwner && !isAdmin) + return ServiceResult.Failure("forbidden", "Only the owner or admin can delete this character."); + + DeleteCharacterLocked(characterId); + m_PersistenceService.PersistStateLocked(); + return ServiceResult.Success(true); + } + } + + public ServiceResult ActivateCharacter(string sessionToken, Guid characterId) + { + lock (m_StateStore.Gate) + { + var user = ResolveUserLocked(sessionToken); + if (user is null) + return ServiceResult.Failure("unauthorized", "You must be logged in."); + + if (!m_StateStore.CharactersById.TryGetValue(characterId, out var character)) + return ServiceResult.Failure("character_not_found", "Character was not found."); + + if (character.OwnerUserId != user.Id) + return ServiceResult.Failure("forbidden", "You can activate only your own character."); + + user.ActiveCharacterId = character.Id; + m_PersistenceService.PersistStateLocked(); + return ServiceResult.Success(true); + } + } + + public ServiceResult> GetOwnCharacters(string sessionToken) + { + lock (m_StateStore.Gate) + { + var user = ResolveUserLocked(sessionToken); + if (user is null) + return ServiceResult>.Failure("unauthorized", "You must be logged in."); + + var characters = m_StateStore.CharactersById.Values + .Where(character => character.OwnerUserId == user.Id) + .OrderBy(character => character.Name, StringComparer.OrdinalIgnoreCase) + .Select(ToCharacterSummary) + .ToArray(); + + return ServiceResult>.Success(characters); + } + } + + 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 void AddCharacterStateLocked(Guid? campaignId, Guid characterId) + { + 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_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 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 user in m_StateStore.UsersById.Values.Where(account => account.ActiveCharacterId == characterId)) + user.ActiveCharacterId = null; + + RemoveCharacterStateLocked(campaignId, characterId); + TouchRosterLocked(campaignId); + } + + private CharacterSummary ToCharacterSummary(Character character) + { + return new(character.Id, character.Name, character.OwnerUserId, character.CampaignId, ResolveOwnerDisplayName(character.OwnerUserId)); + } + + private string ResolveOwnerDisplayName(Guid ownerUserId) + { + return m_StateStore.UsersById.TryGetValue(ownerUserId, out var user) + ? user.DisplayName + : "Unknown user"; + } + + private static string NormalizeUsername(string username) + { + return username.ToUpperInvariant(); + } + + private readonly GamePersistenceService m_PersistenceService; + private readonly GameStateStore m_StateStore; +} diff --git a/RpgRoller/Services/GameService.cs b/RpgRoller/Services/GameService.cs index 999846b..8eccf76 100644 --- a/RpgRoller/Services/GameService.cs +++ b/RpgRoller/Services/GameService.cs @@ -25,6 +25,7 @@ public sealed class GameService : IGameService 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_DiceRoller = diceRoller; LoadStateFromDatabase(); } @@ -193,155 +194,27 @@ public sealed class GameService : IGameService public ServiceResult CreateCharacter(string sessionToken, string name, Guid campaignId) { - if (string.IsNullOrWhiteSpace(name)) - return ServiceResult.Failure("invalid_character_name", "Character name is required."); - - lock (m_Gate) - { - var user = ResolveUserLocked(sessionToken); - if (user is null) - return ServiceResult.Failure("unauthorized", "You must be logged in."); - - if (!m_CampaignsById.ContainsKey(campaignId)) - return ServiceResult.Failure("campaign_not_found", "Campaign was not found."); - - var character = new Character - { - Id = Guid.NewGuid(), - OwnerUserId = user.Id, - CampaignId = campaignId, - Name = name.Trim() - }; - - m_CharactersById[character.Id] = character; - AddCharacterStateLocked(character.CampaignId, character.Id); - TouchRosterLocked(character.CampaignId); - - PersistStateLocked(); - return ServiceResult.Success(ToCharacterSummary(character)); - } + return m_CharacterService.CreateCharacter(sessionToken, name, campaignId); } public ServiceResult UpdateCharacter(string sessionToken, Guid characterId, string name, Guid? campaignId, string? ownerUsername = null) { - if (string.IsNullOrWhiteSpace(name)) - return ServiceResult.Failure("invalid_character_name", "Character name is required."); - - lock (m_Gate) - { - var user = ResolveUserLocked(sessionToken); - if (user is null) - return ServiceResult.Failure("unauthorized", "You must be logged in."); - - if (!m_CharactersById.TryGetValue(characterId, out var character)) - return ServiceResult.Failure("character_not_found", "Character was not found."); - - Campaign? targetCampaign = null; - if (campaignId.HasValue && !m_CampaignsById.TryGetValue(campaignId.Value, out targetCampaign)) - return ServiceResult.Failure("campaign_not_found", "Campaign was not found."); - - var isOwner = character.OwnerUserId == user.Id; - var isAdmin = UserHasRoleLocked(user, UserRoles.Admin); - var isSourceGm = character.CampaignId.HasValue && - m_CampaignsById.TryGetValue(character.CampaignId.Value, out var sourceCampaign) && - sourceCampaign.GmUserId == user.Id; - var isTargetGm = targetCampaign is not null && targetCampaign.GmUserId == user.Id; - if (!isOwner && !isAdmin && !isSourceGm && !isTargetGm) - return ServiceResult.Failure("forbidden", "Only the owner, GM, or admin can edit this character."); - - var sourceCampaignId = character.CampaignId; - var previousOwnerUserId = character.OwnerUserId; - character.Name = name.Trim(); - character.CampaignId = campaignId; - - if (!string.IsNullOrWhiteSpace(ownerUsername)) - { - var trimmedOwnerUsername = ownerUsername.Trim(); - var normalizedOwnerUsername = NormalizeUsername(trimmedOwnerUsername); - if (!m_UserIdsByUsername.TryGetValue(normalizedOwnerUsername, out var targetOwnerUserId)) - return ServiceResult.Failure("owner_not_found", "Owner username was not found."); - - if (targetOwnerUserId != character.OwnerUserId && !isAdmin && !isSourceGm && !isTargetGm) - return ServiceResult.Failure("forbidden", "Only the GM or admin can change character owner."); - - character.OwnerUserId = targetOwnerUserId; - if (character.OwnerUserId != previousOwnerUserId && - m_UsersById.TryGetValue(previousOwnerUserId, out var previousOwner) && - previousOwner.ActiveCharacterId == character.Id) - { - previousOwner.ActiveCharacterId = null; - } - } - - if (sourceCampaignId != character.CampaignId) - { - RemoveCharacterStateLocked(sourceCampaignId, character.Id); - AddCharacterStateLocked(character.CampaignId, character.Id); - } - - TouchRosterLocked(sourceCampaignId); - if (sourceCampaignId != character.CampaignId) - TouchRosterLocked(character.CampaignId); - - PersistStateLocked(); - return ServiceResult.Success(ToCharacterSummary(character)); - } + return m_CharacterService.UpdateCharacter(sessionToken, characterId, name, campaignId, ownerUsername); } public ServiceResult DeleteCharacter(string sessionToken, Guid characterId) { - lock (m_Gate) - { - var user = ResolveUserLocked(sessionToken); - if (user is null) - return ServiceResult.Failure("unauthorized", "You must be logged in."); - - if (!m_CharactersById.TryGetValue(characterId, out var character)) - return ServiceResult.Failure("character_not_found", "Character was not found."); - - var isOwner = character.OwnerUserId == user.Id; - var isAdmin = UserHasRoleLocked(user, UserRoles.Admin); - if (!isOwner && !isAdmin) - return ServiceResult.Failure("forbidden", "Only the owner or admin can delete this character."); - - DeleteCharacterLocked(characterId); - PersistStateLocked(); - return ServiceResult.Success(true); - } + return m_CharacterService.DeleteCharacter(sessionToken, characterId); } public ServiceResult ActivateCharacter(string sessionToken, Guid characterId) { - lock (m_Gate) - { - var user = ResolveUserLocked(sessionToken); - if (user is null) - return ServiceResult.Failure("unauthorized", "You must be logged in."); - - if (!m_CharactersById.TryGetValue(characterId, out var character)) - return ServiceResult.Failure("character_not_found", "Character was not found."); - - if (character.OwnerUserId != user.Id) - return ServiceResult.Failure("forbidden", "You can activate only your own character."); - - user.ActiveCharacterId = character.Id; - PersistStateLocked(); - return ServiceResult.Success(true); - } + return m_CharacterService.ActivateCharacter(sessionToken, characterId); } public ServiceResult> GetOwnCharacters(string sessionToken) { - lock (m_Gate) - { - var user = ResolveUserLocked(sessionToken); - if (user is null) - return ServiceResult>.Failure("unauthorized", "You must be logged in."); - - var characters = m_CharactersById.Values.Where(c => c.OwnerUserId == user.Id).OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase).Select(ToCharacterSummary).ToArray(); - - return ServiceResult>.Success(characters); - } + return m_CharacterService.GetOwnCharacters(sessionToken); } public ServiceResult CreateSkillGroup(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, int? fumbleRange = null) @@ -1067,12 +940,6 @@ public sealed class GameService : IGameService return new(characterId, skillGroups, skills); } - private CharacterSummary ToCharacterSummary(Character character) - { - var ownerDisplayName = ResolveOwnerDisplayName(character.OwnerUserId); - return new(character.Id, character.Name, character.OwnerUserId, character.CampaignId, ownerDisplayName); - } - private CampaignStateSnapshot ToCampaignStateSnapshot(Campaign campaign) { var state = GetOrCreateCampaignStateLocked(campaign.Id); @@ -1538,11 +1405,6 @@ public sealed class GameService : IGameService m_PersistenceService.PersistStateLocked(); } - private static string NormalizeUsername(string username) - { - return username.ToUpperInvariant(); - } - private static int NormalizeCampaignLogPageSize(int? limit) { if (!limit.HasValue) @@ -1560,6 +1422,7 @@ public sealed class GameService : IGameService private readonly Dictionary m_CampaignsById; private readonly Dictionary m_CampaignStateById; private readonly GameCampaignService m_CampaignService; + private readonly GameCharacterService m_CharacterService; private readonly Dictionary m_CharactersById; private readonly GameAuthService m_AuthService; private readonly IDiceRoller m_DiceRoller;