diff --git a/README.md b/README.md index 760646a..0d2187b 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ Backend: - `RpgRoller/Api/`: endpoint mapping modules and auth/session filter helpers - `RpgRoller/Services/`: game workflows with explicit method parameters (no API DTO dependencies) - `RpgRoller/Services/SkillDefinitionValidator.cs`, `RoleSerializer.cs`, `RollVisibilityParser.cs`, and `CustomRollOptionsResolver.cs`: extracted pure backend rule helpers used by `GameService` +- `RpgRoller/Services/GameStateStore.cs`, `GameStateCloneFactory.cs`, and `GamePersistenceService.cs`: extracted runtime-state ownership and SQLite load/save boundaries used by `GameService` Frontend: diff --git a/RpgRoller.Tests/Services/ServiceStateInfrastructureTests.cs b/RpgRoller.Tests/Services/ServiceStateInfrastructureTests.cs new file mode 100644 index 0000000..d842fb0 --- /dev/null +++ b/RpgRoller.Tests/Services/ServiceStateInfrastructureTests.cs @@ -0,0 +1,62 @@ +namespace RpgRoller.Tests; + +public sealed class ServiceStateInfrastructureTests +{ + [Fact] + public void GameStateStore_StartsWithEmptyMutableCollections() + { + var store = new GameStateStore(); + + Assert.NotNull(store.Gate); + Assert.Empty(store.UsersById); + Assert.Empty(store.UserIdsByUsername); + Assert.Empty(store.SessionsByToken); + Assert.Empty(store.CampaignsById); + Assert.Empty(store.CampaignStateById); + Assert.Empty(store.CharactersById); + Assert.Empty(store.SkillGroupsById); + Assert.Empty(store.SkillsById); + Assert.Empty(store.RollLog); + } + + [Fact] + public void GameStateCloneFactory_ProducesDetachedCopies() + { + var user = new UserAccount + { + Id = Guid.NewGuid(), + Username = "user", + UsernameNormalized = "USER", + PasswordHash = "hash", + DisplayName = "User", + Roles = "admin", + ActiveCharacterId = Guid.NewGuid() + }; + var session = new UserSession { Token = "token", UserId = user.Id, CreatedAtUtc = DateTimeOffset.UtcNow }; + var campaign = new Campaign { Id = Guid.NewGuid(), GmUserId = user.Id, Name = "Main", Ruleset = RulesetKind.D6, Version = 3 }; + var character = new Character { Id = Guid.NewGuid(), OwnerUserId = user.Id, CampaignId = campaign.Id, Name = "Hero" }; + var skillGroup = new SkillGroup { Id = Guid.NewGuid(), CharacterId = character.Id, Name = "Group", DiceRollDefinition = "2D+1", WildDice = 1, AllowFumble = true, FumbleRange = null }; + var skill = new Skill { Id = Guid.NewGuid(), CharacterId = character.Id, SkillGroupId = skillGroup.Id, Name = "Skill", DiceRollDefinition = "2D+2", WildDice = 1, AllowFumble = true, FumbleRange = null }; + var logEntry = new RollLogEntry + { + Id = Guid.NewGuid(), + CampaignId = campaign.Id, + CharacterId = character.Id, + SkillId = skill.Id, + RollerUserId = user.Id, + Visibility = RollVisibility.Public, + Result = 12, + Breakdown = "6 + 6 = 12", + Dice = "[]", + TimestampUtc = DateTimeOffset.UtcNow + }; + + Assert.NotSame(user, GameStateCloneFactory.CloneUser(user)); + Assert.NotSame(session, GameStateCloneFactory.CloneSession(session)); + Assert.NotSame(campaign, GameStateCloneFactory.CloneCampaign(campaign)); + Assert.NotSame(character, GameStateCloneFactory.CloneCharacter(character)); + Assert.NotSame(skillGroup, GameStateCloneFactory.CloneSkillGroup(skillGroup)); + Assert.NotSame(skill, GameStateCloneFactory.CloneSkill(skill)); + Assert.NotSame(logEntry, GameStateCloneFactory.CloneRollLogEntry(logEntry)); + } +} diff --git a/RpgRoller/Services/GamePersistenceService.cs b/RpgRoller/Services/GamePersistenceService.cs new file mode 100644 index 0000000..603e2cf --- /dev/null +++ b/RpgRoller/Services/GamePersistenceService.cs @@ -0,0 +1,109 @@ +using Microsoft.EntityFrameworkCore; +using RpgRoller.Data; +using RpgRoller.Domain; + +namespace RpgRoller.Services; + +public sealed class GamePersistenceService +{ + public GamePersistenceService(IDbContextFactory dbContextFactory, GameStateStore stateStore) + { + m_DbContextFactory = dbContextFactory; + m_StateStore = stateStore; + } + + public void LoadStateFromDatabase() + { + using var db = m_DbContextFactory.CreateDbContext(); + var users = db.Users.AsNoTracking().ToList(); + var sessions = db.Sessions.AsNoTracking().ToList(); + var campaigns = db.Campaigns.AsNoTracking().ToList(); + var characters = db.Characters.AsNoTracking().ToList(); + var skillGroups = db.SkillGroups.AsNoTracking().ToList(); + var skills = db.Skills.AsNoTracking().ToList(); + var logEntries = db.RollLogEntries.AsNoTracking().ToList().OrderBy(x => x.TimestampUtc).ThenBy(x => x.Id).ToList(); + + lock (m_StateStore.Gate) + { + m_StateStore.UsersById.Clear(); + m_StateStore.UserIdsByUsername.Clear(); + m_StateStore.SessionsByToken.Clear(); + m_StateStore.CampaignsById.Clear(); + m_StateStore.CharactersById.Clear(); + m_StateStore.SkillGroupsById.Clear(); + m_StateStore.SkillsById.Clear(); + m_StateStore.RollLog.Clear(); + + foreach (var user in users) + { + var normalizedUsername = string.IsNullOrWhiteSpace(user.UsernameNormalized) ? NormalizeUsername(user.Username) : user.UsernameNormalized; + + var storedUser = new UserAccount + { + Id = user.Id, + Username = user.Username, + UsernameNormalized = normalizedUsername, + PasswordHash = user.PasswordHash, + DisplayName = user.DisplayName, + Roles = string.IsNullOrWhiteSpace(user.Roles) ? string.Empty : RoleSerializer.Serialize(RoleSerializer.Parse(user.Roles)), + ActiveCharacterId = user.ActiveCharacterId + }; + m_StateStore.UsersById[storedUser.Id] = storedUser; + m_StateStore.UserIdsByUsername[normalizedUsername] = storedUser.Id; + } + + foreach (var session in sessions) + { + if (m_StateStore.UsersById.ContainsKey(session.UserId)) + m_StateStore.SessionsByToken[session.Token] = GameStateCloneFactory.CloneSession(session); + } + + foreach (var campaign in campaigns) + m_StateStore.CampaignsById[campaign.Id] = GameStateCloneFactory.CloneCampaign(campaign); + + foreach (var character in characters) + m_StateStore.CharactersById[character.Id] = GameStateCloneFactory.CloneCharacter(character); + + foreach (var skillGroup in skillGroups) + m_StateStore.SkillGroupsById[skillGroup.Id] = GameStateCloneFactory.CloneSkillGroup(skillGroup); + + foreach (var skill in skills) + m_StateStore.SkillsById[skill.Id] = GameStateCloneFactory.CloneSkill(skill); + + m_StateStore.RollLog.AddRange(logEntries.Select(GameStateCloneFactory.CloneRollLogEntry)); + } + } + + public void PersistStateLocked() + { + using var db = m_DbContextFactory.CreateDbContext(); + using var transaction = db.Database.BeginTransaction(); + + db.RollLogEntries.ExecuteDelete(); + db.Skills.ExecuteDelete(); + db.SkillGroups.ExecuteDelete(); + db.Characters.ExecuteDelete(); + db.Campaigns.ExecuteDelete(); + db.Sessions.ExecuteDelete(); + db.Users.ExecuteDelete(); + + db.Users.AddRange(m_StateStore.UsersById.Values.Select(GameStateCloneFactory.CloneUser)); + db.Sessions.AddRange(m_StateStore.SessionsByToken.Values.Select(GameStateCloneFactory.CloneSession)); + db.Campaigns.AddRange(m_StateStore.CampaignsById.Values.Select(GameStateCloneFactory.CloneCampaign)); + db.Characters.AddRange(m_StateStore.CharactersById.Values.Select(GameStateCloneFactory.CloneCharacter)); + db.SkillGroups.AddRange(m_StateStore.SkillGroupsById.Values.Select(GameStateCloneFactory.CloneSkillGroup)); + db.Skills.AddRange(m_StateStore.SkillsById.Values.Select(GameStateCloneFactory.CloneSkill)); + db.RollLogEntries.AddRange(m_StateStore.RollLog.Select(GameStateCloneFactory.CloneRollLogEntry)); + + db.SaveChanges(); + transaction.Commit(); + } + + private static string NormalizeUsername(string username) + { + return username.ToUpperInvariant(); + } + + private readonly IDbContextFactory m_DbContextFactory; + private readonly GameStateStore m_StateStore; +} diff --git a/RpgRoller/Services/GameService.cs b/RpgRoller/Services/GameService.cs index 5dd6ccc..1b5f84f 100644 --- a/RpgRoller/Services/GameService.cs +++ b/RpgRoller/Services/GameService.cs @@ -11,7 +11,18 @@ public sealed class GameService : IGameService { public GameService(IDbContextFactory dbContextFactory, IPasswordHasher passwordHasher, IDiceRoller diceRoller) { - m_DbContextFactory = dbContextFactory; + 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_PasswordHasher = passwordHasher; m_DiceRoller = diceRoller; LoadStateFromDatabase(); @@ -1652,11 +1663,11 @@ public sealed class GameService : IGameService return m_UsersById.GetValueOrDefault(session.UserId); } - private CampaignStateTracker GetOrCreateCampaignStateLocked(Guid campaignId) + private GameCampaignStateTracker GetOrCreateCampaignStateLocked(Guid campaignId) { if (!m_CampaignStateById.TryGetValue(campaignId, out var state)) { - state = new CampaignStateTracker(); + state = new GameCampaignStateTracker(); m_CampaignStateById[campaignId] = state; } @@ -1715,7 +1726,7 @@ public sealed class GameService : IGameService m_CampaignStateById.Clear(); foreach (var campaignId in m_CampaignsById.Keys) - m_CampaignStateById[campaignId] = new CampaignStateTracker(); + m_CampaignStateById[campaignId] = new GameCampaignStateTracker(); foreach (var character in m_CharactersById.Values.Where(character => character.CampaignId.HasValue)) AddCharacterStateLocked(character.CampaignId, character.Id); @@ -1723,91 +1734,14 @@ public sealed class GameService : IGameService private void LoadStateFromDatabase() { - using var db = m_DbContextFactory.CreateDbContext(); - var users = db.Users.AsNoTracking().ToList(); - var sessions = db.Sessions.AsNoTracking().ToList(); - var campaigns = db.Campaigns.AsNoTracking().ToList(); - var characters = db.Characters.AsNoTracking().ToList(); - var skillGroups = db.SkillGroups.AsNoTracking().ToList(); - var skills = db.Skills.AsNoTracking().ToList(); - var logEntries = db.RollLogEntries.AsNoTracking().ToList().OrderBy(x => x.TimestampUtc).ThenBy(x => x.Id).ToList(); - + m_PersistenceService.LoadStateFromDatabase(); lock (m_Gate) - { - m_UsersById.Clear(); - m_UserIdsByUsername.Clear(); - m_SessionsByToken.Clear(); - m_CampaignsById.Clear(); - m_CharactersById.Clear(); - m_SkillGroupsById.Clear(); - m_SkillsById.Clear(); - m_RollLog.Clear(); - - foreach (var user in users) - { - var normalizedUsername = string.IsNullOrWhiteSpace(user.UsernameNormalized) ? NormalizeUsername(user.Username) : user.UsernameNormalized; - - var storedUser = new UserAccount - { - Id = user.Id, - Username = user.Username, - UsernameNormalized = normalizedUsername, - PasswordHash = user.PasswordHash, - DisplayName = user.DisplayName, - Roles = string.IsNullOrWhiteSpace(user.Roles) ? string.Empty : RoleSerializer.Serialize(RoleSerializer.Parse(user.Roles)), - ActiveCharacterId = user.ActiveCharacterId - }; - m_UsersById[storedUser.Id] = storedUser; - - m_UserIdsByUsername[normalizedUsername] = storedUser.Id; - } - - foreach (var session in sessions) - { - if (m_UsersById.ContainsKey(session.UserId)) - m_SessionsByToken[session.Token] = CloneSession(session); - } - - foreach (var campaign in campaigns) - m_CampaignsById[campaign.Id] = CloneCampaign(campaign); - - foreach (var character in characters) - m_CharactersById[character.Id] = CloneCharacter(character); - - foreach (var skillGroup in skillGroups) - m_SkillGroupsById[skillGroup.Id] = CloneSkillGroup(skillGroup); - - foreach (var skill in skills) - m_SkillsById[skill.Id] = CloneSkill(skill); - - m_RollLog.AddRange(logEntries.Select(CloneRollLogEntry)); RebuildCampaignStateLocked(); - } } private void PersistStateLocked() { - using var db = m_DbContextFactory.CreateDbContext(); - using var transaction = db.Database.BeginTransaction(); - - db.RollLogEntries.ExecuteDelete(); - db.Skills.ExecuteDelete(); - db.SkillGroups.ExecuteDelete(); - db.Characters.ExecuteDelete(); - db.Campaigns.ExecuteDelete(); - db.Sessions.ExecuteDelete(); - db.Users.ExecuteDelete(); - - db.Users.AddRange(m_UsersById.Values.Select(CloneUser)); - db.Sessions.AddRange(m_SessionsByToken.Values.Select(CloneSession)); - db.Campaigns.AddRange(m_CampaignsById.Values.Select(CloneCampaign)); - db.Characters.AddRange(m_CharactersById.Values.Select(CloneCharacter)); - db.SkillGroups.AddRange(m_SkillGroupsById.Values.Select(CloneSkillGroup)); - db.Skills.AddRange(m_SkillsById.Values.Select(CloneSkill)); - db.RollLogEntries.AddRange(m_RollLog.Select(CloneRollLogEntry)); - - db.SaveChanges(); - transaction.Commit(); + m_PersistenceService.PersistStateLocked(); } private static string NormalizeUsername(string username) @@ -1823,124 +1757,24 @@ public sealed class GameService : IGameService return Math.Clamp(limit.Value, 1, CampaignLogLivePageSize); } - private static UserAccount CloneUser(UserAccount user) - { - return new() - { - Id = user.Id, - Username = user.Username, - UsernameNormalized = user.UsernameNormalized, - PasswordHash = user.PasswordHash, - DisplayName = user.DisplayName, - Roles = user.Roles, - ActiveCharacterId = user.ActiveCharacterId - }; - } - - private static UserSession CloneSession(UserSession session) - { - return new() - { - Token = session.Token, - UserId = session.UserId, - CreatedAtUtc = session.CreatedAtUtc - }; - } - - private static Campaign CloneCampaign(Campaign campaign) - { - return new() - { - Id = campaign.Id, - GmUserId = campaign.GmUserId, - Name = campaign.Name, - Ruleset = campaign.Ruleset, - Version = campaign.Version - }; - } - - private static Character CloneCharacter(Character character) - { - return new() - { - Id = character.Id, - OwnerUserId = character.OwnerUserId, - CampaignId = character.CampaignId, - Name = character.Name - }; - } - - private static Skill CloneSkill(Skill skill) - { - return new() - { - Id = skill.Id, - CharacterId = skill.CharacterId, - SkillGroupId = skill.SkillGroupId, - Name = skill.Name, - DiceRollDefinition = skill.DiceRollDefinition, - WildDice = skill.WildDice, - AllowFumble = skill.AllowFumble, - FumbleRange = skill.FumbleRange - }; - } - - private static SkillGroup CloneSkillGroup(SkillGroup skillGroup) - { - return new() - { - Id = skillGroup.Id, - CharacterId = skillGroup.CharacterId, - Name = skillGroup.Name, - DiceRollDefinition = skillGroup.DiceRollDefinition, - WildDice = skillGroup.WildDice, - AllowFumble = skillGroup.AllowFumble, - FumbleRange = skillGroup.FumbleRange - }; - } - - private static RollLogEntry CloneRollLogEntry(RollLogEntry entry) - { - return new() - { - Id = entry.Id, - CampaignId = entry.CampaignId, - CharacterId = entry.CharacterId, - SkillId = entry.SkillId, - RollerUserId = entry.RollerUserId, - Visibility = entry.Visibility, - Result = entry.Result, - Breakdown = entry.Breakdown, - Dice = entry.Dice, - TimestampUtc = entry.TimestampUtc - }; - } - private const int CampaignLogHistoryWindowSize = 100; private const int CampaignLogLivePageSize = 25; private const string CustomRollBreakdownSeparator = " => "; private static readonly Guid CustomRollSkillId = Guid.Empty; private const string CustomRollLabel = "Custom roll"; private static readonly JsonSerializerOptions DiceJsonOptions = RpgRollerJson.CreateSerializerOptions(); - private readonly Dictionary m_CampaignsById = []; - private readonly Dictionary m_CampaignStateById = []; - private readonly Dictionary m_CharactersById = []; - private readonly IDbContextFactory m_DbContextFactory; + private readonly Dictionary m_CampaignsById; + private readonly Dictionary m_CampaignStateById; + private readonly Dictionary m_CharactersById; private readonly IDiceRoller m_DiceRoller; - private readonly object m_Gate = new(); + private readonly object m_Gate; private readonly IPasswordHasher m_PasswordHasher; - private readonly List m_RollLog = []; - private readonly Dictionary m_SessionsByToken = new(StringComparer.Ordinal); - private readonly Dictionary m_SkillGroupsById = []; - private readonly Dictionary m_SkillsById = []; - private readonly Dictionary m_UserIdsByUsername = new(StringComparer.Ordinal); - private readonly Dictionary m_UsersById = []; - - private sealed class CampaignStateTracker - { - public long TotalVersion { get; set; } = 1; - public long RosterVersion { get; set; } = 1; - public long LogVersion { get; set; } = 1; - public Dictionary CharacterVersions { get; } = []; - } + private readonly GamePersistenceService m_PersistenceService; + private readonly List m_RollLog; + private readonly Dictionary m_SessionsByToken; + 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; } diff --git a/RpgRoller/Services/GameStateCloneFactory.cs b/RpgRoller/Services/GameStateCloneFactory.cs new file mode 100644 index 0000000..803214a --- /dev/null +++ b/RpgRoller/Services/GameStateCloneFactory.cs @@ -0,0 +1,99 @@ +using RpgRoller.Domain; + +namespace RpgRoller.Services; + +public static class GameStateCloneFactory +{ + public static UserAccount CloneUser(UserAccount user) + { + return new() + { + Id = user.Id, + Username = user.Username, + UsernameNormalized = user.UsernameNormalized, + PasswordHash = user.PasswordHash, + DisplayName = user.DisplayName, + Roles = user.Roles, + ActiveCharacterId = user.ActiveCharacterId + }; + } + + public static UserSession CloneSession(UserSession session) + { + return new() + { + Token = session.Token, + UserId = session.UserId, + CreatedAtUtc = session.CreatedAtUtc + }; + } + + public static Campaign CloneCampaign(Campaign campaign) + { + return new() + { + Id = campaign.Id, + GmUserId = campaign.GmUserId, + Name = campaign.Name, + Ruleset = campaign.Ruleset, + Version = campaign.Version + }; + } + + public static Character CloneCharacter(Character character) + { + return new() + { + Id = character.Id, + OwnerUserId = character.OwnerUserId, + CampaignId = character.CampaignId, + Name = character.Name + }; + } + + public static Skill CloneSkill(Skill skill) + { + return new() + { + Id = skill.Id, + CharacterId = skill.CharacterId, + SkillGroupId = skill.SkillGroupId, + Name = skill.Name, + DiceRollDefinition = skill.DiceRollDefinition, + WildDice = skill.WildDice, + AllowFumble = skill.AllowFumble, + FumbleRange = skill.FumbleRange + }; + } + + public static SkillGroup CloneSkillGroup(SkillGroup skillGroup) + { + return new() + { + Id = skillGroup.Id, + CharacterId = skillGroup.CharacterId, + Name = skillGroup.Name, + DiceRollDefinition = skillGroup.DiceRollDefinition, + WildDice = skillGroup.WildDice, + AllowFumble = skillGroup.AllowFumble, + FumbleRange = skillGroup.FumbleRange + }; + } + + public static RollLogEntry CloneRollLogEntry(RollLogEntry entry) + { + return new() + { + Id = entry.Id, + CampaignId = entry.CampaignId, + CharacterId = entry.CharacterId, + SkillId = entry.SkillId, + RollerUserId = entry.RollerUserId, + Visibility = entry.Visibility, + Result = entry.Result, + Breakdown = entry.Breakdown, + Dice = entry.Dice, + TimestampUtc = entry.TimestampUtc + }; + } +} diff --git a/RpgRoller/Services/GameStateStore.cs b/RpgRoller/Services/GameStateStore.cs new file mode 100644 index 0000000..77ad6b2 --- /dev/null +++ b/RpgRoller/Services/GameStateStore.cs @@ -0,0 +1,25 @@ +using RpgRoller.Domain; + +namespace RpgRoller.Services; + +public sealed class GameStateStore +{ + public object Gate { get; } = new(); + public Dictionary CampaignsById { get; } = []; + public Dictionary CampaignStateById { get; } = []; + public Dictionary CharactersById { get; } = []; + public List RollLog { get; } = []; + public Dictionary SessionsByToken { get; } = new(StringComparer.Ordinal); + public Dictionary SkillGroupsById { get; } = []; + public Dictionary SkillsById { get; } = []; + public Dictionary UserIdsByUsername { get; } = new(StringComparer.Ordinal); + public Dictionary UsersById { get; } = []; +} + +public sealed class GameCampaignStateTracker +{ + public long TotalVersion { get; set; } = 1; + public long RosterVersion { get; set; } = 1; + public long LogVersion { get; set; } = 1; + public Dictionary CharacterVersions { get; } = []; +}