From 9479b2e2f360c379d9d0343392c600dbf5feef84 Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Sat, 4 Apr 2026 23:30:44 +0200 Subject: [PATCH] Extract game skill service --- README.md | 1 + RpgRoller/Services/GameService.cs | 287 +------------------ RpgRoller/Services/GameSkillService.cs | 375 +++++++++++++++++++++++++ 3 files changed, 385 insertions(+), 278 deletions(-) create mode 100644 RpgRoller/Services/GameSkillService.cs diff --git a/README.md b/README.md index c479204..b8f52aa 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ Backend: - `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 +- `RpgRoller/Services/GameSkillService.cs`: extracted skill-group CRUD, skill CRUD, sheet shaping, and ruleset-specific validation orchestration behind the same facade contract Frontend: diff --git a/RpgRoller/Services/GameService.cs b/RpgRoller/Services/GameService.cs index 8eccf76..02c631f 100644 --- a/RpgRoller/Services/GameService.cs +++ b/RpgRoller/Services/GameService.cs @@ -26,6 +26,7 @@ public sealed class GameService : IGameService m_AuthService = new(m_StateStore, passwordHasher, m_PersistenceService); m_CampaignService = new(m_StateStore, m_PersistenceService); m_CharacterService = new(m_StateStore, m_PersistenceService); + m_SkillService = new(m_StateStore, m_PersistenceService); m_DiceRoller = diceRoller; LoadStateFromDatabase(); } @@ -219,248 +220,37 @@ public sealed class GameService : IGameService public ServiceResult CreateSkillGroup(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, int? fumbleRange = null) { - if (string.IsNullOrWhiteSpace(name)) - return ServiceResult.Failure("invalid_skill_group_name", "Skill group 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."); - - if (!TryResolveCharacterCampaignLocked(character, out var campaign, out var campaignError)) - return ServiceResult.Failure(campaignError!.Code, campaignError.Message); - - if (!CanEditCharacterLocked(user.Id, character, campaign)) - return ServiceResult.Failure("forbidden", "Only the owner or GM can manage skill groups."); - - var prototypeValidation = SkillDefinitionValidator.Validate(campaign.Ruleset, diceRollDefinition, wildDice, allowFumble, fumbleRange); - if (!prototypeValidation.Succeeded) - return ServiceResult.Failure(prototypeValidation.Error!.Code, prototypeValidation.Error.Message); - - var group = new SkillGroup - { - Id = Guid.NewGuid(), - CharacterId = character.Id, - Name = name.Trim(), - DiceRollDefinition = prototypeValidation.Value!.CanonicalExpression, - WildDice = prototypeValidation.Value.WildDice, - AllowFumble = prototypeValidation.Value.AllowFumble, - FumbleRange = prototypeValidation.Value.FumbleRange - }; - - m_SkillGroupsById[group.Id] = group; - TouchCharacterLocked(campaign.Id, character.Id); - - PersistStateLocked(); - return ServiceResult.Success(ToSkillGroupSummary(group)); - } + return m_SkillService.CreateSkillGroup(sessionToken, characterId, name, diceRollDefinition, wildDice, allowFumble, fumbleRange); } public ServiceResult UpdateSkillGroup(string sessionToken, Guid skillGroupId, string name, string diceRollDefinition, int wildDice, bool allowFumble, int? fumbleRange = null) { - if (string.IsNullOrWhiteSpace(name)) - return ServiceResult.Failure("invalid_skill_group_name", "Skill group 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_SkillGroupsById.TryGetValue(skillGroupId, out var group)) - return ServiceResult.Failure("skill_group_not_found", "Skill group was not found."); - - var character = m_CharactersById[group.CharacterId]; - if (!TryResolveCharacterCampaignLocked(character, out var campaign, out var campaignError)) - return ServiceResult.Failure(campaignError!.Code, campaignError.Message); - - if (!CanEditCharacterLocked(user.Id, character, campaign)) - return ServiceResult.Failure("forbidden", "Only the owner or GM can manage skill groups."); - - var prototypeValidation = SkillDefinitionValidator.Validate(campaign.Ruleset, diceRollDefinition, wildDice, allowFumble, fumbleRange); - if (!prototypeValidation.Succeeded) - return ServiceResult.Failure(prototypeValidation.Error!.Code, prototypeValidation.Error.Message); - - group.Name = name.Trim(); - group.DiceRollDefinition = prototypeValidation.Value!.CanonicalExpression; - group.WildDice = prototypeValidation.Value.WildDice; - group.AllowFumble = prototypeValidation.Value.AllowFumble; - group.FumbleRange = prototypeValidation.Value.FumbleRange; - TouchCharacterLocked(campaign.Id, character.Id); - - PersistStateLocked(); - return ServiceResult.Success(ToSkillGroupSummary(group)); - } + return m_SkillService.UpdateSkillGroup(sessionToken, skillGroupId, name, diceRollDefinition, wildDice, allowFumble, fumbleRange); } public ServiceResult DeleteSkillGroup(string sessionToken, Guid skillGroupId) { - lock (m_Gate) - { - var user = ResolveUserLocked(sessionToken); - if (user is null) - return ServiceResult.Failure("unauthorized", "You must be logged in."); - - if (!m_SkillGroupsById.TryGetValue(skillGroupId, out var group)) - return ServiceResult.Failure("skill_group_not_found", "Skill group was not found."); - - var character = m_CharactersById[group.CharacterId]; - if (!TryResolveCharacterCampaignLocked(character, out var campaign, out var campaignError)) - return ServiceResult.Failure(campaignError!.Code, campaignError.Message); - - if (!CanEditCharacterLocked(user.Id, character, campaign)) - return ServiceResult.Failure("forbidden", "Only the owner or GM can manage skill groups."); - - foreach (var skill in m_SkillsById.Values.Where(skill => skill.SkillGroupId == group.Id)) - skill.SkillGroupId = null; - - m_SkillGroupsById.Remove(group.Id); - TouchCharacterLocked(campaign.Id, character.Id); - - PersistStateLocked(); - return ServiceResult.Success(true); - } + return m_SkillService.DeleteSkillGroup(sessionToken, skillGroupId); } public ServiceResult CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null) { - if (string.IsNullOrWhiteSpace(name)) - return ServiceResult.Failure("invalid_skill_name", "Skill 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."); - - if (!TryResolveCharacterCampaignLocked(character, out var campaign, out var campaignError)) - return ServiceResult.Failure(campaignError!.Code, campaignError.Message); - - if (!CanEditCharacterLocked(user.Id, character, campaign)) - return ServiceResult.Failure("forbidden", "Only the owner or GM can edit skills."); - - var skillValidation = SkillDefinitionValidator.Validate(campaign.Ruleset, diceRollDefinition, wildDice, allowFumble, fumbleRange); - if (!skillValidation.Succeeded) - return ServiceResult.Failure(skillValidation.Error!.Code, skillValidation.Error.Message); - - var resolvedSkillGroupId = ResolveSkillGroupForSkillChangeLocked(skillGroupId, character.Id); - if (!resolvedSkillGroupId.Succeeded) - return ServiceResult.Failure(resolvedSkillGroupId.Error!.Code, resolvedSkillGroupId.Error.Message); - - var skill = new Skill - { - Id = Guid.NewGuid(), - CharacterId = character.Id, - SkillGroupId = resolvedSkillGroupId.Value, - Name = name.Trim(), - DiceRollDefinition = skillValidation.Value!.CanonicalExpression, - WildDice = skillValidation.Value.WildDice, - AllowFumble = skillValidation.Value.AllowFumble, - FumbleRange = skillValidation.Value.FumbleRange - }; - - m_SkillsById[skill.Id] = skill; - TouchCharacterLocked(campaign.Id, character.Id); - - PersistStateLocked(); - return ServiceResult.Success(ToSkillSummary(skill)); - } + return m_SkillService.CreateSkill(sessionToken, characterId, name, diceRollDefinition, wildDice, allowFumble, skillGroupId, fumbleRange); } public ServiceResult UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null) { - if (string.IsNullOrWhiteSpace(name)) - return ServiceResult.Failure("invalid_skill_name", "Skill 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_SkillsById.TryGetValue(skillId, out var skill)) - return ServiceResult.Failure("skill_not_found", "Skill was not found."); - - var character = m_CharactersById[skill.CharacterId]; - if (!TryResolveCharacterCampaignLocked(character, out var campaign, out var campaignError)) - return ServiceResult.Failure(campaignError!.Code, campaignError.Message); - - if (!CanEditCharacterLocked(user.Id, character, campaign)) - return ServiceResult.Failure("forbidden", "Only the owner or GM can edit skills."); - - var skillValidation = SkillDefinitionValidator.Validate(campaign.Ruleset, diceRollDefinition, wildDice, allowFumble, fumbleRange); - if (!skillValidation.Succeeded) - return ServiceResult.Failure(skillValidation.Error!.Code, skillValidation.Error.Message); - - var resolvedSkillGroupId = ResolveSkillGroupForSkillChangeLocked(skillGroupId, character.Id); - if (!resolvedSkillGroupId.Succeeded) - return ServiceResult.Failure(resolvedSkillGroupId.Error!.Code, resolvedSkillGroupId.Error.Message); - - skill.Name = name.Trim(); - skill.DiceRollDefinition = skillValidation.Value!.CanonicalExpression; - skill.WildDice = skillValidation.Value.WildDice; - skill.AllowFumble = skillValidation.Value.AllowFumble; - skill.FumbleRange = skillValidation.Value.FumbleRange; - skill.SkillGroupId = resolvedSkillGroupId.Value; - TouchCharacterLocked(campaign.Id, character.Id); - - PersistStateLocked(); - return ServiceResult.Success(ToSkillSummary(skill)); - } + return m_SkillService.UpdateSkill(sessionToken, skillId, name, diceRollDefinition, wildDice, allowFumble, skillGroupId, fumbleRange); } public ServiceResult DeleteSkill(string sessionToken, Guid skillId) { - lock (m_Gate) - { - var user = ResolveUserLocked(sessionToken); - if (user is null) - return ServiceResult.Failure("unauthorized", "You must be logged in."); - - if (!m_SkillsById.TryGetValue(skillId, out var skill)) - return ServiceResult.Failure("skill_not_found", "Skill was not found."); - - var character = m_CharactersById[skill.CharacterId]; - if (!TryResolveCharacterCampaignLocked(character, out var campaign, out var campaignError)) - return ServiceResult.Failure(campaignError!.Code, campaignError.Message); - - if (!CanEditCharacterLocked(user.Id, character, campaign)) - return ServiceResult.Failure("forbidden", "Only the owner or GM can edit skills."); - - m_SkillsById.Remove(skill.Id); - TouchCharacterLocked(campaign.Id, character.Id); - - PersistStateLocked(); - return ServiceResult.Success(true); - } + return m_SkillService.DeleteSkill(sessionToken, skillId); } public ServiceResult GetCharacterSheet(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 (!TryResolveCharacterCampaignLocked(character, out var campaign, out var campaignError)) - return ServiceResult.Failure(campaignError!.Code, campaignError.Message); - - if (!CanViewCampaignLocked(user.Id, campaign.Id)) - return ServiceResult.Failure("forbidden", "You are not a participant in this campaign."); - - return ServiceResult.Success(ToCharacterSheet(character.Id)); - } + return m_SkillService.GetCharacterSheet(sessionToken, characterId); } public ServiceResult RollSkill(string sessionToken, Guid skillId, string visibility) @@ -848,20 +638,6 @@ public sealed class GameService : IGameService }; } - private ServiceResult ResolveSkillGroupForSkillChangeLocked(Guid? requestedSkillGroupId, Guid characterId) - { - if (!requestedSkillGroupId.HasValue) - return ServiceResult.Success(null); - - if (!m_SkillGroupsById.TryGetValue(requestedSkillGroupId.Value, out var skillGroup)) - return ServiceResult.Failure("skill_group_not_found", "Skill group was not found."); - - if (skillGroup.CharacterId != characterId) - return ServiceResult.Failure("invalid_skill_group", "Skill group must belong to the same character."); - - return ServiceResult.Success(skillGroup.Id); - } - private ServiceResult RecordRollLocked( UserAccount user, Campaign campaign, @@ -924,22 +700,6 @@ public sealed class GameService : IGameService return new(user.Id, user.Username, user.DisplayName, RoleSerializer.Parse(user.Roles)); } - private CharacterSheet ToCharacterSheet(Guid characterId) - { - var skillGroups = m_SkillGroupsById.Values - .Where(group => group.CharacterId == characterId) - .OrderBy(group => group.Name, StringComparer.OrdinalIgnoreCase) - .Select(ToCharacterSheetSkillGroup) - .ToArray(); - var skills = m_SkillsById.Values - .Where(skill => skill.CharacterId == characterId) - .OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase) - .Select(ToCharacterSheetSkill) - .ToArray(); - - return new(characterId, skillGroups, skills); - } - private CampaignStateSnapshot ToCampaignStateSnapshot(Campaign campaign) { var state = GetOrCreateCampaignStateLocked(campaign.Id); @@ -960,26 +720,6 @@ public sealed class GameService : IGameService .ThenBy(r => r.Id); } - private static CharacterSheetSkillGroup ToCharacterSheetSkillGroup(SkillGroup skillGroup) - { - return new(skillGroup.Id, skillGroup.Name, skillGroup.DiceRollDefinition, skillGroup.WildDice, skillGroup.AllowFumble, skillGroup.FumbleRange); - } - - private static SkillGroupSummary ToSkillGroupSummary(SkillGroup skillGroup) - { - return new(skillGroup.Id, skillGroup.CharacterId, skillGroup.Name, skillGroup.DiceRollDefinition, skillGroup.WildDice, skillGroup.AllowFumble, skillGroup.FumbleRange); - } - - private static CharacterSheetSkill ToCharacterSheetSkill(Skill skill) - { - return new(skill.Id, skill.SkillGroupId, skill.Name, skill.DiceRollDefinition, skill.WildDice, skill.AllowFumble, skill.FumbleRange); - } - - private static SkillSummary ToSkillSummary(Skill skill) - { - return new(skill.Id, skill.CharacterId, skill.SkillGroupId, skill.Name, skill.DiceRollDefinition, skill.WildDice, skill.AllowFumble, skill.FumbleRange); - } - private static RollResult ToRollResult(RollLogEntry entry, IReadOnlyList dice) { return new(entry.Id, entry.CampaignId, entry.CharacterId, entry.SkillId, entry.RollerUserId, entry.Visibility == RollVisibility.Public ? "public" : "private", entry.Result, entry.Breakdown, dice, entry.TimestampUtc); @@ -1362,16 +1102,6 @@ public sealed class GameService : IGameService state.RosterVersion += 1; } - private void TouchCharacterLocked(Guid? campaignId, Guid characterId) - { - if (!campaignId.HasValue || !m_CampaignsById.ContainsKey(campaignId.Value)) - return; - - var state = GetOrCreateCampaignStateLocked(campaignId.Value); - state.TotalVersion += 1; - state.CharacterVersions[characterId] = state.CharacterVersions.GetValueOrDefault(characterId, 1) + 1; - } - private void TouchLogLocked(Guid? campaignId) { if (!campaignId.HasValue || !m_CampaignsById.ContainsKey(campaignId.Value)) @@ -1430,6 +1160,7 @@ public sealed class GameService : IGameService private readonly GamePersistenceService m_PersistenceService; private readonly List m_RollLog; private readonly Dictionary m_SessionsByToken; + private readonly GameSkillService m_SkillService; private readonly Dictionary m_SkillGroupsById; private readonly Dictionary m_SkillsById; private readonly GameStateStore m_StateStore; diff --git a/RpgRoller/Services/GameSkillService.cs b/RpgRoller/Services/GameSkillService.cs new file mode 100644 index 0000000..d446a35 --- /dev/null +++ b/RpgRoller/Services/GameSkillService.cs @@ -0,0 +1,375 @@ +using RpgRoller.Contracts; +using RpgRoller.Domain; + +namespace RpgRoller.Services; + +public sealed class GameSkillService +{ + public GameSkillService(GameStateStore stateStore, GamePersistenceService persistenceService) + { + m_StateStore = stateStore; + m_PersistenceService = persistenceService; + } + + public ServiceResult CreateSkillGroup(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, int? fumbleRange = null) + { + if (string.IsNullOrWhiteSpace(name)) + return ServiceResult.Failure("invalid_skill_group_name", "Skill group 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."); + + if (!TryResolveCharacterCampaignLocked(character, out var campaign, out var campaignError)) + return ServiceResult.Failure(campaignError!.Code, campaignError.Message); + + if (!CanEditCharacterLocked(user.Id, character, campaign)) + return ServiceResult.Failure("forbidden", "Only the owner or GM can manage skill groups."); + + var prototypeValidation = SkillDefinitionValidator.Validate(campaign.Ruleset, diceRollDefinition, wildDice, allowFumble, fumbleRange); + if (!prototypeValidation.Succeeded) + return ServiceResult.Failure(prototypeValidation.Error!.Code, prototypeValidation.Error.Message); + + var group = new SkillGroup + { + Id = Guid.NewGuid(), + CharacterId = character.Id, + Name = name.Trim(), + DiceRollDefinition = prototypeValidation.Value!.CanonicalExpression, + WildDice = prototypeValidation.Value.WildDice, + AllowFumble = prototypeValidation.Value.AllowFumble, + FumbleRange = prototypeValidation.Value.FumbleRange + }; + + m_StateStore.SkillGroupsById[group.Id] = group; + TouchCharacterLocked(campaign.Id, character.Id); + + m_PersistenceService.PersistStateLocked(); + return ServiceResult.Success(ToSkillGroupSummary(group)); + } + } + + public ServiceResult UpdateSkillGroup(string sessionToken, Guid skillGroupId, string name, string diceRollDefinition, int wildDice, bool allowFumble, int? fumbleRange = null) + { + if (string.IsNullOrWhiteSpace(name)) + return ServiceResult.Failure("invalid_skill_group_name", "Skill group 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.SkillGroupsById.TryGetValue(skillGroupId, out var group)) + return ServiceResult.Failure("skill_group_not_found", "Skill group was not found."); + + var character = m_StateStore.CharactersById[group.CharacterId]; + if (!TryResolveCharacterCampaignLocked(character, out var campaign, out var campaignError)) + return ServiceResult.Failure(campaignError!.Code, campaignError.Message); + + if (!CanEditCharacterLocked(user.Id, character, campaign)) + return ServiceResult.Failure("forbidden", "Only the owner or GM can manage skill groups."); + + var prototypeValidation = SkillDefinitionValidator.Validate(campaign.Ruleset, diceRollDefinition, wildDice, allowFumble, fumbleRange); + if (!prototypeValidation.Succeeded) + return ServiceResult.Failure(prototypeValidation.Error!.Code, prototypeValidation.Error.Message); + + group.Name = name.Trim(); + group.DiceRollDefinition = prototypeValidation.Value!.CanonicalExpression; + group.WildDice = prototypeValidation.Value.WildDice; + group.AllowFumble = prototypeValidation.Value.AllowFumble; + group.FumbleRange = prototypeValidation.Value.FumbleRange; + TouchCharacterLocked(campaign.Id, character.Id); + + m_PersistenceService.PersistStateLocked(); + return ServiceResult.Success(ToSkillGroupSummary(group)); + } + } + + public ServiceResult DeleteSkillGroup(string sessionToken, Guid skillGroupId) + { + lock (m_StateStore.Gate) + { + var user = ResolveUserLocked(sessionToken); + if (user is null) + return ServiceResult.Failure("unauthorized", "You must be logged in."); + + if (!m_StateStore.SkillGroupsById.TryGetValue(skillGroupId, out var group)) + return ServiceResult.Failure("skill_group_not_found", "Skill group was not found."); + + var character = m_StateStore.CharactersById[group.CharacterId]; + if (!TryResolveCharacterCampaignLocked(character, out var campaign, out var campaignError)) + return ServiceResult.Failure(campaignError!.Code, campaignError.Message); + + if (!CanEditCharacterLocked(user.Id, character, campaign)) + return ServiceResult.Failure("forbidden", "Only the owner or GM can manage skill groups."); + + foreach (var skill in m_StateStore.SkillsById.Values.Where(skill => skill.SkillGroupId == group.Id)) + skill.SkillGroupId = null; + + m_StateStore.SkillGroupsById.Remove(group.Id); + TouchCharacterLocked(campaign.Id, character.Id); + + m_PersistenceService.PersistStateLocked(); + return ServiceResult.Success(true); + } + } + + public ServiceResult CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null) + { + if (string.IsNullOrWhiteSpace(name)) + return ServiceResult.Failure("invalid_skill_name", "Skill 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."); + + if (!TryResolveCharacterCampaignLocked(character, out var campaign, out var campaignError)) + return ServiceResult.Failure(campaignError!.Code, campaignError.Message); + + if (!CanEditCharacterLocked(user.Id, character, campaign)) + return ServiceResult.Failure("forbidden", "Only the owner or GM can edit skills."); + + var skillValidation = SkillDefinitionValidator.Validate(campaign.Ruleset, diceRollDefinition, wildDice, allowFumble, fumbleRange); + if (!skillValidation.Succeeded) + return ServiceResult.Failure(skillValidation.Error!.Code, skillValidation.Error.Message); + + var resolvedSkillGroupId = ResolveSkillGroupForSkillChangeLocked(skillGroupId, character.Id); + if (!resolvedSkillGroupId.Succeeded) + return ServiceResult.Failure(resolvedSkillGroupId.Error!.Code, resolvedSkillGroupId.Error.Message); + + var skill = new Skill + { + Id = Guid.NewGuid(), + CharacterId = character.Id, + SkillGroupId = resolvedSkillGroupId.Value, + Name = name.Trim(), + DiceRollDefinition = skillValidation.Value!.CanonicalExpression, + WildDice = skillValidation.Value.WildDice, + AllowFumble = skillValidation.Value.AllowFumble, + FumbleRange = skillValidation.Value.FumbleRange + }; + + m_StateStore.SkillsById[skill.Id] = skill; + TouchCharacterLocked(campaign.Id, character.Id); + + m_PersistenceService.PersistStateLocked(); + return ServiceResult.Success(ToSkillSummary(skill)); + } + } + + public ServiceResult UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null) + { + if (string.IsNullOrWhiteSpace(name)) + return ServiceResult.Failure("invalid_skill_name", "Skill 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.SkillsById.TryGetValue(skillId, out var skill)) + return ServiceResult.Failure("skill_not_found", "Skill was not found."); + + var character = m_StateStore.CharactersById[skill.CharacterId]; + if (!TryResolveCharacterCampaignLocked(character, out var campaign, out var campaignError)) + return ServiceResult.Failure(campaignError!.Code, campaignError.Message); + + if (!CanEditCharacterLocked(user.Id, character, campaign)) + return ServiceResult.Failure("forbidden", "Only the owner or GM can edit skills."); + + var skillValidation = SkillDefinitionValidator.Validate(campaign.Ruleset, diceRollDefinition, wildDice, allowFumble, fumbleRange); + if (!skillValidation.Succeeded) + return ServiceResult.Failure(skillValidation.Error!.Code, skillValidation.Error.Message); + + var resolvedSkillGroupId = ResolveSkillGroupForSkillChangeLocked(skillGroupId, character.Id); + if (!resolvedSkillGroupId.Succeeded) + return ServiceResult.Failure(resolvedSkillGroupId.Error!.Code, resolvedSkillGroupId.Error.Message); + + skill.Name = name.Trim(); + skill.DiceRollDefinition = skillValidation.Value!.CanonicalExpression; + skill.WildDice = skillValidation.Value.WildDice; + skill.AllowFumble = skillValidation.Value.AllowFumble; + skill.FumbleRange = skillValidation.Value.FumbleRange; + skill.SkillGroupId = resolvedSkillGroupId.Value; + TouchCharacterLocked(campaign.Id, character.Id); + + m_PersistenceService.PersistStateLocked(); + return ServiceResult.Success(ToSkillSummary(skill)); + } + } + + public ServiceResult DeleteSkill(string sessionToken, Guid skillId) + { + lock (m_StateStore.Gate) + { + var user = ResolveUserLocked(sessionToken); + if (user is null) + return ServiceResult.Failure("unauthorized", "You must be logged in."); + + if (!m_StateStore.SkillsById.TryGetValue(skillId, out var skill)) + return ServiceResult.Failure("skill_not_found", "Skill was not found."); + + var character = m_StateStore.CharactersById[skill.CharacterId]; + if (!TryResolveCharacterCampaignLocked(character, out var campaign, out var campaignError)) + return ServiceResult.Failure(campaignError!.Code, campaignError.Message); + + if (!CanEditCharacterLocked(user.Id, character, campaign)) + return ServiceResult.Failure("forbidden", "Only the owner or GM can edit skills."); + + m_StateStore.SkillsById.Remove(skill.Id); + TouchCharacterLocked(campaign.Id, character.Id); + + m_PersistenceService.PersistStateLocked(); + return ServiceResult.Success(true); + } + } + + public ServiceResult GetCharacterSheet(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 (!TryResolveCharacterCampaignLocked(character, out var campaign, out var campaignError)) + return ServiceResult.Failure(campaignError!.Code, campaignError.Message); + + if (!CanViewCampaignLocked(user.Id, campaign.Id)) + return ServiceResult.Failure("forbidden", "You are not a participant in this campaign."); + + return ServiceResult.Success(ToCharacterSheet(character.Id)); + } + } + + 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 bool TryResolveCharacterCampaignLocked(Character character, out Campaign campaign, out ServiceError? error) + { + campaign = default!; + if (!character.CampaignId.HasValue || !m_StateStore.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 bool CanEditCharacterLocked(Guid actorUserId, Character character, Campaign campaign) + { + return character.OwnerUserId == actorUserId || campaign.GmUserId == actorUserId; + } + + private bool CanViewCampaignLocked(Guid userId, Guid campaignId) + { + if (m_StateStore.UsersById.TryGetValue(userId, out var user) && RoleSerializer.HasRole(user.Roles, UserRoles.Admin)) + return true; + + var campaign = m_StateStore.CampaignsById[campaignId]; + if (campaign.GmUserId == userId) + return true; + + return m_StateStore.CharactersById.Values.Any(c => c.CampaignId.HasValue && c.CampaignId.Value == campaignId && c.OwnerUserId == userId); + } + + private ServiceResult ResolveSkillGroupForSkillChangeLocked(Guid? requestedSkillGroupId, Guid characterId) + { + if (!requestedSkillGroupId.HasValue) + return ServiceResult.Success(null); + + if (!m_StateStore.SkillGroupsById.TryGetValue(requestedSkillGroupId.Value, out var skillGroup)) + return ServiceResult.Failure("skill_group_not_found", "Skill group was not found."); + + if (skillGroup.CharacterId != characterId) + return ServiceResult.Failure("invalid_skill_group", "Skill group must belong to the same character."); + + return ServiceResult.Success(skillGroup.Id); + } + + private CharacterSheet ToCharacterSheet(Guid characterId) + { + var skillGroups = m_StateStore.SkillGroupsById.Values + .Where(group => group.CharacterId == characterId) + .OrderBy(group => group.Name, StringComparer.OrdinalIgnoreCase) + .Select(ToCharacterSheetSkillGroup) + .ToArray(); + var skills = m_StateStore.SkillsById.Values + .Where(skill => skill.CharacterId == characterId) + .OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase) + .Select(ToCharacterSheetSkill) + .ToArray(); + + return new(characterId, skillGroups, skills); + } + + private static CharacterSheetSkillGroup ToCharacterSheetSkillGroup(SkillGroup skillGroup) + { + return new(skillGroup.Id, skillGroup.Name, skillGroup.DiceRollDefinition, skillGroup.WildDice, skillGroup.AllowFumble, skillGroup.FumbleRange); + } + + private static CharacterSheetSkill ToCharacterSheetSkill(Skill skill) + { + return new(skill.Id, skill.SkillGroupId, skill.Name, skill.DiceRollDefinition, skill.WildDice, skill.AllowFumble, skill.FumbleRange); + } + + private static SkillGroupSummary ToSkillGroupSummary(SkillGroup skillGroup) + { + return new(skillGroup.Id, skillGroup.CharacterId, skillGroup.Name, skillGroup.DiceRollDefinition, skillGroup.WildDice, skillGroup.AllowFumble, skillGroup.FumbleRange); + } + + private static SkillSummary ToSkillSummary(Skill skill) + { + return new(skill.Id, skill.CharacterId, skill.SkillGroupId, skill.Name, skill.DiceRollDefinition, skill.WildDice, skill.AllowFumble, skill.FumbleRange); + } + + private void TouchCharacterLocked(Guid? campaignId, Guid characterId) + { + if (!campaignId.HasValue || !m_StateStore.CampaignsById.ContainsKey(campaignId.Value)) + return; + + var state = GetOrCreateCampaignStateLocked(campaignId.Value); + state.TotalVersion += 1; + state.CharacterVersions[characterId] = state.CharacterVersions.GetValueOrDefault(characterId, 1) + 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; +}