diff --git a/README.md b/README.md index b1d54ca..24f5ffa 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,8 @@ Backend: - `RpgRoller/Services/GameSkillService.cs`: skill-group CRUD, skill CRUD, sheet shaping, and ruleset validation - `RpgRoller/Services/GameRollService.cs`: skill/custom rolls, compact log pages, roll detail, and campaign state snapshots - `RpgRoller/Services/GameUserAdministrationService.cs`: username reads, admin user listing, role updates, and account deletion -- `RpgRoller/Services/GameStateStore.cs`, `GameStateCloneFactory.cs`, and `GamePersistenceService.cs`: in-memory runtime state and SQLite load/save boundaries +- `RpgRoller/Services/GameStateStore.cs`, `GameStateCloneFactory.cs`, and `GamePersistenceService.cs`: in-memory runtime state, campaign-state version tracking, and SQLite load/save boundaries +- `RpgRoller/Services/GameAuthorization.cs`, `GameContextResolver.cs`, and `GameDtoMapper.cs`: shared authorization, session/campaign resolution, and backend read-model mapping - `RpgRoller/Services/SkillDefinitionValidator.cs`, `RoleSerializer.cs`, `RollVisibilityParser.cs`, and `CustomRollOptionsResolver.cs`: shared rules and parsing helpers Frontend: @@ -47,7 +48,7 @@ Frontend: Current repo note: -- `TASKS.md` tracks the remaining GameService and Workspace decomposition work. +- `TASKS.md` tracks the remaining roll-engine extraction and Workspace cleanup work. - This README describes the code as it exists today. It does not treat blueprint items in `TASKS.md` as finished unless they are already present in the repo. ## Runtime and Persistence diff --git a/RpgRoller.Tests/Services/ServiceSharedHelperTests.cs b/RpgRoller.Tests/Services/ServiceSharedHelperTests.cs new file mode 100644 index 0000000..b8e3e27 --- /dev/null +++ b/RpgRoller.Tests/Services/ServiceSharedHelperTests.cs @@ -0,0 +1,387 @@ +using RpgRoller.Contracts; +using RpgRoller.Domain; +using RpgRoller.Services; + +namespace RpgRoller.Tests; + +public sealed class ServiceSharedHelperTests +{ + [Fact] + public void GameStateStore_TracksCampaignSlicesAndCharacterVersions() + { + var campaignId = Guid.NewGuid(); + var characterId = Guid.NewGuid(); + var store = new GameStateStore(); + + store.CampaignsById[campaignId] = new Campaign + { + Id = campaignId, + GmUserId = Guid.NewGuid(), + Name = "Alpha", + Ruleset = RulesetKind.D6, + Version = 1 + }; + store.CharactersById[characterId] = new Character + { + Id = characterId, + OwnerUserId = Guid.NewGuid(), + CampaignId = campaignId, + Name = "Scout" + }; + + store.RebuildCampaignStateLocked(); + + var initialState = store.GetOrCreateCampaignStateLocked(campaignId); + Assert.Equal(1, initialState.CharacterVersions[characterId]); + + store.TouchRosterLocked(campaignId); + store.TouchCharacterLocked(campaignId, characterId); + store.TouchLogLocked(campaignId); + + Assert.Equal(4, initialState.TotalVersion); + Assert.Equal(2, initialState.RosterVersion); + Assert.Equal(2, initialState.LogVersion); + Assert.Equal(2, initialState.CharacterVersions[characterId]); + + store.RemoveCharacterStateLocked(campaignId, characterId); + Assert.Empty(initialState.CharacterVersions); + + store.AddCharacterStateLocked(campaignId, characterId); + Assert.Equal(1, initialState.CharacterVersions[characterId]); + + store.TouchRosterLocked(null); + store.TouchCharacterLocked(Guid.NewGuid(), Guid.NewGuid()); + store.TouchLogLocked(Guid.NewGuid()); + store.RemoveCharacterStateLocked(Guid.NewGuid(), Guid.NewGuid()); + } + + [Fact] + public void GameAuthorization_CoversCampaignAndRollVisibility() + { + var adminId = Guid.NewGuid(); + var gmId = Guid.NewGuid(); + var playerId = Guid.NewGuid(); + var outsiderId = Guid.NewGuid(); + var campaignId = Guid.NewGuid(); + var store = new GameStateStore(); + + store.UsersById[adminId] = new UserAccount + { + Id = adminId, + Username = "admin", + UsernameNormalized = "ADMIN", + PasswordHash = "hash", + DisplayName = "Admin", + Roles = UserRoles.Admin + }; + store.UsersById[gmId] = new UserAccount + { + Id = gmId, + Username = "gm", + UsernameNormalized = "GM", + PasswordHash = "hash", + DisplayName = "GM", + Roles = string.Empty + }; + store.UsersById[playerId] = new UserAccount + { + Id = playerId, + Username = "player", + UsernameNormalized = "PLAYER", + PasswordHash = "hash", + DisplayName = "Player", + Roles = string.Empty + }; + store.UsersById[outsiderId] = new UserAccount + { + Id = outsiderId, + Username = "outsider", + UsernameNormalized = "OUTSIDER", + PasswordHash = "hash", + DisplayName = "Outsider", + Roles = string.Empty + }; + + var campaign = new Campaign + { + Id = campaignId, + GmUserId = gmId, + Name = "Alpha", + Ruleset = RulesetKind.D6, + Version = 1 + }; + store.CampaignsById[campaignId] = campaign; + var playerCharacterId = Guid.NewGuid(); + store.CharactersById[playerCharacterId] = new Character + { + Id = playerCharacterId, + OwnerUserId = playerId, + CampaignId = campaignId, + Name = "Scout" + }; + + var publicEntry = new RollLogEntry + { + Id = Guid.NewGuid(), + CampaignId = campaignId, + CharacterId = Guid.NewGuid(), + SkillId = Guid.NewGuid(), + RollerUserId = playerId, + Visibility = RollVisibility.Public, + Result = 12, + Breakdown = "6+6=12", + Dice = "[]", + TimestampUtc = DateTimeOffset.UtcNow + }; + var privateEntry = new RollLogEntry + { + Id = Guid.NewGuid(), + CampaignId = publicEntry.CampaignId, + CharacterId = publicEntry.CharacterId, + SkillId = publicEntry.SkillId, + RollerUserId = publicEntry.RollerUserId, + Visibility = RollVisibility.Private, + Result = publicEntry.Result, + Breakdown = publicEntry.Breakdown, + Dice = publicEntry.Dice, + TimestampUtc = publicEntry.TimestampUtc + }; + + Assert.True(GameAuthorization.HasRole(store.UsersById[adminId], UserRoles.Admin)); + Assert.True(GameAuthorization.CanViewCampaign(store, adminId, campaignId)); + Assert.True(GameAuthorization.CanViewCampaign(store, gmId, campaignId)); + Assert.True(GameAuthorization.CanViewCampaign(store, playerId, campaignId)); + Assert.False(GameAuthorization.CanViewCampaign(store, outsiderId, campaignId)); + + Assert.True(GameAuthorization.CanEditCharacter(playerId, store.CharactersById.Values.Single(), campaign)); + Assert.True(GameAuthorization.CanEditCharacter(gmId, store.CharactersById.Values.Single(), campaign)); + Assert.False(GameAuthorization.CanEditCharacter(outsiderId, store.CharactersById.Values.Single(), campaign)); + + Assert.True(GameAuthorization.CanViewRoll(store, gmId, campaign, privateEntry)); + Assert.True(GameAuthorization.CanViewRoll(store, playerId, campaign, privateEntry)); + Assert.False(GameAuthorization.CanViewRoll(store, outsiderId, campaign, publicEntry)); + Assert.False(GameAuthorization.CanViewRoll(store, outsiderId, campaign, privateEntry)); + } + + [Fact] + public void GameContextResolver_HandlesUnauthorizedForbiddenAndSuccessPaths() + { + var userId = Guid.NewGuid(); + var otherUserId = Guid.NewGuid(); + var campaignId = Guid.NewGuid(); + var store = new GameStateStore(); + + store.UsersById[userId] = new UserAccount + { + Id = userId, + Username = "user", + UsernameNormalized = "USER", + PasswordHash = "hash", + DisplayName = "User", + Roles = string.Empty + }; + store.UsersById[otherUserId] = new UserAccount + { + Id = otherUserId, + Username = "other", + UsernameNormalized = "OTHER", + PasswordHash = "hash", + DisplayName = "Other", + Roles = string.Empty + }; + store.SessionsByToken["valid"] = new UserSession + { + Token = "valid", + UserId = userId, + CreatedAtUtc = DateTimeOffset.UtcNow + }; + store.CampaignsById[campaignId] = new Campaign + { + Id = campaignId, + GmUserId = otherUserId, + Name = "Alpha", + Ruleset = RulesetKind.D6, + Version = 1 + }; + var participant = new Character + { + Id = Guid.NewGuid(), + OwnerUserId = userId, + CampaignId = campaignId, + Name = "Scout" + }; + store.CharactersById[participant.Id] = participant; + + Assert.Null(GameContextResolver.ResolveUserLocked(store, string.Empty)); + Assert.Null(GameContextResolver.ResolveUserLocked(store, "missing")); + Assert.Equal(userId, GameContextResolver.ResolveUserLocked(store, "valid")!.Id); + + Assert.Equal("unauthorized", GameContextResolver.ResolveCampaignContextLocked(store, string.Empty, campaignId).Error!.Code); + Assert.Equal("campaign_not_found", GameContextResolver.ResolveCampaignContextLocked(store, "valid", Guid.NewGuid()).Error!.Code); + + var forbiddenStore = new GameStateStore(); + forbiddenStore.UsersById[userId] = store.UsersById[userId]; + forbiddenStore.SessionsByToken["valid"] = store.SessionsByToken["valid"]; + forbiddenStore.CampaignsById[campaignId] = store.CampaignsById[campaignId]; + Assert.Equal("forbidden", GameContextResolver.ResolveCampaignContextLocked(forbiddenStore, "valid", campaignId).Error!.Code); + + var context = ServiceTestSupport.GetValue(GameContextResolver.ResolveCampaignContextLocked(store, "valid", campaignId)); + Assert.Equal(userId, context.User.Id); + Assert.Equal(campaignId, context.Campaign.Id); + + var orphan = new Character + { + Id = Guid.NewGuid(), + OwnerUserId = userId, + CampaignId = null, + Name = "Orphan" + }; + Assert.False(GameContextResolver.TryResolveCharacterCampaignLocked(store, orphan, out _, out var orphanError)); + Assert.Equal("character_not_in_campaign", orphanError!.Code); + + var missingCampaignCharacter = new Character + { + Id = Guid.NewGuid(), + OwnerUserId = orphan.OwnerUserId, + CampaignId = Guid.NewGuid(), + Name = orphan.Name + }; + Assert.False(GameContextResolver.TryResolveCharacterCampaignLocked(store, missingCampaignCharacter, out _, out var missingCampaignError)); + Assert.Equal("character_not_in_campaign", missingCampaignError!.Code); + + Assert.True(GameContextResolver.TryResolveCharacterCampaignLocked(store, participant, out var resolvedCampaign, out var noError)); + Assert.Equal(campaignId, resolvedCampaign.Id); + Assert.Null(noError); + } + + [Fact] + public void GameDtoMapper_MapsServiceContractsAndFallbacks() + { + var gmId = Guid.NewGuid(); + var ownerId = Guid.NewGuid(); + var blankOwnerId = Guid.NewGuid(); + var campaignId = Guid.NewGuid(); + var characterId = Guid.NewGuid(); + var skillGroupId = Guid.NewGuid(); + var skillId = Guid.NewGuid(); + var rollId = Guid.NewGuid(); + var store = new GameStateStore(); + + store.UsersById[gmId] = new UserAccount + { + Id = gmId, + Username = "gm", + UsernameNormalized = "GM", + PasswordHash = "hash", + DisplayName = "GM", + Roles = UserRoles.Admin + }; + store.UsersById[ownerId] = new UserAccount + { + Id = ownerId, + Username = "owner", + UsernameNormalized = "OWNER", + PasswordHash = "hash", + DisplayName = "Owner", + Roles = string.Empty + }; + store.UsersById[blankOwnerId] = new UserAccount + { + Id = blankOwnerId, + Username = "blank", + UsernameNormalized = "BLANK", + PasswordHash = "hash", + DisplayName = "", + Roles = string.Empty + }; + store.CampaignsById[campaignId] = new Campaign + { + Id = campaignId, + GmUserId = gmId, + Name = "Alpha", + Ruleset = RulesetKind.Rolemaster, + Version = 1 + }; + store.CharactersById[characterId] = new Character + { + Id = characterId, + OwnerUserId = ownerId, + CampaignId = campaignId, + Name = "Scout" + }; + store.SkillGroupsById[skillGroupId] = new SkillGroup + { + Id = skillGroupId, + CharacterId = characterId, + Name = "Awareness", + DiceRollDefinition = "d100!+15", + WildDice = 0, + AllowFumble = false, + FumbleRange = 5 + }; + store.SkillsById[skillId] = new Skill + { + Id = skillId, + CharacterId = characterId, + SkillGroupId = skillGroupId, + Name = "Perception", + DiceRollDefinition = "d100!+25", + WildDice = 0, + AllowFumble = false, + FumbleRange = 3 + }; + store.RebuildCampaignStateLocked(); + store.TouchRosterLocked(campaignId); + store.TouchCharacterLocked(campaignId, characterId); + store.TouchLogLocked(campaignId); + + var dice = new[] { new RollDieResult(66, false, false, false, false, false, 1, RollDieKinds.RolemasterStandard, 66) }; + var logEntry = new RollLogEntry + { + Id = rollId, + CampaignId = campaignId, + CharacterId = characterId, + SkillId = skillId, + RollerUserId = ownerId, + Visibility = RollVisibility.Private, + Result = 91, + Breakdown = "66+25=91", + Dice = "[]", + TimestampUtc = DateTimeOffset.UtcNow + }; + + var userSummary = GameDtoMapper.ToUserSummary(store.UsersById[gmId]); + var adminSummary = GameDtoMapper.ToAdminUserSummary(store.UsersById[gmId]); + var campaignOption = GameDtoMapper.ToCampaignOption(store.CampaignsById[campaignId]); + var campaignSummary = GameDtoMapper.ToCampaignSummary(store, store.CampaignsById[campaignId]); + var campaignRoster = GameDtoMapper.ToCampaignRoster(store, store.CampaignsById[campaignId]); + var characterSummary = GameDtoMapper.ToCharacterSummary(store, store.CharactersById[characterId]); + var sheet = GameDtoMapper.ToCharacterSheet(store, characterId); + var groupSummary = GameDtoMapper.ToSkillGroupSummary(store.SkillGroupsById[skillGroupId]); + var skillSummary = GameDtoMapper.ToSkillSummary(store.SkillsById[skillId]); + var rollResult = GameDtoMapper.ToRollResult(logEntry, dice); + var logDto = GameDtoMapper.ToCampaignLogEntry(logEntry, "Scout", "Perception", "Owner", dice); + var logListDto = GameDtoMapper.ToCampaignLogListEntry(logEntry, "Scout", "Perception", "You", "Private (you)", "private-self", "66 | rolemaster", ["r66"]); + var detail = GameDtoMapper.ToCampaignRollDetail(logEntry, dice); + var snapshot = GameDtoMapper.ToCampaignStateSnapshot(store, campaignId); + + Assert.Contains(UserRoles.Admin, userSummary.Roles); + Assert.Contains(UserRoles.Admin, adminSummary.Roles); + Assert.Equal("Alpha", campaignOption.Name); + Assert.Equal("rolemaster", campaignSummary.RulesetId); + Assert.Single(campaignRoster.Characters); + Assert.Equal("Owner", characterSummary.OwnerDisplayName); + Assert.Single(sheet.SkillGroups); + Assert.Single(sheet.Skills); + Assert.Equal(5, groupSummary.FumbleRange); + Assert.Equal(3, skillSummary.FumbleRange); + Assert.Equal("private", rollResult.Visibility); + Assert.Equal("Owner", logDto.RollerDisplayName); + Assert.Equal("private-self", logListDto.VisibilityStyle); + Assert.Equal(logEntry.Breakdown, detail.Breakdown); + Assert.Equal(campaignId, snapshot.CampaignId); + Assert.Single(snapshot.CharacterVersions); + Assert.Equal("fallback", GameDtoMapper.ResolveOwnerDisplayName(store, blankOwnerId, "fallback")); + Assert.Equal("fallback", GameDtoMapper.ResolveOwnerDisplayName(store, Guid.NewGuid(), "fallback")); + } +} diff --git a/RpgRoller/Services/GameAuthService.cs b/RpgRoller/Services/GameAuthService.cs index d7cb639..300959f 100644 --- a/RpgRoller/Services/GameAuthService.cs +++ b/RpgRoller/Services/GameAuthService.cs @@ -48,7 +48,7 @@ public sealed class GameAuthService m_StateStore.UserIdsByUsername[user.UsernameNormalized] = user.Id; m_PersistenceService.PersistStateLocked(); - return ServiceResult.Success(ToUserSummary(user)); + return ServiceResult.Success(GameDtoMapper.ToUserSummary(user)); } } @@ -73,7 +73,7 @@ public sealed class GameAuthService var session = CreateSession(userId); m_PersistenceService.PersistStateLocked(); - return ServiceResult<(UserSummary User, string SessionToken)>.Success((ToUserSummary(user), session.Token)); + return ServiceResult<(UserSummary User, string SessionToken)>.Success((GameDtoMapper.ToUserSummary(user), session.Token)); } } @@ -90,8 +90,8 @@ public sealed class GameAuthService { lock (m_StateStore.Gate) { - var user = ResolveUserLocked(sessionToken); - return user is null ? null : ToUserSummary(user); + var user = GameContextResolver.ResolveUserLocked(m_StateStore, sessionToken); + return user is null ? null : GameDtoMapper.ToUserSummary(user); } } @@ -99,7 +99,7 @@ public sealed class GameAuthService { lock (m_StateStore.Gate) { - var user = ResolveUserLocked(sessionToken); + var user = GameContextResolver.ResolveUserLocked(m_StateStore, sessionToken); if (user is null) return ServiceResult.Failure("unauthorized", "You must be logged in."); @@ -115,7 +115,7 @@ public sealed class GameAuthService campaignId = activeCharacter.CampaignId; } - return ServiceResult.Success(new(ToUserSummary(user), user.ActiveCharacterId, campaignId)); + return ServiceResult.Success(new(GameDtoMapper.ToUserSummary(user), user.ActiveCharacterId, campaignId)); } } @@ -133,22 +133,6 @@ public sealed class GameAuthService return session; } - 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 UserSummary ToUserSummary(UserAccount user) - { - return new(user.Id, user.Username, user.DisplayName, RoleSerializer.Parse(user.Roles)); - } - private static string NormalizeUsername(string username) { return username.ToUpperInvariant(); diff --git a/RpgRoller/Services/GameAuthorization.cs b/RpgRoller/Services/GameAuthorization.cs new file mode 100644 index 0000000..86f5703 --- /dev/null +++ b/RpgRoller/Services/GameAuthorization.cs @@ -0,0 +1,36 @@ +using RpgRoller.Domain; + +namespace RpgRoller.Services; + +public static class GameAuthorization +{ + public static bool HasRole(UserAccount user, string role) + { + return RoleSerializer.HasRole(user.Roles, role); + } + + public static bool CanViewCampaign(GameStateStore stateStore, Guid actorUserId, Guid campaignId) + { + if (stateStore.UsersById.TryGetValue(actorUserId, out var user) && HasRole(user, UserRoles.Admin)) + return true; + + var campaign = stateStore.CampaignsById[campaignId]; + if (campaign.GmUserId == actorUserId) + return true; + + return stateStore.CharactersById.Values.Any(character => + character.CampaignId == campaignId && + character.OwnerUserId == actorUserId); + } + + public static bool CanEditCharacter(Guid actorUserId, Character character, Campaign campaign) + { + return character.OwnerUserId == actorUserId || campaign.GmUserId == actorUserId; + } + + public static bool CanViewRoll(GameStateStore stateStore, Guid actorUserId, Campaign campaign, RollLogEntry entry) + { + return CanViewCampaign(stateStore, actorUserId, campaign.Id) && + (entry.Visibility == RollVisibility.Public || entry.RollerUserId == actorUserId || campaign.GmUserId == actorUserId); + } +} diff --git a/RpgRoller/Services/GameCampaignService.cs b/RpgRoller/Services/GameCampaignService.cs index f8239ff..bbf60fa 100644 --- a/RpgRoller/Services/GameCampaignService.cs +++ b/RpgRoller/Services/GameCampaignService.cs @@ -22,7 +22,7 @@ public sealed class GameCampaignService lock (m_StateStore.Gate) { - var user = ResolveUserLocked(sessionToken); + var user = GameContextResolver.ResolveUserLocked(m_StateStore, sessionToken); if (user is null) return ServiceResult.Failure("unauthorized", "You must be logged in."); @@ -37,7 +37,7 @@ public sealed class GameCampaignService m_StateStore.CampaignsById[campaign.Id] = campaign; m_PersistenceService.PersistStateLocked(); - return ServiceResult.Success(ToCampaignSummary(campaign)); + return ServiceResult.Success(GameDtoMapper.ToCampaignSummary(m_StateStore, campaign)); } } @@ -45,27 +45,14 @@ public sealed class GameCampaignService { lock (m_StateStore.Gate) { - var user = ResolveUserLocked(sessionToken); + var user = GameContextResolver.ResolveUserLocked(m_StateStore, 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 + var results = m_StateStore.CampaignsById.Values + .Where(campaign => GameAuthorization.CanViewCampaign(m_StateStore, user.Id, campaign.Id)) .OrderBy(campaign => campaign.Name, StringComparer.OrdinalIgnoreCase) - .Select(ToCampaignSummary) + .Select(campaign => GameDtoMapper.ToCampaignSummary(m_StateStore, campaign)) .ToArray(); return ServiceResult>.Success(results); @@ -76,13 +63,13 @@ public sealed class GameCampaignService { lock (m_StateStore.Gate) { - var user = ResolveUserLocked(sessionToken); + var user = GameContextResolver.ResolveUserLocked(m_StateStore, 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)) + .Select(GameDtoMapper.ToCampaignOption) .ToArray(); return ServiceResult>.Success(options); @@ -93,12 +80,12 @@ public sealed class GameCampaignService { lock (m_StateStore.Gate) { - var context = ResolveContextLocked(sessionToken, campaignId); + var context = GameContextResolver.ResolveCampaignContextLocked(m_StateStore, sessionToken, campaignId); if (!context.Succeeded) return ServiceResult.Failure(context.Error!.Code, context.Error.Message); var (_, campaign) = context.Value; - return ServiceResult.Success(ToCampaignRoster(campaign)); + return ServiceResult.Success(GameDtoMapper.ToCampaignRoster(m_StateStore, campaign)); } } @@ -106,14 +93,14 @@ public sealed class GameCampaignService { lock (m_StateStore.Gate) { - var user = ResolveUserLocked(sessionToken); + var user = GameContextResolver.ResolveUserLocked(m_StateStore, 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)) + if (campaign.GmUserId != user.Id && !GameAuthorization.HasRole(user, UserRoles.Admin)) return ServiceResult.Failure("forbidden", "Only the campaign owner or admin can delete this campaign."); DeleteCampaignLocked(campaignId); @@ -122,75 +109,6 @@ public sealed class GameCampaignService } } - 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)) diff --git a/RpgRoller/Services/GameCharacterService.cs b/RpgRoller/Services/GameCharacterService.cs index f2b440f..b64e710 100644 --- a/RpgRoller/Services/GameCharacterService.cs +++ b/RpgRoller/Services/GameCharacterService.cs @@ -18,7 +18,7 @@ public sealed class GameCharacterService lock (m_StateStore.Gate) { - var user = ResolveUserLocked(sessionToken); + var user = GameContextResolver.ResolveUserLocked(m_StateStore, sessionToken); if (user is null) return ServiceResult.Failure("unauthorized", "You must be logged in."); @@ -34,11 +34,11 @@ public sealed class GameCharacterService }; m_StateStore.CharactersById[character.Id] = character; - AddCharacterStateLocked(character.CampaignId, character.Id); - TouchRosterLocked(character.CampaignId); + m_StateStore.AddCharacterStateLocked(character.CampaignId, character.Id); + m_StateStore.TouchRosterLocked(character.CampaignId); m_PersistenceService.PersistStateLocked(); - return ServiceResult.Success(ToCharacterSummary(character)); + return ServiceResult.Success(GameDtoMapper.ToCharacterSummary(m_StateStore, character)); } } @@ -49,7 +49,7 @@ public sealed class GameCharacterService lock (m_StateStore.Gate) { - var user = ResolveUserLocked(sessionToken); + var user = GameContextResolver.ResolveUserLocked(m_StateStore, sessionToken); if (user is null) return ServiceResult.Failure("unauthorized", "You must be logged in."); @@ -61,7 +61,7 @@ public sealed class GameCharacterService 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 isAdmin = GameAuthorization.HasRole(user, UserRoles.Admin); var isSourceGm = character.CampaignId.HasValue && m_StateStore.CampaignsById.TryGetValue(character.CampaignId.Value, out var sourceCampaign) && sourceCampaign.GmUserId == user.Id; @@ -95,16 +95,16 @@ public sealed class GameCharacterService if (sourceCampaignId != character.CampaignId) { - RemoveCharacterStateLocked(sourceCampaignId, character.Id); - AddCharacterStateLocked(character.CampaignId, character.Id); + m_StateStore.RemoveCharacterStateLocked(sourceCampaignId, character.Id); + m_StateStore.AddCharacterStateLocked(character.CampaignId, character.Id); } - TouchRosterLocked(sourceCampaignId); + m_StateStore.TouchRosterLocked(sourceCampaignId); if (sourceCampaignId != character.CampaignId) - TouchRosterLocked(character.CampaignId); + m_StateStore.TouchRosterLocked(character.CampaignId); m_PersistenceService.PersistStateLocked(); - return ServiceResult.Success(ToCharacterSummary(character)); + return ServiceResult.Success(GameDtoMapper.ToCharacterSummary(m_StateStore, character)); } } @@ -112,7 +112,7 @@ public sealed class GameCharacterService { lock (m_StateStore.Gate) { - var user = ResolveUserLocked(sessionToken); + var user = GameContextResolver.ResolveUserLocked(m_StateStore, sessionToken); if (user is null) return ServiceResult.Failure("unauthorized", "You must be logged in."); @@ -120,7 +120,7 @@ public sealed class GameCharacterService return ServiceResult.Failure("character_not_found", "Character was not found."); var isOwner = character.OwnerUserId == user.Id; - var isAdmin = RoleSerializer.HasRole(user.Roles, UserRoles.Admin); + var isAdmin = GameAuthorization.HasRole(user, UserRoles.Admin); if (!isOwner && !isAdmin) return ServiceResult.Failure("forbidden", "Only the owner or admin can delete this character."); @@ -134,7 +134,7 @@ public sealed class GameCharacterService { lock (m_StateStore.Gate) { - var user = ResolveUserLocked(sessionToken); + var user = GameContextResolver.ResolveUserLocked(m_StateStore, sessionToken); if (user is null) return ServiceResult.Failure("unauthorized", "You must be logged in."); @@ -154,69 +154,20 @@ public sealed class GameCharacterService { lock (m_StateStore.Gate) { - var user = ResolveUserLocked(sessionToken); + var user = GameContextResolver.ResolveUserLocked(m_StateStore, 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) + .Select(character => GameDtoMapper.ToCharacterSummary(m_StateStore, character)) .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)) @@ -238,20 +189,8 @@ public sealed class GameCharacterService 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"; + m_StateStore.RemoveCharacterStateLocked(campaignId, characterId); + m_StateStore.TouchRosterLocked(campaignId); } private static string NormalizeUsername(string username) diff --git a/RpgRoller/Services/GameContextResolver.cs b/RpgRoller/Services/GameContextResolver.cs new file mode 100644 index 0000000..f5df96f --- /dev/null +++ b/RpgRoller/Services/GameContextResolver.cs @@ -0,0 +1,48 @@ +using RpgRoller.Domain; + +namespace RpgRoller.Services; + +public static class GameContextResolver +{ + public static UserAccount? ResolveUserLocked(GameStateStore stateStore, string sessionToken) + { + if (string.IsNullOrWhiteSpace(sessionToken)) + return null; + + if (!stateStore.SessionsByToken.TryGetValue(sessionToken, out var session)) + return null; + + return stateStore.UsersById.GetValueOrDefault(session.UserId); + } + + public static ServiceResult<(UserAccount User, Campaign Campaign)> ResolveCampaignContextLocked(GameStateStore stateStore, string sessionToken, Guid campaignId) + { + var user = ResolveUserLocked(stateStore, sessionToken); + if (user is null) + return ServiceResult<(UserAccount User, Campaign Campaign)>.Failure("unauthorized", "You must be logged in."); + + if (!stateStore.CampaignsById.TryGetValue(campaignId, out var campaign)) + return ServiceResult<(UserAccount User, Campaign Campaign)>.Failure("campaign_not_found", "Campaign was not found."); + + if (!GameAuthorization.CanViewCampaign(stateStore, 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)); + } + + public static bool TryResolveCharacterCampaignLocked(GameStateStore stateStore, Character character, out Campaign campaign, out ServiceError? error) + { + campaign = default!; + if (!character.CampaignId.HasValue || + !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; + } +} diff --git a/RpgRoller/Services/GameDtoMapper.cs b/RpgRoller/Services/GameDtoMapper.cs new file mode 100644 index 0000000..a0f21d0 --- /dev/null +++ b/RpgRoller/Services/GameDtoMapper.cs @@ -0,0 +1,151 @@ +using RpgRoller.Contracts; +using RpgRoller.Domain; + +namespace RpgRoller.Services; + +public static class GameDtoMapper +{ + public static UserSummary ToUserSummary(UserAccount user) + { + return new(user.Id, user.Username, user.DisplayName, RoleSerializer.Parse(user.Roles)); + } + + public static AdminUserSummary ToAdminUserSummary(UserAccount user) + { + return new(user.Id, user.Username, user.DisplayName, RoleSerializer.Parse(user.Roles)); + } + + public static CampaignOption ToCampaignOption(Campaign campaign) + { + return new(campaign.Id, campaign.Name); + } + + public static CampaignSummary ToCampaignSummary(GameStateStore stateStore, Campaign campaign) + { + var gm = stateStore.UsersById[campaign.GmUserId]; + var characterCount = 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); + } + + public static CampaignRoster ToCampaignRoster(GameStateStore stateStore, Campaign campaign) + { + var gm = stateStore.UsersById[campaign.GmUserId]; + var characters = stateStore.CharactersById.Values + .Where(character => character.CampaignId == campaign.Id) + .OrderBy(character => character.Name, StringComparer.OrdinalIgnoreCase) + .Select(character => ToCharacterSummary(stateStore, character)) + .ToArray(); + + return new(campaign.Id, campaign.Name, DiceRules.ToRulesetId(campaign.Ruleset), new CampaignGmSummary(gm.Id, gm.DisplayName), characters); + } + + public static CharacterSummary ToCharacterSummary(GameStateStore stateStore, Character character) + { + return new(character.Id, character.Name, character.OwnerUserId, character.CampaignId, ResolveOwnerDisplayName(stateStore, character.OwnerUserId, "Unknown user")); + } + + public static CharacterSheet ToCharacterSheet(GameStateStore stateStore, Guid characterId) + { + var skillGroups = stateStore.SkillGroupsById.Values + .Where(group => group.CharacterId == characterId) + .OrderBy(group => group.Name, StringComparer.OrdinalIgnoreCase) + .Select(ToCharacterSheetSkillGroup) + .ToArray(); + var skills = stateStore.SkillsById.Values + .Where(skill => skill.CharacterId == characterId) + .OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase) + .Select(ToCharacterSheetSkill) + .ToArray(); + + return new(characterId, skillGroups, skills); + } + + public static SkillGroupSummary ToSkillGroupSummary(SkillGroup skillGroup) + { + return new(skillGroup.Id, skillGroup.CharacterId, skillGroup.Name, skillGroup.DiceRollDefinition, skillGroup.WildDice, skillGroup.AllowFumble, skillGroup.FumbleRange); + } + + public static SkillSummary ToSkillSummary(Skill skill) + { + return new(skill.Id, skill.CharacterId, skill.SkillGroupId, skill.Name, skill.DiceRollDefinition, skill.WildDice, skill.AllowFumble, skill.FumbleRange); + } + + public 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); + } + + public static CampaignLogEntry ToCampaignLogEntry(RollLogEntry entry, string characterName, string skillName, string rollerDisplayName, IReadOnlyList dice) + { + return new( + entry.Id, + entry.CampaignId, + entry.CharacterId, + characterName, + entry.SkillId, + skillName, + entry.RollerUserId, + rollerDisplayName, + entry.Visibility == RollVisibility.Public ? "public" : "private", + entry.Result, + entry.Breakdown, + dice, + entry.TimestampUtc); + } + + public static CampaignLogListEntry ToCampaignLogListEntry( + RollLogEntry entry, + string characterName, + string skillName, + string rollerLabel, + string visibilityLabel, + string visibilityStyle, + string summaryText, + string[]? eventBadges) + { + return new( + entry.Id, + characterName, + skillName, + rollerLabel, + visibilityLabel, + visibilityStyle, + entry.Result, + summaryText, + eventBadges, + entry.TimestampUtc); + } + + public static CampaignRollDetail ToCampaignRollDetail(RollLogEntry entry, RollDieResult[] dice) + { + return new(entry.Id, entry.Breakdown, dice); + } + + public static CampaignStateSnapshot ToCampaignStateSnapshot(GameStateStore stateStore, Guid campaignId) + { + var state = stateStore.GetOrCreateCampaignStateLocked(campaignId); + var characterVersions = state.CharacterVersions + .OrderBy(version => version.Key) + .Select(version => new CharacterStateVersion(version.Key, version.Value)) + .ToArray(); + + return new CampaignStateSnapshot(campaignId, state.TotalVersion, state.RosterVersion, state.LogVersion, characterVersions); + } + + public static string ResolveOwnerDisplayName(GameStateStore stateStore, Guid ownerUserId, string fallback) + { + return stateStore.UsersById.TryGetValue(ownerUserId, out var user) && !string.IsNullOrWhiteSpace(user.DisplayName) + ? user.DisplayName + : fallback; + } + + 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); + } +} diff --git a/RpgRoller/Services/GameRollService.cs b/RpgRoller/Services/GameRollService.cs index 29fa6ed..9a29756 100644 --- a/RpgRoller/Services/GameRollService.cs +++ b/RpgRoller/Services/GameRollService.cs @@ -17,7 +17,7 @@ public sealed class GameRollService { lock (m_StateStore.Gate) { - var user = ResolveUserLocked(sessionToken); + var user = GameContextResolver.ResolveUserLocked(m_StateStore, sessionToken); if (user is null) return ServiceResult.Failure("unauthorized", "You must be logged in."); @@ -25,10 +25,10 @@ public sealed class GameRollService 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)) + if (!GameContextResolver.TryResolveCharacterCampaignLocked(m_StateStore, character, out var campaign, out var campaignError)) return ServiceResult.Failure(campaignError!.Code, campaignError.Message); - if (!CanEditCharacterLocked(user.Id, character, campaign)) + if (!GameAuthorization.CanEditCharacter(user.Id, character, campaign)) return ServiceResult.Failure("forbidden", "Only the owner or GM can roll this skill."); var parsedExpression = DiceRules.ParseExpression(campaign.Ruleset, skill.DiceRollDefinition); @@ -48,17 +48,17 @@ public sealed class GameRollService { lock (m_StateStore.Gate) { - var user = ResolveUserLocked(sessionToken); + var user = GameContextResolver.ResolveUserLocked(m_StateStore, 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)) + if (!GameContextResolver.TryResolveCharacterCampaignLocked(m_StateStore, character, out var campaign, out var campaignError)) return ServiceResult.Failure(campaignError!.Code, campaignError.Message); - if (!CanEditCharacterLocked(user.Id, character, campaign)) + if (!GameAuthorization.CanEditCharacter(user.Id, character, campaign)) return ServiceResult.Failure("forbidden", "Only the owner or GM can make a custom roll for this character."); var parsedExpression = DiceRules.ParseExpression(campaign.Ruleset, expression); @@ -79,7 +79,7 @@ public sealed class GameRollService { lock (m_StateStore.Gate) { - var context = ResolveContextLocked(sessionToken, campaignId); + var context = GameContextResolver.ResolveCampaignContextLocked(m_StateStore, sessionToken, campaignId); if (!context.Succeeded) return ServiceResult>.Failure(context.Error!.Code, context.Error.Message); @@ -97,7 +97,7 @@ public sealed class GameRollService { lock (m_StateStore.Gate) { - var context = ResolveContextLocked(sessionToken, campaignId); + var context = GameContextResolver.ResolveCampaignContextLocked(m_StateStore, sessionToken, campaignId); if (!context.Succeeded) return ServiceResult.Failure(context.Error!.Code, context.Error.Message); @@ -137,7 +137,7 @@ public sealed class GameRollService { lock (m_StateStore.Gate) { - var user = ResolveUserLocked(sessionToken); + var user = GameContextResolver.ResolveUserLocked(m_StateStore, sessionToken); if (user is null) return ServiceResult.Failure("unauthorized", "You must be logged in."); @@ -145,10 +145,10 @@ public sealed class GameRollService if (entry is null) return ServiceResult.Failure("roll_not_found", "Roll was not found."); - if (!m_StateStore.CampaignsById.TryGetValue(entry.CampaignId, out var campaign) || !CanViewRollLocked(user, campaign, entry)) + if (!m_StateStore.CampaignsById.TryGetValue(entry.CampaignId, out var campaign) || !GameAuthorization.CanViewRoll(m_StateStore, user.Id, campaign, entry)) return ServiceResult.Failure("roll_not_found", "Roll was not found."); - return ServiceResult.Success(new CampaignRollDetail(entry.Id, entry.Breakdown, DeserializeDice(entry.Dice).ToArray())); + return ServiceResult.Success(GameDtoMapper.ToCampaignRollDetail(entry, DeserializeDice(entry.Dice).ToArray())); } } @@ -156,11 +156,11 @@ public sealed class GameRollService { lock (m_StateStore.Gate) { - var context = ResolveContextLocked(sessionToken, campaignId); + var context = GameContextResolver.ResolveCampaignContextLocked(m_StateStore, sessionToken, campaignId); if (!context.Succeeded) return ServiceResult.Failure(context.Error!.Code, context.Error.Message); - return ServiceResult.Success(ToCampaignStateSnapshot(context.Value!.Campaign)); + return ServiceResult.Success(GameDtoMapper.ToCampaignStateSnapshot(m_StateStore, context.Value!.Campaign.Id)); } } @@ -422,10 +422,10 @@ public sealed class GameRollService }; m_StateStore.RollLog.Add(entry); - TouchLogLocked(campaign.Id); + m_StateStore.TouchLogLocked(campaign.Id); m_PersistenceService.PersistStateLocked(); - return ServiceResult.Success(ToRollResult(entry, roll.Dice)); + return ServiceResult.Success(GameDtoMapper.ToRollResult(entry, roll.Dice)); } private static string FormatLoggedBreakdown(Guid skillId, string canonicalExpression, string breakdown) @@ -435,32 +435,6 @@ public sealed class GameRollService : breakdown; } - 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 CampaignStateSnapshot ToCampaignStateSnapshot(Campaign campaign) - { - var state = GetOrCreateCampaignStateLocked(campaign.Id); - var characterVersions = state.CharacterVersions - .OrderBy(version => version.Key) - .Select(version => new CharacterStateVersion(version.Key, version.Value)) - .ToArray(); - - return new CampaignStateSnapshot(campaign.Id, state.TotalVersion, state.RosterVersion, state.LogVersion, characterVersions); - } - private IEnumerable GetVisibleCampaignLogEntriesLocked(UserAccount user, Campaign campaign) { return m_StateStore.RollLog @@ -470,32 +444,14 @@ public sealed class GameRollService .ThenBy(r => r.Id); } - 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); - } - private CampaignLogEntry ToLogEntry(RollLogEntry entry) { var dice = DeserializeDice(entry.Dice); var characterName = m_StateStore.CharactersById.TryGetValue(entry.CharacterId, out var character) ? character.Name : "Unknown character"; var skillName = ResolveLoggedSkillName(entry); - var rollerDisplayName = ResolveOwnerDisplayName(entry.RollerUserId); + var rollerDisplayName = GameDtoMapper.ResolveOwnerDisplayName(m_StateStore, entry.RollerUserId, "Unknown owner"); - return new( - entry.Id, - entry.CampaignId, - entry.CharacterId, - characterName, - entry.SkillId, - skillName, - entry.RollerUserId, - rollerDisplayName, - entry.Visibility == RollVisibility.Public ? "public" : "private", - entry.Result, - entry.Breakdown, - dice, - entry.TimestampUtc); + return GameDtoMapper.ToCampaignLogEntry(entry, characterName, skillName, rollerDisplayName, dice); } private CampaignLogListEntry ToLogListEntry(UserAccount user, Campaign campaign, RollLogEntry entry) @@ -506,17 +462,15 @@ public sealed class GameRollService var loggedExpression = ResolveLoggedExpression(entry); var eventBadges = BuildCompactLogEventBadges(campaign, loggedExpression, dice); - return new( - entry.Id, + return GameDtoMapper.ToCampaignLogListEntry( + entry, characterName, skillName, ResolveLogRollerLabel(user, campaign, entry), ResolveLogVisibilityLabel(user, campaign, entry), ResolveLogVisibilityStyle(user, campaign, entry), - entry.Result, BuildCompactLogSummary(dice), - eventBadges, - entry.TimestampUtc); + eventBadges); } private static string SerializeDice(IReadOnlyList dice) @@ -648,12 +602,6 @@ public sealed class GameRollService parsedExpression.Value.Sides == 20; } - private bool CanViewRollLocked(UserAccount user, Campaign campaign, RollLogEntry entry) - { - return CanViewCampaignLocked(user.Id, campaign.Id) && - (entry.Visibility == RollVisibility.Public || entry.RollerUserId == user.Id || campaign.GmUserId == user.Id); - } - private string ResolveLogRollerLabel(UserAccount user, Campaign campaign, RollLogEntry entry) { if (entry.RollerUserId == user.Id) @@ -662,7 +610,7 @@ public sealed class GameRollService if (entry.RollerUserId == campaign.GmUserId) return "GM"; - return ResolveOwnerDisplayName(entry.RollerUserId); + return GameDtoMapper.ResolveOwnerDisplayName(m_StateStore, entry.RollerUserId, "Unknown owner"); } private static string ResolveLogVisibilityLabel(UserAccount user, Campaign campaign, RollLogEntry entry) @@ -687,13 +635,6 @@ public sealed class GameRollService return campaign.GmUserId == user.Id ? "private-gm" : "private-generic"; } - private string ResolveOwnerDisplayName(Guid ownerUserId) - { - return m_StateStore.UsersById.TryGetValue(ownerUserId, out var owner) && !string.IsNullOrWhiteSpace(owner.DisplayName) - ? owner.DisplayName - : "Unknown owner"; - } - private static IReadOnlyList DeserializeDice(string serializedDice) { if (string.IsNullOrWhiteSpace(serializedDice)) @@ -709,69 +650,6 @@ public sealed class GameRollService } } - 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 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 void TouchLogLocked(Guid? campaignId) - { - if (!campaignId.HasValue || !m_StateStore.CampaignsById.ContainsKey(campaignId.Value)) - return; - - var state = GetOrCreateCampaignStateLocked(campaignId.Value); - state.TotalVersion += 1; - state.LogVersion += 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 static int NormalizeCampaignLogPageSize(int? limit) { if (!limit.HasValue) diff --git a/RpgRoller/Services/GameService.cs b/RpgRoller/Services/GameService.cs index 090ec23..9f68f26 100644 --- a/RpgRoller/Services/GameService.cs +++ b/RpgRoller/Services/GameService.cs @@ -11,7 +11,6 @@ public sealed class GameService : IGameService public GameService(IDbContextFactory dbContextFactory, IPasswordHasher passwordHasher, IDiceRoller diceRoller) { m_StateStore = new(); - m_Gate = m_StateStore.Gate; m_PersistenceService = new(dbContextFactory, m_StateStore); m_AuthService = new(m_StateStore, passwordHasher, m_PersistenceService); m_CampaignService = new(m_StateStore, m_PersistenceService); @@ -19,7 +18,9 @@ public sealed class GameService : IGameService m_RollService = new(m_StateStore, m_PersistenceService, diceRoller); m_SkillService = new(m_StateStore, m_PersistenceService); m_UserAdministrationService = new(m_StateStore, m_PersistenceService); - LoadStateFromDatabase(); + m_PersistenceService.LoadStateFromDatabase(); + lock (m_StateStore.Gate) + m_StateStore.RebuildCampaignStateLocked(); } public IReadOnlyList GetRulesets() @@ -187,48 +188,9 @@ public sealed class GameService : IGameService return m_RollService.GetCampaignStateSnapshot(sessionToken, campaignId); } - 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 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 RebuildCampaignStateLocked() - { - m_StateStore.CampaignStateById.Clear(); - - foreach (var campaignId in m_StateStore.CampaignsById.Keys) - m_StateStore.CampaignStateById[campaignId] = new GameCampaignStateTracker(); - - foreach (var character in m_StateStore.CharactersById.Values.Where(character => character.CampaignId.HasValue)) - AddCharacterStateLocked(character.CampaignId, character.Id); - } - - private void LoadStateFromDatabase() - { - m_PersistenceService.LoadStateFromDatabase(); - lock (m_Gate) - RebuildCampaignStateLocked(); - } - private readonly GameCampaignService m_CampaignService; private readonly GameCharacterService m_CharacterService; private readonly GameAuthService m_AuthService; - private readonly object m_Gate; private readonly GamePersistenceService m_PersistenceService; private readonly GameRollService m_RollService; private readonly GameSkillService m_SkillService; diff --git a/RpgRoller/Services/GameSkillService.cs b/RpgRoller/Services/GameSkillService.cs index d446a35..b32cdc3 100644 --- a/RpgRoller/Services/GameSkillService.cs +++ b/RpgRoller/Services/GameSkillService.cs @@ -18,17 +18,17 @@ public sealed class GameSkillService lock (m_StateStore.Gate) { - var user = ResolveUserLocked(sessionToken); + var user = GameContextResolver.ResolveUserLocked(m_StateStore, 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)) + if (!GameContextResolver.TryResolveCharacterCampaignLocked(m_StateStore, character, out var campaign, out var campaignError)) return ServiceResult.Failure(campaignError!.Code, campaignError.Message); - if (!CanEditCharacterLocked(user.Id, character, campaign)) + if (!GameAuthorization.CanEditCharacter(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); @@ -47,10 +47,10 @@ public sealed class GameSkillService }; m_StateStore.SkillGroupsById[group.Id] = group; - TouchCharacterLocked(campaign.Id, character.Id); + m_StateStore.TouchCharacterLocked(campaign.Id, character.Id); m_PersistenceService.PersistStateLocked(); - return ServiceResult.Success(ToSkillGroupSummary(group)); + return ServiceResult.Success(GameDtoMapper.ToSkillGroupSummary(group)); } } @@ -61,7 +61,7 @@ public sealed class GameSkillService lock (m_StateStore.Gate) { - var user = ResolveUserLocked(sessionToken); + var user = GameContextResolver.ResolveUserLocked(m_StateStore, sessionToken); if (user is null) return ServiceResult.Failure("unauthorized", "You must be logged in."); @@ -69,10 +69,10 @@ public sealed class GameSkillService 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)) + if (!GameContextResolver.TryResolveCharacterCampaignLocked(m_StateStore, character, out var campaign, out var campaignError)) return ServiceResult.Failure(campaignError!.Code, campaignError.Message); - if (!CanEditCharacterLocked(user.Id, character, campaign)) + if (!GameAuthorization.CanEditCharacter(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); @@ -84,10 +84,10 @@ public sealed class GameSkillService group.WildDice = prototypeValidation.Value.WildDice; group.AllowFumble = prototypeValidation.Value.AllowFumble; group.FumbleRange = prototypeValidation.Value.FumbleRange; - TouchCharacterLocked(campaign.Id, character.Id); + m_StateStore.TouchCharacterLocked(campaign.Id, character.Id); m_PersistenceService.PersistStateLocked(); - return ServiceResult.Success(ToSkillGroupSummary(group)); + return ServiceResult.Success(GameDtoMapper.ToSkillGroupSummary(group)); } } @@ -95,7 +95,7 @@ public sealed class GameSkillService { lock (m_StateStore.Gate) { - var user = ResolveUserLocked(sessionToken); + var user = GameContextResolver.ResolveUserLocked(m_StateStore, sessionToken); if (user is null) return ServiceResult.Failure("unauthorized", "You must be logged in."); @@ -103,17 +103,17 @@ public sealed class GameSkillService 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)) + if (!GameContextResolver.TryResolveCharacterCampaignLocked(m_StateStore, character, out var campaign, out var campaignError)) return ServiceResult.Failure(campaignError!.Code, campaignError.Message); - if (!CanEditCharacterLocked(user.Id, character, campaign)) + if (!GameAuthorization.CanEditCharacter(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_StateStore.TouchCharacterLocked(campaign.Id, character.Id); m_PersistenceService.PersistStateLocked(); return ServiceResult.Success(true); @@ -127,17 +127,17 @@ public sealed class GameSkillService lock (m_StateStore.Gate) { - var user = ResolveUserLocked(sessionToken); + var user = GameContextResolver.ResolveUserLocked(m_StateStore, 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)) + if (!GameContextResolver.TryResolveCharacterCampaignLocked(m_StateStore, character, out var campaign, out var campaignError)) return ServiceResult.Failure(campaignError!.Code, campaignError.Message); - if (!CanEditCharacterLocked(user.Id, character, campaign)) + if (!GameAuthorization.CanEditCharacter(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); @@ -161,10 +161,10 @@ public sealed class GameSkillService }; m_StateStore.SkillsById[skill.Id] = skill; - TouchCharacterLocked(campaign.Id, character.Id); + m_StateStore.TouchCharacterLocked(campaign.Id, character.Id); m_PersistenceService.PersistStateLocked(); - return ServiceResult.Success(ToSkillSummary(skill)); + return ServiceResult.Success(GameDtoMapper.ToSkillSummary(skill)); } } @@ -175,7 +175,7 @@ public sealed class GameSkillService lock (m_StateStore.Gate) { - var user = ResolveUserLocked(sessionToken); + var user = GameContextResolver.ResolveUserLocked(m_StateStore, sessionToken); if (user is null) return ServiceResult.Failure("unauthorized", "You must be logged in."); @@ -183,10 +183,10 @@ public sealed class GameSkillService 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)) + if (!GameContextResolver.TryResolveCharacterCampaignLocked(m_StateStore, character, out var campaign, out var campaignError)) return ServiceResult.Failure(campaignError!.Code, campaignError.Message); - if (!CanEditCharacterLocked(user.Id, character, campaign)) + if (!GameAuthorization.CanEditCharacter(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); @@ -203,10 +203,10 @@ public sealed class GameSkillService skill.AllowFumble = skillValidation.Value.AllowFumble; skill.FumbleRange = skillValidation.Value.FumbleRange; skill.SkillGroupId = resolvedSkillGroupId.Value; - TouchCharacterLocked(campaign.Id, character.Id); + m_StateStore.TouchCharacterLocked(campaign.Id, character.Id); m_PersistenceService.PersistStateLocked(); - return ServiceResult.Success(ToSkillSummary(skill)); + return ServiceResult.Success(GameDtoMapper.ToSkillSummary(skill)); } } @@ -214,7 +214,7 @@ public sealed class GameSkillService { lock (m_StateStore.Gate) { - var user = ResolveUserLocked(sessionToken); + var user = GameContextResolver.ResolveUserLocked(m_StateStore, sessionToken); if (user is null) return ServiceResult.Failure("unauthorized", "You must be logged in."); @@ -222,14 +222,14 @@ public sealed class GameSkillService 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)) + if (!GameContextResolver.TryResolveCharacterCampaignLocked(m_StateStore, character, out var campaign, out var campaignError)) return ServiceResult.Failure(campaignError!.Code, campaignError.Message); - if (!CanEditCharacterLocked(user.Id, character, campaign)) + if (!GameAuthorization.CanEditCharacter(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_StateStore.TouchCharacterLocked(campaign.Id, character.Id); m_PersistenceService.PersistStateLocked(); return ServiceResult.Success(true); @@ -240,65 +240,23 @@ public sealed class GameSkillService { lock (m_StateStore.Gate) { - var user = ResolveUserLocked(sessionToken); + var user = GameContextResolver.ResolveUserLocked(m_StateStore, 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)) + if (!GameContextResolver.TryResolveCharacterCampaignLocked(m_StateStore, character, out var campaign, out var campaignError)) return ServiceResult.Failure(campaignError!.Code, campaignError.Message); - if (!CanViewCampaignLocked(user.Id, campaign.Id)) + if (!GameAuthorization.CanViewCampaign(m_StateStore, user.Id, campaign.Id)) return ServiceResult.Failure("forbidden", "You are not a participant in this campaign."); - return ServiceResult.Success(ToCharacterSheet(character.Id)); + return ServiceResult.Success(GameDtoMapper.ToCharacterSheet(m_StateStore, 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) @@ -313,63 +271,6 @@ public sealed class GameSkillService 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; } diff --git a/RpgRoller/Services/GameStateStore.cs b/RpgRoller/Services/GameStateStore.cs index 77ad6b2..c313499 100644 --- a/RpgRoller/Services/GameStateStore.cs +++ b/RpgRoller/Services/GameStateStore.cs @@ -14,6 +14,75 @@ public sealed class GameStateStore public Dictionary SkillsById { get; } = []; public Dictionary UserIdsByUsername { get; } = new(StringComparer.Ordinal); public Dictionary UsersById { get; } = []; + + public GameCampaignStateTracker GetOrCreateCampaignStateLocked(Guid campaignId) + { + if (!CampaignStateById.TryGetValue(campaignId, out var state)) + { + state = new GameCampaignStateTracker(); + CampaignStateById[campaignId] = state; + } + + return state; + } + + public void RebuildCampaignStateLocked() + { + CampaignStateById.Clear(); + + foreach (var campaignId in CampaignsById.Keys) + CampaignStateById[campaignId] = new GameCampaignStateTracker(); + + foreach (var character in CharactersById.Values.Where(character => character.CampaignId.HasValue)) + AddCharacterStateLocked(character.CampaignId, character.Id); + } + + public void AddCharacterStateLocked(Guid? campaignId, Guid characterId) + { + if (!campaignId.HasValue || !CampaignsById.ContainsKey(campaignId.Value)) + return; + + var state = GetOrCreateCampaignStateLocked(campaignId.Value); + state.CharacterVersions[characterId] = 1; + } + + public void RemoveCharacterStateLocked(Guid? campaignId, Guid characterId) + { + if (!campaignId.HasValue || !CampaignStateById.TryGetValue(campaignId.Value, out var state)) + return; + + state.CharacterVersions.Remove(characterId); + } + + public void TouchRosterLocked(Guid? campaignId) + { + if (!campaignId.HasValue || !CampaignsById.ContainsKey(campaignId.Value)) + return; + + var state = GetOrCreateCampaignStateLocked(campaignId.Value); + state.TotalVersion += 1; + state.RosterVersion += 1; + } + + public void TouchCharacterLocked(Guid? campaignId, Guid characterId) + { + if (!campaignId.HasValue || !CampaignsById.ContainsKey(campaignId.Value)) + return; + + var state = GetOrCreateCampaignStateLocked(campaignId.Value); + state.TotalVersion += 1; + state.CharacterVersions[characterId] = state.CharacterVersions.GetValueOrDefault(characterId, 1) + 1; + } + + public void TouchLogLocked(Guid? campaignId) + { + if (!campaignId.HasValue || !CampaignsById.ContainsKey(campaignId.Value)) + return; + + var state = GetOrCreateCampaignStateLocked(campaignId.Value); + state.TotalVersion += 1; + state.LogVersion += 1; + } } public sealed class GameCampaignStateTracker diff --git a/RpgRoller/Services/GameUserAdministrationService.cs b/RpgRoller/Services/GameUserAdministrationService.cs index fe5564e..6b8eb64 100644 --- a/RpgRoller/Services/GameUserAdministrationService.cs +++ b/RpgRoller/Services/GameUserAdministrationService.cs @@ -15,7 +15,7 @@ public sealed class GameUserAdministrationService { lock (m_StateStore.Gate) { - var user = ResolveUserLocked(sessionToken); + var user = GameContextResolver.ResolveUserLocked(m_StateStore, sessionToken); if (user is null) return ServiceResult>.Failure("unauthorized", "You must be logged in."); @@ -32,16 +32,16 @@ public sealed class GameUserAdministrationService { lock (m_StateStore.Gate) { - var user = ResolveUserLocked(sessionToken); + var user = GameContextResolver.ResolveUserLocked(m_StateStore, sessionToken); if (user is null) return ServiceResult>.Failure("unauthorized", "You must be logged in."); - if (!UserHasRoleLocked(user, UserRoles.Admin)) + if (!GameAuthorization.HasRole(user, UserRoles.Admin)) return ServiceResult>.Failure("forbidden", "Admin role is required."); var users = m_StateStore.UsersById.Values .OrderBy(account => account.Username, StringComparer.OrdinalIgnoreCase) - .Select(ToAdminUserSummary) + .Select(GameDtoMapper.ToAdminUserSummary) .ToArray(); return ServiceResult>.Success(users); @@ -52,11 +52,11 @@ public sealed class GameUserAdministrationService { lock (m_StateStore.Gate) { - var user = ResolveUserLocked(sessionToken); + var user = GameContextResolver.ResolveUserLocked(m_StateStore, sessionToken); if (user is null) return ServiceResult.Failure("unauthorized", "You must be logged in."); - if (!UserHasRoleLocked(user, UserRoles.Admin)) + if (!GameAuthorization.HasRole(user, UserRoles.Admin)) return ServiceResult.Failure("forbidden", "Admin role is required."); if (!m_StateStore.UsersById.TryGetValue(userId, out var targetUser)) @@ -71,7 +71,7 @@ public sealed class GameUserAdministrationService targetUser.Roles = RoleSerializer.Serialize(normalizedRoles); m_PersistenceService.PersistStateLocked(); - return ServiceResult.Success(ToAdminUserSummary(targetUser)); + return ServiceResult.Success(GameDtoMapper.ToAdminUserSummary(targetUser)); } } @@ -79,11 +79,11 @@ public sealed class GameUserAdministrationService { lock (m_StateStore.Gate) { - var user = ResolveUserLocked(sessionToken); + var user = GameContextResolver.ResolveUserLocked(m_StateStore, sessionToken); if (user is null) return ServiceResult.Failure("unauthorized", "You must be logged in."); - if (!UserHasRoleLocked(user, UserRoles.Admin)) + if (!GameAuthorization.HasRole(user, UserRoles.Admin)) return ServiceResult.Failure("forbidden", "Admin role is required."); if (user.Id == userId) @@ -129,27 +129,6 @@ public sealed class GameUserAdministrationService } } - 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)) @@ -193,37 +172,8 @@ public sealed class GameUserAdministrationService 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; + m_StateStore.RemoveCharacterStateLocked(campaignId, characterId); + m_StateStore.TouchRosterLocked(campaignId); } private readonly GamePersistenceService m_PersistenceService; diff --git a/TASKS.md b/TASKS.md index 3153d2f..552cd6e 100644 --- a/TASKS.md +++ b/TASKS.md @@ -16,22 +16,22 @@ The user-visible proof is intentionally boring: after starting the app, logging - [x] (2026-04-04 22:46Z) Inspected the current backend state. `RpgRoller/Services/GameService.cs`, `GameStateStore.cs`, `GamePersistenceService.cs`, `GameAuthService.cs`, `GameCampaignService.cs`, `GameCharacterService.cs`, `GameSkillService.cs`, `GameRollService.cs`, and `GameUserAdministrationService.cs` already exist. - [x] (2026-04-04 22:46Z) Inspected the current frontend state. `RpgRoller/Components/Pages/WorkspaceState.cs`, `WorkspaceSessionCoordinator.cs`, `WorkspaceCampaignCoordinator.cs`, `WorkspaceCampaignScopeCoordinator.cs`, `WorkspacePlayCoordinator.cs`, `WorkspaceAdminCoordinator.cs`, `WorkspaceLiveStateController.cs`, `WorkspaceFeedbackService.cs`, and `WorkspaceToast.cs` already exist. - [x] (2026-04-04 22:46Z) Marked the large structural extractions as already done in this plan instead of treating the repository as pre-refactor. -- [ ] Complete backend shared-helper consolidation. Remaining work: move duplicated authorization, session resolution, campaign-context resolution, mapping, and campaign-state version helpers out of the domain services and into dedicated helper files or `GameStateStore` methods. +- [x] (2026-04-04 23:03Z) Completed backend shared-helper consolidation. `GameStateStore` now owns campaign-state version mutations, `GameAuthorization`, `GameContextResolver`, and `GameDtoMapper` now own the shared helper seams, and the domain services delegate to them instead of keeping private copies. - [ ] Complete backend roll decomposition. Remaining work: extract the dice engines, breakdown formatting, and compact log summary logic out of `RpgRoller/Services/GameRollService.cs` while preserving all existing roll behavior. -- [ ] Finish thinning `RpgRoller/Services/GameService.cs`. Remaining work: remove the remaining startup and campaign-state helper methods so the class only wires collaborators and delegates public `IGameService` calls. +- [x] (2026-04-04 23:03Z) Finished thinning `RpgRoller/Services/GameService.cs` for startup and campaign-state bootstrap. The constructor now loads persistence and rebuilds campaign-state versions through `GameStateStore` without keeping private helper methods. - [ ] Finish thinning `RpgRoller/Components/Pages/Workspace.razor.cs`. Remaining work: remove the large mirror of `WorkspaceState` properties and the excess pass-through wrappers so the file acts as a composition root plus lifecycle and JS-invokable bridge. -- [ ] Update `README.md` and this ExecPlan after the code changes land so the documentation reflects the final, not intermediate, structure. +- [ ] Update `README.md` and this ExecPlan after the remaining code changes land so the documentation reflects the final, not intermediate, structure. Completed in this iteration: backend helper descriptions and current remaining scope. ## Surprises & Discoveries -- Observation: `GameService` is already mostly a thin facade, but it still owns startup and campaign-state rebuilding work. - Evidence: `RpgRoller/Services/GameService.cs` constructs collaborators and delegates public methods, yet still contains `LoadStateFromDatabase`, `RebuildCampaignStateLocked`, `AddCharacterStateLocked`, and `GetOrCreateCampaignStateLocked`. +- Observation: backend helper consolidation was lower risk than it first looked because most duplicated code already matched line-for-line semantics. + Evidence: after moving authorization, session resolution, and mapping into `GameAuthorization`, `GameContextResolver`, and `GameDtoMapper`, the surrounding service tests passed without behavioral updates. -- Observation: the backend refactor stopped after file extraction, not after helper consolidation. - Evidence: `RpgRoller/Services/GameCampaignService.cs`, `GameCharacterService.cs`, `GameSkillService.cs`, `GameRollService.cs`, and `GameUserAdministrationService.cs` each still define their own `ResolveUserLocked`, campaign visibility logic, owner display mapping, or campaign-state mutation helpers. +- Observation: `GameRollService` remained the only backend file with broad mixed ownership after the shared helpers moved out. + Evidence: the service now delegates authorization, context, state-snapshot mapping, and roll/log DTO mapping, but it still contains dice algorithms, compact log summary formatting, event badge generation, and dice serialization in one file. - Observation: `GameRollService` is now the main backend monolith. - Evidence: `RpgRoller/Services/GameRollService.cs` still owns authorization checks, campaign-context resolution, D6 logic, Rolemaster logic, log summary formatting, event badge generation, JSON dice serialization, and campaign-state snapshot shaping in one file. + Evidence: `RpgRoller/Services/GameRollService.cs` still owns D6 logic, Rolemaster logic, log summary formatting, event badge generation, and JSON dice serialization in one file. - Observation: the frontend refactor introduced one extra collaborator that was not named in the original blueprint, and that collaborator is worth keeping. Evidence: `RpgRoller/Components/Pages/WorkspaceCampaignScopeCoordinator.cs` now owns selected-campaign reload, selected-character synchronization, log reset, and unauthorized-session handling. Those behaviors are cohesive and should not be pushed back into `Workspace.razor.cs`. @@ -56,15 +56,19 @@ The user-visible proof is intentionally boring: after starting the app, logging Rationale: most of the benefit of the first extraction is already present. The missing value now is shared helper ownership, not more top-level service files. Date/Author: 2026-04-04 / Codex +- Decision: Keep the new backend helper seams static for now instead of introducing injected utility services. + Rationale: the extracted logic is pure over `GameStateStore` plus method inputs, so static helpers keep wiring simple while still removing the duplication that was obscuring the domain services. + Date/Author: 2026-04-04 / Codex + - Decision: Keep validation instructions in this ExecPlan even though this revision is documentation-only. Rationale: `PLANS.md` requires executable validation guidance, but the user explicitly requested no CI or test work for this pass. The commands remain here for the implementation pass that follows later. Date/Author: 2026-04-04 / Codex ## Outcomes & Retrospective -This document rewrite did not change application behavior. It changed the planning artifact so that it now matches the code that is already in the repository. A future contributor can start from this file alone and understand which refactor steps are complete, which files already exist, and which structural seams are still missing. +The repository now has the shared backend seams that the earlier rewrite described as missing. `GameStateStore` owns campaign-state version mutation, `GameAuthorization` owns shared access checks, `GameContextResolver` owns session and campaign resolution, and `GameDtoMapper` owns the backend read-model construction that had been repeated across services. -The remaining work is narrower than the original blueprint implied. The repository no longer needs first-wave extraction. It needs second-wave cleanup: shared helper consolidation, roll-engine breakup, final facade thinning, and Razor binding simplification. That narrower scope should keep the next implementation iterations small. +The remaining work is narrower than before. The repository now needs second-wave cleanup focused mainly on `GameRollService` algorithm extraction and the final `Workspace` binding cleanup. `GameService` is already at the intended facade shape, so later iterations can stay smaller and more reviewable. ## Context and Orientation @@ -74,7 +78,7 @@ A "thin facade" in this plan means a class that mainly wires collaborators and f The current backend state is better than the old monolith. `RpgRoller/Services/GameService.cs` already delegates its public methods to `GameAuthService`, `GameCampaignService`, `GameCharacterService`, `GameSkillService`, `GameRollService`, and `GameUserAdministrationService`. `RpgRoller/Services/GamePersistenceService.cs` already owns SQLite loading and saving. `RpgRoller/Services/SkillDefinitionValidator.cs`, `RoleSerializer.cs`, `RollVisibilityParser.cs`, `CustomRollOptionsResolver.cs`, and `GameStateCloneFactory.cs` already exist as helper files. -The remaining backend problem is duplication. Several services still repeat the same logic for resolving the current user from a session token, checking whether someone can view or edit a campaign, building summaries, and incrementing campaign-state versions. `RpgRoller/Services/GameRollService.cs` is still a large mixed file that combines access checks, dice algorithms, log formatting, and read-model shaping. That is the main backend target. +The backend shared-helper duplication is now resolved. `RpgRoller/Services/GameStateStore.cs` owns campaign-state version mutations. `RpgRoller/Services/GameAuthorization.cs` owns shared access checks. `RpgRoller/Services/GameContextResolver.cs` owns session-token and campaign resolution. `RpgRoller/Services/GameDtoMapper.cs` owns the backend read models returned by the services. The main backend target that remains is `RpgRoller/Services/GameRollService.cs`, which still combines dice algorithms, compact log formatting, event badges, and dice serialization in one file. The current frontend state is also better than the old monolith. `RpgRoller/Components/Pages/WorkspaceState.cs` holds most UI state and many computed projections. Session/bootstrap behavior lives in `WorkspaceSessionCoordinator.cs`. Campaign management and modal flows live in `WorkspaceCampaignCoordinator.cs`. Selected campaign scope refresh lives in `WorkspaceCampaignScopeCoordinator.cs`. Play/log behavior lives in `WorkspacePlayCoordinator.cs`. Admin behavior lives in `WorkspaceAdminCoordinator.cs`. Live event reconciliation lives in `WorkspaceLiveStateController.cs`. Toast and announcement behavior lives in `WorkspaceFeedbackService.cs`. @@ -123,15 +127,16 @@ Start every future implementation pass by re-reading the plan and checking the c git status --short rg --files RpgRoller/Services RpgRoller/Components/Pages -When beginning backend helper consolidation, inspect the current duplication before editing: +Shared backend helper consolidation is complete in the current tree. The next backend pass should begin by inspecting the remaining roll-service concentration before editing: Get-Content RpgRoller\Services\GameService.cs - Get-Content RpgRoller\Services\GameCharacterService.cs - Get-Content RpgRoller\Services\GameSkillService.cs Get-Content RpgRoller\Services\GameRollService.cs - Get-Content RpgRoller\Services\GameUserAdministrationService.cs + Get-Content RpgRoller\Services\GameAuthorization.cs + Get-Content RpgRoller\Services\GameContextResolver.cs + Get-Content RpgRoller\Services\GameDtoMapper.cs + Get-Content RpgRoller\Services\GameStateStore.cs -Create or update the shared helper files, then replace each duplicated private helper with calls into the shared files or `GameStateStore`. Keep each change small enough that a single review can verify behavior stayed the same. +Keep the next extraction small. Move one cohesive cluster at a time out of `GameRollService` so tests can prove that dice totals, breakdown strings, and compact log responses stayed unchanged. When beginning frontend cleanup, inspect the current composition surface before editing: @@ -152,7 +157,7 @@ The expected result is simple: no failing tests, no coverage regression, and the ## Validation and Acceptance -This documentation-only revision intentionally did not run CI, tests, or Playwright. Those checks remain mandatory when implementation resumes. +This implementation revision ran targeted helper tests during extraction. Full repo validation through `pwsh ./scripts/ci-local.ps1` remains mandatory before considering the iteration complete. The backend is accepted when `RpgRoller/Services/GameService.cs` contains only collaborator wiring, ruleset enumeration, and public delegation; when shared authorization, context, mapping, and campaign-state helper logic each live in one place; and when `RpgRoller/Services/GameRollService.cs` no longer embeds the dice engines or compact log summary builders. @@ -260,3 +265,5 @@ The component may keep tiny wrapper methods for lifecycle, `JSInvokable` entry p In `RpgRoller/Components/Pages/WorkspaceState.cs`, keep all plain state plus pure computed and formatting helpers needed directly by the Razor file. That includes selected campaign name, selected play character projections, screen flags, connection-state label and CSS class, app CSS class, owner labels, and skill-definition labels. Revision note (2026-04-04): Replaced the old blueprint with an ExecPlan, reconciled it against the code already present in the repository, and marked completed versus remaining refactor work after direct file inspection. The reason for this rewrite is that `AGENTS.md` now requires complex refactors to be tracked as ExecPlans maintained under `PLANS.md`. + +Revision note (2026-04-04 23:03Z): Marked backend shared-helper consolidation and `GameService` facade thinning as complete after implementing `GameAuthorization`, `GameContextResolver`, `GameDtoMapper`, and `GameStateStore` tracker methods. Updated the remaining scope so the next pass starts with `GameRollService` decomposition and later `Workspace` cleanup.