From 17b049d2ca28f1a4e397f063ce4ec133020dc0a1 Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Sat, 4 Apr 2026 23:51:33 +0200 Subject: [PATCH] Extract game user admin service --- README.md | 1 + .../ServiceAdminAndCampaignDeletionTests.cs | 10 + RpgRoller/Services/GameService.cs | 260 +----------------- .../Services/GameUserAdministrationService.cs | 231 ++++++++++++++++ 4 files changed, 255 insertions(+), 247 deletions(-) create mode 100644 RpgRoller/Services/GameUserAdministrationService.cs diff --git a/README.md b/README.md index f0e1959..7e6323e 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ Backend: - `RpgRoller/Services/GameCharacterService.cs`: extracted character creation, transfer, activation, deletion, and owner-scoped listing workflows behind the same facade contract - `RpgRoller/Services/GameSkillService.cs`: extracted skill-group CRUD, skill CRUD, sheet shaping, and ruleset-specific validation orchestration behind the same facade contract - `RpgRoller/Services/GameRollService.cs`: extracted roll execution, log shaping, roll detail visibility, and campaign-state snapshot reads behind the same facade contract +- `RpgRoller/Services/GameUserAdministrationService.cs`: extracted username reads, admin user listings, role updates, and account deletion workflows behind the same facade contract Frontend: diff --git a/RpgRoller.Tests/Services/ServiceAdminAndCampaignDeletionTests.cs b/RpgRoller.Tests/Services/ServiceAdminAndCampaignDeletionTests.cs index 8ad314d..5ed2690 100644 --- a/RpgRoller.Tests/Services/ServiceAdminAndCampaignDeletionTests.cs +++ b/RpgRoller.Tests/Services/ServiceAdminAndCampaignDeletionTests.cs @@ -18,6 +18,9 @@ public sealed class ServiceAdminAndCampaignDeletionTests var adminSession = ServiceTestSupport.GetValue(service.Login("admin", "Password123")).SessionToken; var memberSession = ServiceTestSupport.GetValue(service.Login("member", "Password123")).SessionToken; + var usernames = ServiceTestSupport.GetValue(service.GetUsernames(memberSession)); + Assert.Equal(["admin", "member"], usernames); + var forbiddenList = service.GetUsers(memberSession); Assert.False(forbiddenList.Succeeded); @@ -28,6 +31,10 @@ public sealed class ServiceAdminAndCampaignDeletionTests var promoted = ServiceTestSupport.GetValue(service.UpdateUserRoles(adminSession, memberUser.Id, [UserRoles.Admin])); Assert.Contains(promoted.Roles, role => string.Equals(role, UserRoles.Admin, StringComparison.OrdinalIgnoreCase)); + var invalidRole = service.UpdateUserRoles(adminSession, memberUser.Id, [UserRoles.Admin, "gm"]); + Assert.False(invalidRole.Succeeded); + Assert.Equal("invalid_role", invalidRole.Error?.Code); + var selfDemote = service.UpdateUserRoles(adminSession, bootstrapAdmin.Id, Array.Empty()); Assert.False(selfDemote.Succeeded); @@ -111,6 +118,9 @@ public sealed class ServiceAdminAndCampaignDeletionTests var deleteResult = ServiceTestSupport.GetValue(service.DeleteUser(adminSession, gmUser.Id)); Assert.True(deleteResult); + Assert.Null(service.GetUserBySession(gmSession)); + Assert.False(service.GetMe(gmSession).Succeeded); + Assert.False(service.GetUsernames(gmSession).Succeeded); Assert.False(service.GetCampaign(adminSession, gmOwnedCampaign.Id).Succeeded); var playerCharacters = ServiceTestSupport.GetValue(service.GetOwnCharacters(playerSession)); diff --git a/RpgRoller/Services/GameService.cs b/RpgRoller/Services/GameService.cs index 628a02a..090ec23 100644 --- a/RpgRoller/Services/GameService.cs +++ b/RpgRoller/Services/GameService.cs @@ -11,22 +11,14 @@ public sealed class GameService : IGameService public GameService(IDbContextFactory dbContextFactory, IPasswordHasher passwordHasher, IDiceRoller diceRoller) { m_StateStore = new(); - m_CampaignsById = m_StateStore.CampaignsById; - m_CampaignStateById = m_StateStore.CampaignStateById; - m_CharactersById = m_StateStore.CharactersById; m_Gate = m_StateStore.Gate; - m_RollLog = m_StateStore.RollLog; - m_SessionsByToken = m_StateStore.SessionsByToken; - m_SkillGroupsById = m_StateStore.SkillGroupsById; - m_SkillsById = m_StateStore.SkillsById; - m_UserIdsByUsername = m_StateStore.UserIdsByUsername; - m_UsersById = m_StateStore.UsersById; m_PersistenceService = new(dbContextFactory, m_StateStore); m_AuthService = new(m_StateStore, passwordHasher, m_PersistenceService); m_CampaignService = new(m_StateStore, m_PersistenceService); m_CharacterService = new(m_StateStore, m_PersistenceService); m_RollService = new(m_StateStore, m_PersistenceService, diceRoller); m_SkillService = new(m_StateStore, m_PersistenceService); + m_UserAdministrationService = new(m_StateStore, m_PersistenceService); LoadStateFromDatabase(); } @@ -87,109 +79,22 @@ public sealed class GameService : IGameService public ServiceResult> GetUsernames(string sessionToken) { - lock (m_Gate) - { - var user = ResolveUserLocked(sessionToken); - if (user is null) - return ServiceResult>.Failure("unauthorized", "You must be logged in."); - - var usernames = m_UsersById.Values.Select(account => account.Username).OrderBy(username => username, StringComparer.OrdinalIgnoreCase).ToArray(); - return ServiceResult>.Success(usernames); - } + return m_UserAdministrationService.GetUsernames(sessionToken); } public ServiceResult> GetUsers(string sessionToken) { - lock (m_Gate) - { - var user = ResolveUserLocked(sessionToken); - if (user is null) - return ServiceResult>.Failure("unauthorized", "You must be logged in."); - - if (!UserHasRoleLocked(user, UserRoles.Admin)) - return ServiceResult>.Failure("forbidden", "Admin role is required."); - - var users = m_UsersById.Values - .OrderBy(account => account.Username, StringComparer.OrdinalIgnoreCase) - .Select(ToAdminUserSummary) - .ToArray(); - return ServiceResult>.Success(users); - } + return m_UserAdministrationService.GetUsers(sessionToken); } public ServiceResult UpdateUserRoles(string sessionToken, Guid userId, IReadOnlyList roles) { - lock (m_Gate) - { - var user = ResolveUserLocked(sessionToken); - if (user is null) - return ServiceResult.Failure("unauthorized", "You must be logged in."); - - if (!UserHasRoleLocked(user, UserRoles.Admin)) - return ServiceResult.Failure("forbidden", "Admin role is required."); - - if (!m_UsersById.TryGetValue(userId, out var targetUser)) - return ServiceResult.Failure("user_not_found", "User was not found."); - - var normalizedRoles = RoleSerializer.Normalize(roles); - if (normalizedRoles.Any(role => !string.Equals(role, UserRoles.Admin, StringComparison.Ordinal))) - return ServiceResult.Failure("invalid_role", "Unsupported role."); - - if (user.Id == targetUser.Id && !normalizedRoles.Contains(UserRoles.Admin, StringComparer.Ordinal)) - return ServiceResult.Failure("forbidden", "You cannot remove your own admin role."); - - targetUser.Roles = RoleSerializer.Serialize(normalizedRoles); - PersistStateLocked(); - return ServiceResult.Success(ToAdminUserSummary(targetUser)); - } + return m_UserAdministrationService.UpdateUserRoles(sessionToken, userId, roles); } public ServiceResult DeleteUser(string sessionToken, Guid userId) { - lock (m_Gate) - { - var user = ResolveUserLocked(sessionToken); - if (user is null) - return ServiceResult.Failure("unauthorized", "You must be logged in."); - - if (!UserHasRoleLocked(user, UserRoles.Admin)) - return ServiceResult.Failure("forbidden", "Admin role is required."); - - if (user.Id == userId) - return ServiceResult.Failure("forbidden", "You cannot delete your own account."); - - if (!m_UsersById.TryGetValue(userId, out var targetUser)) - return ServiceResult.Failure("user_not_found", "User was not found."); - - var gmCampaignIds = m_CampaignsById.Values.Where(campaign => campaign.GmUserId == targetUser.Id).Select(campaign => campaign.Id).ToArray(); - var gmCampaignIdSet = gmCampaignIds.ToHashSet(); - var preservedCharacterIds = m_CharactersById.Values - .Where(character => character.CampaignId.HasValue && gmCampaignIdSet.Contains(character.CampaignId.Value)) - .Select(character => character.Id) - .ToHashSet(); - - foreach (var campaignId in gmCampaignIds) - DeleteCampaignLocked(campaignId); - - var ownedCharacterIds = m_CharactersById.Values - .Where(character => character.OwnerUserId == targetUser.Id && !preservedCharacterIds.Contains(character.Id)) - .Select(character => character.Id) - .ToArray(); - foreach (var characterId in ownedCharacterIds) - DeleteCharacterLocked(characterId); - - m_RollLog.RemoveAll(entry => entry.RollerUserId == targetUser.Id); - - var staleSessions = m_SessionsByToken.Values.Where(session => session.UserId == targetUser.Id).Select(session => session.Token).ToArray(); - foreach (var token in staleSessions) - m_SessionsByToken.Remove(token); - - m_UsersById.Remove(targetUser.Id); - m_UserIdsByUsername.Remove(targetUser.UsernameNormalized); - - PersistStateLocked(); - return ServiceResult.Success(true); - } + return m_UserAdministrationService.DeleteUser(sessionToken, userId); } public ServiceResult CreateCharacter(string sessionToken, string name, Guid campaignId) @@ -282,110 +187,12 @@ public sealed class GameService : IGameService return m_RollService.GetCampaignStateSnapshot(sessionToken, campaignId); } - private static UserSummary ToUserSummary(UserAccount user) - { - return new(user.Id, user.Username, user.DisplayName, RoleSerializer.Parse(user.Roles)); - } - - private static AdminUserSummary ToAdminUserSummary(UserAccount user) - { - return new(user.Id, user.Username, user.DisplayName, RoleSerializer.Parse(user.Roles)); - } - - private bool TryGetCurrentCampaignIdLocked(UserAccount user, out Guid campaignId) - { - campaignId = Guid.Empty; - if (user.ActiveCharacterId is not Guid activeCharacterId) - return false; - - if (!m_CharactersById.TryGetValue(activeCharacterId, out var character)) - { - user.ActiveCharacterId = null; - PersistStateLocked(); - return false; - } - - if (!character.CampaignId.HasValue) - return false; - - campaignId = character.CampaignId.Value; - return true; - } - - private bool TryResolveCharacterCampaignLocked(Character character, out Campaign campaign, out ServiceError? error) - { - campaign = default!; - if (!character.CampaignId.HasValue || !m_CampaignsById.TryGetValue(character.CampaignId.Value, out var resolvedCampaign) || resolvedCampaign is null) - { - error = new ServiceError("character_not_in_campaign", "Character is not linked to a campaign."); - return false; - } - - campaign = resolvedCampaign; - error = null; - return true; - } - - private void DeleteCampaignLocked(Guid campaignId) - { - if (!m_CampaignsById.Remove(campaignId)) - return; - - var affectedCharacterIds = m_CharactersById.Values.Where(character => character.CampaignId == campaignId).Select(character => character.Id).ToArray(); - foreach (var characterId in affectedCharacterIds) - m_CharactersById[characterId].CampaignId = null; - - m_RollLog.RemoveAll(entry => entry.CampaignId == campaignId); - m_CampaignStateById.Remove(campaignId); - } - - private void DeleteCharacterLocked(Guid characterId) - { - if (!m_CharactersById.TryGetValue(characterId, out var character)) - return; - - var campaignId = character.CampaignId; - m_CharactersById.Remove(characterId); - - var skillGroupIds = m_SkillGroupsById.Values.Where(group => group.CharacterId == characterId).Select(group => group.Id).ToHashSet(); - foreach (var skillGroupId in skillGroupIds) - m_SkillGroupsById.Remove(skillGroupId); - - var skillIds = m_SkillsById.Values.Where(skill => skill.CharacterId == characterId).Select(skill => skill.Id).ToHashSet(); - foreach (var skillId in skillIds) - m_SkillsById.Remove(skillId); - - m_RollLog.RemoveAll(entry => entry.CharacterId == characterId || skillIds.Contains(entry.SkillId)); - - foreach (var user in m_UsersById.Values.Where(account => account.ActiveCharacterId == characterId)) - user.ActiveCharacterId = null; - - RemoveCharacterStateLocked(campaignId, characterId); - TouchRosterLocked(campaignId); - } - - private static bool UserHasRoleLocked(UserAccount user, string role) - { - return RoleSerializer.HasRole(user.Roles, role); - } - - private UserAccount? ResolveUserLocked(string sessionToken) - { - if (string.IsNullOrWhiteSpace(sessionToken)) - return null; - - if (!m_SessionsByToken.TryGetValue(sessionToken, out var session)) - return null; - - return m_UsersById.GetValueOrDefault(session.UserId); - } - private GameCampaignStateTracker GetOrCreateCampaignStateLocked(Guid campaignId) { - if (!m_CampaignStateById.TryGetValue(campaignId, out var state)) + if (!m_StateStore.CampaignStateById.TryGetValue(campaignId, out var state)) { state = new GameCampaignStateTracker(); - m_CampaignStateById[campaignId] = state; + m_StateStore.CampaignStateById[campaignId] = state; } return state; @@ -393,49 +200,21 @@ public sealed class GameService : IGameService private void AddCharacterStateLocked(Guid? campaignId, Guid characterId) { - if (!campaignId.HasValue || !m_CampaignsById.ContainsKey(campaignId.Value)) + if (!campaignId.HasValue || !m_StateStore.CampaignsById.ContainsKey(campaignId.Value)) return; var state = GetOrCreateCampaignStateLocked(campaignId.Value); state.CharacterVersions[characterId] = 1; } - private void RemoveCharacterStateLocked(Guid? campaignId, Guid characterId) - { - if (!campaignId.HasValue || !m_CampaignStateById.TryGetValue(campaignId.Value, out var state)) - return; - - state.CharacterVersions.Remove(characterId); - } - - private void TouchRosterLocked(Guid? campaignId) - { - if (!campaignId.HasValue || !m_CampaignsById.ContainsKey(campaignId.Value)) - return; - - var state = GetOrCreateCampaignStateLocked(campaignId.Value); - state.TotalVersion += 1; - state.RosterVersion += 1; - } - - private void TouchLogLocked(Guid? campaignId) - { - if (!campaignId.HasValue || !m_CampaignsById.ContainsKey(campaignId.Value)) - return; - - var state = GetOrCreateCampaignStateLocked(campaignId.Value); - state.TotalVersion += 1; - state.LogVersion += 1; - } - private void RebuildCampaignStateLocked() { - m_CampaignStateById.Clear(); + m_StateStore.CampaignStateById.Clear(); - foreach (var campaignId in m_CampaignsById.Keys) - m_CampaignStateById[campaignId] = new GameCampaignStateTracker(); + foreach (var campaignId in m_StateStore.CampaignsById.Keys) + m_StateStore.CampaignStateById[campaignId] = new GameCampaignStateTracker(); - foreach (var character in m_CharactersById.Values.Where(character => character.CampaignId.HasValue)) + foreach (var character in m_StateStore.CharactersById.Values.Where(character => character.CampaignId.HasValue)) AddCharacterStateLocked(character.CampaignId, character.Id); } @@ -446,26 +225,13 @@ public sealed class GameService : IGameService RebuildCampaignStateLocked(); } - private void PersistStateLocked() - { - m_PersistenceService.PersistStateLocked(); - } - - private readonly Dictionary m_CampaignsById; - private readonly Dictionary m_CampaignStateById; private readonly GameCampaignService m_CampaignService; private readonly GameCharacterService m_CharacterService; - private readonly Dictionary m_CharactersById; private readonly GameAuthService m_AuthService; private readonly object m_Gate; private readonly GamePersistenceService m_PersistenceService; - private readonly List m_RollLog; private readonly GameRollService m_RollService; - private readonly Dictionary m_SessionsByToken; private readonly GameSkillService m_SkillService; - private readonly Dictionary m_SkillGroupsById; - private readonly Dictionary m_SkillsById; private readonly GameStateStore m_StateStore; - private readonly Dictionary m_UserIdsByUsername; - private readonly Dictionary m_UsersById; + private readonly GameUserAdministrationService m_UserAdministrationService; } diff --git a/RpgRoller/Services/GameUserAdministrationService.cs b/RpgRoller/Services/GameUserAdministrationService.cs new file mode 100644 index 0000000..fe5564e --- /dev/null +++ b/RpgRoller/Services/GameUserAdministrationService.cs @@ -0,0 +1,231 @@ +using RpgRoller.Contracts; +using RpgRoller.Domain; + +namespace RpgRoller.Services; + +public sealed class GameUserAdministrationService +{ + public GameUserAdministrationService(GameStateStore stateStore, GamePersistenceService persistenceService) + { + m_StateStore = stateStore; + m_PersistenceService = persistenceService; + } + + public ServiceResult> GetUsernames(string sessionToken) + { + lock (m_StateStore.Gate) + { + var user = ResolveUserLocked(sessionToken); + if (user is null) + return ServiceResult>.Failure("unauthorized", "You must be logged in."); + + var usernames = m_StateStore.UsersById.Values + .Select(account => account.Username) + .OrderBy(username => username, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + return ServiceResult>.Success(usernames); + } + } + + public ServiceResult> GetUsers(string sessionToken) + { + lock (m_StateStore.Gate) + { + var user = ResolveUserLocked(sessionToken); + if (user is null) + return ServiceResult>.Failure("unauthorized", "You must be logged in."); + + if (!UserHasRoleLocked(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) + .ToArray(); + + return ServiceResult>.Success(users); + } + } + + public ServiceResult UpdateUserRoles(string sessionToken, Guid userId, IReadOnlyList roles) + { + lock (m_StateStore.Gate) + { + var user = ResolveUserLocked(sessionToken); + if (user is null) + return ServiceResult.Failure("unauthorized", "You must be logged in."); + + if (!UserHasRoleLocked(user, UserRoles.Admin)) + return ServiceResult.Failure("forbidden", "Admin role is required."); + + if (!m_StateStore.UsersById.TryGetValue(userId, out var targetUser)) + return ServiceResult.Failure("user_not_found", "User was not found."); + + var normalizedRoles = RoleSerializer.Normalize(roles); + if (normalizedRoles.Any(role => !string.Equals(role, UserRoles.Admin, StringComparison.Ordinal))) + return ServiceResult.Failure("invalid_role", "Unsupported role."); + + if (user.Id == targetUser.Id && !normalizedRoles.Contains(UserRoles.Admin, StringComparer.Ordinal)) + return ServiceResult.Failure("forbidden", "You cannot remove your own admin role."); + + targetUser.Roles = RoleSerializer.Serialize(normalizedRoles); + m_PersistenceService.PersistStateLocked(); + return ServiceResult.Success(ToAdminUserSummary(targetUser)); + } + } + + public ServiceResult DeleteUser(string sessionToken, Guid userId) + { + lock (m_StateStore.Gate) + { + var user = ResolveUserLocked(sessionToken); + if (user is null) + return ServiceResult.Failure("unauthorized", "You must be logged in."); + + if (!UserHasRoleLocked(user, UserRoles.Admin)) + return ServiceResult.Failure("forbidden", "Admin role is required."); + + if (user.Id == userId) + return ServiceResult.Failure("forbidden", "You cannot delete your own account."); + + if (!m_StateStore.UsersById.TryGetValue(userId, out var targetUser)) + return ServiceResult.Failure("user_not_found", "User was not found."); + + var gmCampaignIds = m_StateStore.CampaignsById.Values + .Where(campaign => campaign.GmUserId == targetUser.Id) + .Select(campaign => campaign.Id) + .ToArray(); + var gmCampaignIdSet = gmCampaignIds.ToHashSet(); + var preservedCharacterIds = m_StateStore.CharactersById.Values + .Where(character => character.CampaignId.HasValue && gmCampaignIdSet.Contains(character.CampaignId.Value)) + .Select(character => character.Id) + .ToHashSet(); + + foreach (var campaignId in gmCampaignIds) + DeleteCampaignLocked(campaignId); + + var ownedCharacterIds = m_StateStore.CharactersById.Values + .Where(character => character.OwnerUserId == targetUser.Id && !preservedCharacterIds.Contains(character.Id)) + .Select(character => character.Id) + .ToArray(); + foreach (var characterId in ownedCharacterIds) + DeleteCharacterLocked(characterId); + + m_StateStore.RollLog.RemoveAll(entry => entry.RollerUserId == targetUser.Id); + + var staleSessions = m_StateStore.SessionsByToken.Values + .Where(session => session.UserId == targetUser.Id) + .Select(session => session.Token) + .ToArray(); + foreach (var token in staleSessions) + m_StateStore.SessionsByToken.Remove(token); + + m_StateStore.UsersById.Remove(targetUser.Id); + m_StateStore.UserIdsByUsername.Remove(targetUser.UsernameNormalized); + + m_PersistenceService.PersistStateLocked(); + return ServiceResult.Success(true); + } + } + + private UserAccount? ResolveUserLocked(string sessionToken) + { + if (string.IsNullOrWhiteSpace(sessionToken)) + return null; + + if (!m_StateStore.SessionsByToken.TryGetValue(sessionToken, out var session)) + return null; + + return m_StateStore.UsersById.GetValueOrDefault(session.UserId); + } + + private static bool UserHasRoleLocked(UserAccount user, string role) + { + return RoleSerializer.HasRole(user.Roles, role); + } + + private static AdminUserSummary ToAdminUserSummary(UserAccount user) + { + return new(user.Id, user.Username, user.DisplayName, RoleSerializer.Parse(user.Roles)); + } + + private void DeleteCampaignLocked(Guid campaignId) + { + if (!m_StateStore.CampaignsById.Remove(campaignId)) + return; + + var affectedCharacterIds = m_StateStore.CharactersById.Values + .Where(character => character.CampaignId == campaignId) + .Select(character => character.Id) + .ToArray(); + foreach (var characterId in affectedCharacterIds) + m_StateStore.CharactersById[characterId].CampaignId = null; + + m_StateStore.RollLog.RemoveAll(entry => entry.CampaignId == campaignId); + m_StateStore.CampaignStateById.Remove(campaignId); + } + + private void DeleteCharacterLocked(Guid characterId) + { + if (!m_StateStore.CharactersById.TryGetValue(characterId, out var character)) + return; + + var campaignId = character.CampaignId; + m_StateStore.CharactersById.Remove(characterId); + + var skillGroupIds = m_StateStore.SkillGroupsById.Values + .Where(group => group.CharacterId == characterId) + .Select(group => group.Id) + .ToHashSet(); + foreach (var skillGroupId in skillGroupIds) + m_StateStore.SkillGroupsById.Remove(skillGroupId); + + var skillIds = m_StateStore.SkillsById.Values + .Where(skill => skill.CharacterId == characterId) + .Select(skill => skill.Id) + .ToHashSet(); + foreach (var skillId in skillIds) + m_StateStore.SkillsById.Remove(skillId); + + m_StateStore.RollLog.RemoveAll(entry => entry.CharacterId == characterId || skillIds.Contains(entry.SkillId)); + + foreach (var account in m_StateStore.UsersById.Values.Where(account => account.ActiveCharacterId == characterId)) + account.ActiveCharacterId = null; + + RemoveCharacterStateLocked(campaignId, characterId); + TouchRosterLocked(campaignId); + } + + private void RemoveCharacterStateLocked(Guid? campaignId, Guid characterId) + { + if (!campaignId.HasValue || !m_StateStore.CampaignStateById.TryGetValue(campaignId.Value, out var state)) + return; + + state.CharacterVersions.Remove(characterId); + } + + private void TouchRosterLocked(Guid? campaignId) + { + if (!campaignId.HasValue || !m_StateStore.CampaignsById.ContainsKey(campaignId.Value)) + return; + + var state = GetOrCreateCampaignStateLocked(campaignId.Value); + state.TotalVersion += 1; + state.RosterVersion += 1; + } + + private GameCampaignStateTracker GetOrCreateCampaignStateLocked(Guid campaignId) + { + if (!m_StateStore.CampaignStateById.TryGetValue(campaignId, out var state)) + { + state = new GameCampaignStateTracker(); + m_StateStore.CampaignStateById[campaignId] = state; + } + + return state; + } + + private readonly GamePersistenceService m_PersistenceService; + private readonly GameStateStore m_StateStore; +}