From 951ce9f1fea491ba880af1d9ddd1189d574f6aee Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Sat, 4 Apr 2026 23:23:41 +0200 Subject: [PATCH] Extract game campaign service --- README.md | 1 + RpgRoller/Services/GameCampaignService.cs | 209 ++++++++++++++++++++++ RpgRoller/Services/GameService.cs | 124 +------------ 3 files changed, 217 insertions(+), 117 deletions(-) create mode 100644 RpgRoller/Services/GameCampaignService.cs diff --git a/README.md b/README.md index 8f5e21b..24197fb 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Backend: - `RpgRoller/Services/SkillDefinitionValidator.cs`, `RoleSerializer.cs`, `RollVisibilityParser.cs`, and `CustomRollOptionsResolver.cs`: extracted pure backend rule helpers used by `GameService` - `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 Frontend: diff --git a/RpgRoller/Services/GameCampaignService.cs b/RpgRoller/Services/GameCampaignService.cs new file mode 100644 index 0000000..f8239ff --- /dev/null +++ b/RpgRoller/Services/GameCampaignService.cs @@ -0,0 +1,209 @@ +using RpgRoller.Contracts; +using RpgRoller.Domain; + +namespace RpgRoller.Services; + +public sealed class GameCampaignService +{ + public GameCampaignService(GameStateStore stateStore, GamePersistenceService persistenceService) + { + m_StateStore = stateStore; + m_PersistenceService = persistenceService; + } + + public ServiceResult CreateCampaign(string sessionToken, string name, string rulesetId) + { + if (string.IsNullOrWhiteSpace(name)) + return ServiceResult.Failure("invalid_campaign_name", "Campaign name is required."); + + var ruleset = DiceRules.TryParseRulesetId(rulesetId); + if (ruleset is null) + return ServiceResult.Failure("invalid_ruleset", "Unknown ruleset."); + + lock (m_StateStore.Gate) + { + var user = ResolveUserLocked(sessionToken); + if (user is null) + return ServiceResult.Failure("unauthorized", "You must be logged in."); + + var campaign = new Campaign + { + Id = Guid.NewGuid(), + GmUserId = user.Id, + Name = name.Trim(), + Ruleset = ruleset.Value, + Version = 1 + }; + + m_StateStore.CampaignsById[campaign.Id] = campaign; + m_PersistenceService.PersistStateLocked(); + return ServiceResult.Success(ToCampaignSummary(campaign)); + } + } + + public ServiceResult> GetCampaigns(string sessionToken) + { + lock (m_StateStore.Gate) + { + var user = ResolveUserLocked(sessionToken); + if (user is null) + return ServiceResult>.Failure("unauthorized", "You must be logged in."); + + IEnumerable visibleCampaigns; + if (RoleSerializer.HasRole(user.Roles, UserRoles.Admin)) + { + visibleCampaigns = m_StateStore.CampaignsById.Values; + } + else + { + var campaignIds = new HashSet(m_StateStore.CampaignsById.Values.Where(campaign => campaign.GmUserId == user.Id).Select(campaign => campaign.Id)); + foreach (var character in m_StateStore.CharactersById.Values.Where(character => character.OwnerUserId == user.Id && character.CampaignId.HasValue)) + campaignIds.Add(character.CampaignId!.Value); + + visibleCampaigns = campaignIds.Select(campaignId => m_StateStore.CampaignsById[campaignId]); + } + + var results = visibleCampaigns + .OrderBy(campaign => campaign.Name, StringComparer.OrdinalIgnoreCase) + .Select(ToCampaignSummary) + .ToArray(); + + return ServiceResult>.Success(results); + } + } + + public ServiceResult> GetCharacterCampaignOptions(string sessionToken) + { + lock (m_StateStore.Gate) + { + var user = ResolveUserLocked(sessionToken); + if (user is null) + return ServiceResult>.Failure("unauthorized", "You must be logged in."); + + var options = m_StateStore.CampaignsById.Values + .OrderBy(campaign => campaign.Name, StringComparer.OrdinalIgnoreCase) + .Select(campaign => new CampaignOption(campaign.Id, campaign.Name)) + .ToArray(); + + return ServiceResult>.Success(options); + } + } + + public ServiceResult GetCampaign(string sessionToken, Guid campaignId) + { + lock (m_StateStore.Gate) + { + var context = ResolveContextLocked(sessionToken, campaignId); + if (!context.Succeeded) + return ServiceResult.Failure(context.Error!.Code, context.Error.Message); + + var (_, campaign) = context.Value; + return ServiceResult.Success(ToCampaignRoster(campaign)); + } + } + + public ServiceResult DeleteCampaign(string sessionToken, Guid campaignId) + { + 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.TryGetValue(campaignId, out var campaign)) + return ServiceResult.Failure("campaign_not_found", "Campaign was not found."); + + if (campaign.GmUserId != user.Id && !RoleSerializer.HasRole(user.Roles, UserRoles.Admin)) + return ServiceResult.Failure("forbidden", "Only the campaign owner or admin can delete this campaign."); + + DeleteCampaignLocked(campaignId); + m_PersistenceService.PersistStateLocked(); + return ServiceResult.Success(true); + } + } + + private ServiceResult<(UserAccount User, Campaign Campaign)> ResolveContextLocked(string sessionToken, Guid campaignId) + { + var user = ResolveUserLocked(sessionToken); + if (user is null) + return ServiceResult<(UserAccount User, Campaign Campaign)>.Failure("unauthorized", "You must be logged in."); + + if (!m_StateStore.CampaignsById.TryGetValue(campaignId, out var campaign)) + return ServiceResult<(UserAccount User, Campaign Campaign)>.Failure("campaign_not_found", "Campaign was not found."); + + if (!CanViewCampaignLocked(user.Id, campaign.Id)) + return ServiceResult<(UserAccount User, Campaign Campaign)>.Failure("forbidden", "You are not a participant in this campaign."); + + return ServiceResult<(UserAccount User, Campaign Campaign)>.Success((user, campaign)); + } + + 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 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 CampaignSummary ToCampaignSummary(Campaign campaign) + { + var gm = m_StateStore.UsersById[campaign.GmUserId]; + var characterCount = m_StateStore.CharactersById.Values.Count(character => character.CampaignId == campaign.Id); + return new(campaign.Id, campaign.Name, DiceRules.ToRulesetId(campaign.Ruleset), new CampaignGmSummary(gm.Id, gm.DisplayName), characterCount); + } + + private CampaignRoster ToCampaignRoster(Campaign campaign) + { + var gm = m_StateStore.UsersById[campaign.GmUserId]; + var characters = m_StateStore.CharactersById.Values + .Where(character => character.CampaignId == campaign.Id) + .OrderBy(character => character.Name, StringComparer.OrdinalIgnoreCase) + .Select(character => new CharacterSummary( + character.Id, + character.Name, + character.OwnerUserId, + character.CampaignId, + ResolveOwnerDisplayName(character.OwnerUserId))) + .ToArray(); + + return new(campaign.Id, campaign.Name, DiceRules.ToRulesetId(campaign.Ruleset), new CampaignGmSummary(gm.Id, gm.DisplayName), characters); + } + + private string ResolveOwnerDisplayName(Guid ownerUserId) + { + return m_StateStore.UsersById.TryGetValue(ownerUserId, out var user) + ? user.DisplayName + : "Unknown user"; + } + + 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 readonly GamePersistenceService m_PersistenceService; + private readonly GameStateStore m_StateStore; +} diff --git a/RpgRoller/Services/GameService.cs b/RpgRoller/Services/GameService.cs index fe03c17..999846b 100644 --- a/RpgRoller/Services/GameService.cs +++ b/RpgRoller/Services/GameService.cs @@ -24,6 +24,7 @@ public sealed class GameService : IGameService 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_DiceRoller = diceRoller; LoadStateFromDatabase(); } @@ -60,110 +61,27 @@ public sealed class GameService : IGameService public ServiceResult CreateCampaign(string sessionToken, string name, string rulesetId) { - if (string.IsNullOrWhiteSpace(name)) - return ServiceResult.Failure("invalid_campaign_name", "Campaign name is required."); - - var ruleset = DiceRules.TryParseRulesetId(rulesetId); - if (ruleset is null) - return ServiceResult.Failure("invalid_ruleset", "Unknown ruleset."); - - lock (m_Gate) - { - var user = ResolveUserLocked(sessionToken); - if (user is null) - return ServiceResult.Failure("unauthorized", "You must be logged in."); - - var campaign = new Campaign - { - Id = Guid.NewGuid(), - GmUserId = user.Id, - Name = name.Trim(), - Ruleset = ruleset.Value, - Version = 1 - }; - - m_CampaignsById[campaign.Id] = campaign; - PersistStateLocked(); - return ServiceResult.Success(ToCampaignSummary(campaign)); - } + return m_CampaignService.CreateCampaign(sessionToken, name, rulesetId); } public ServiceResult> GetCampaigns(string sessionToken) { - lock (m_Gate) - { - var user = ResolveUserLocked(sessionToken); - if (user is null) - return ServiceResult>.Failure("unauthorized", "You must be logged in."); - - IEnumerable visibleCampaigns; - if (UserHasRoleLocked(user, UserRoles.Admin)) - { - visibleCampaigns = m_CampaignsById.Values; - } - else - { - var campaignIds = new HashSet(m_CampaignsById.Values.Where(campaign => campaign.GmUserId == user.Id).Select(campaign => campaign.Id)); - foreach (var character in m_CharactersById.Values.Where(character => character.OwnerUserId == user.Id && character.CampaignId.HasValue)) - campaignIds.Add(character.CampaignId!.Value); - - visibleCampaigns = campaignIds.Select(campaignId => m_CampaignsById[campaignId]); - } - - var results = visibleCampaigns.OrderBy(campaign => campaign.Name, StringComparer.OrdinalIgnoreCase).Select(ToCampaignSummary).ToArray(); - - return ServiceResult>.Success(results); - } + return m_CampaignService.GetCampaigns(sessionToken); } public ServiceResult> GetCharacterCampaignOptions(string sessionToken) { - lock (m_Gate) - { - var user = ResolveUserLocked(sessionToken); - if (user is null) - return ServiceResult>.Failure("unauthorized", "You must be logged in."); - - var options = m_CampaignsById.Values - .OrderBy(campaign => campaign.Name, StringComparer.OrdinalIgnoreCase) - .Select(ToCampaignOption) - .ToArray(); - - return ServiceResult>.Success(options); - } + return m_CampaignService.GetCharacterCampaignOptions(sessionToken); } public ServiceResult GetCampaign(string sessionToken, Guid campaignId) { - lock (m_Gate) - { - var context = ResolveContextLocked(sessionToken, campaignId); - if (!context.Succeeded) - return ServiceResult.Failure(context.Error!.Code, context.Error.Message); - - var (_, campaign) = context.Value; - return ServiceResult.Success(ToCampaignRoster(campaign)); - } + return m_CampaignService.GetCampaign(sessionToken, campaignId); } public ServiceResult DeleteCampaign(string sessionToken, Guid campaignId) { - lock (m_Gate) - { - var user = ResolveUserLocked(sessionToken); - if (user is null) - return ServiceResult.Failure("unauthorized", "You must be logged in."); - - if (!m_CampaignsById.TryGetValue(campaignId, out var campaign)) - return ServiceResult.Failure("campaign_not_found", "Campaign was not found."); - - if (campaign.GmUserId != user.Id && !UserHasRoleLocked(user, UserRoles.Admin)) - return ServiceResult.Failure("forbidden", "Only the campaign owner or admin can delete this campaign."); - - DeleteCampaignLocked(campaignId); - PersistStateLocked(); - return ServiceResult.Success(true); - } + return m_CampaignService.DeleteCampaign(sessionToken, campaignId); } public ServiceResult> GetUsernames(string sessionToken) @@ -1133,30 +1051,6 @@ public sealed class GameService : IGameService return new(user.Id, user.Username, user.DisplayName, RoleSerializer.Parse(user.Roles)); } - private static CampaignOption ToCampaignOption(Campaign campaign) - { - return new(campaign.Id, campaign.Name); - } - - private CampaignSummary ToCampaignSummary(Campaign campaign) - { - var gm = m_UsersById[campaign.GmUserId]; - var characterCount = m_CharactersById.Values.Count(character => character.CampaignId == campaign.Id); - return new(campaign.Id, campaign.Name, DiceRules.ToRulesetId(campaign.Ruleset), ToCampaignGmSummary(gm), characterCount); - } - - private CampaignRoster ToCampaignRoster(Campaign campaign) - { - var gm = m_UsersById[campaign.GmUserId]; - var characters = m_CharactersById.Values - .Where(character => character.CampaignId == campaign.Id) - .OrderBy(character => character.Name, StringComparer.OrdinalIgnoreCase) - .Select(ToCharacterSummary) - .ToArray(); - - return new(campaign.Id, campaign.Name, DiceRules.ToRulesetId(campaign.Ruleset), ToCampaignGmSummary(gm), characters); - } - private CharacterSheet ToCharacterSheet(Guid characterId) { var skillGroups = m_SkillGroupsById.Values @@ -1199,11 +1093,6 @@ public sealed class GameService : IGameService .ThenBy(r => r.Id); } - private static CampaignGmSummary ToCampaignGmSummary(UserAccount user) - { - return new(user.Id, user.DisplayName); - } - private static CharacterSheetSkillGroup ToCharacterSheetSkillGroup(SkillGroup skillGroup) { return new(skillGroup.Id, skillGroup.Name, skillGroup.DiceRollDefinition, skillGroup.WildDice, skillGroup.AllowFumble, skillGroup.FumbleRange); @@ -1670,6 +1559,7 @@ public sealed class GameService : IGameService private static readonly JsonSerializerOptions DiceJsonOptions = RpgRollerJson.CreateSerializerOptions(); private readonly Dictionary m_CampaignsById; private readonly Dictionary m_CampaignStateById; + private readonly GameCampaignService m_CampaignService; private readonly Dictionary m_CharactersById; private readonly GameAuthService m_AuthService; private readonly IDiceRoller m_DiceRoller;