From 5c199b44685bdb0f58518ab5bec952ff3866ecf0 Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Tue, 24 Feb 2026 23:13:20 +0100 Subject: [PATCH] Use in-memory runtime state with SQLite write-through --- RpgRoller.Tests/GameServiceTests.cs | 107 ++- RpgRoller.Tests/UnitTest1.cs | 3 +- RpgRoller/Program.cs | 8 +- RpgRoller/Services/GameService.cs | 1080 ++++++++++++++++----------- 4 files changed, 743 insertions(+), 455 deletions(-) diff --git a/RpgRoller.Tests/GameServiceTests.cs b/RpgRoller.Tests/GameServiceTests.cs index 4393749..4b108a4 100644 --- a/RpgRoller.Tests/GameServiceTests.cs +++ b/RpgRoller.Tests/GameServiceTests.cs @@ -238,26 +238,41 @@ public sealed class GameServiceTests Assert.False(service.CreateSkill(ownerSession, Guid.NewGuid(), new CreateSkillRequest("Stealth", "2D+1")).Succeeded); Assert.False(service.CreateSkill(otherSession, ownerCharacter.Id, new CreateSkillRequest("Stealth", "2D+1")).Succeeded); - var ownerUser = harness.Db.Users.Single(u => u.UsernameNormalized == "OWNER"); - ownerUser.ActiveCharacterId = Guid.NewGuid(); - harness.Db.SaveChanges(); + using (var db = harness.CreateDbContext()) + { + var ownerUser = db.Users.Single(u => u.UsernameNormalized == "OWNER"); + ownerUser.ActiveCharacterId = Guid.NewGuid(); + db.SaveChanges(); + } - var staleMe = GetValue(service.GetMe(ownerSession)); + using var staleMeHarness = CreateHarnessFromPath(harness.DbPath, 2, 3, 4); + var staleMeService = staleMeHarness.Service; + + var staleMe = GetValue(staleMeService.GetMe(ownerSession)); Assert.Null(staleMe.ActiveCharacterId); Assert.Null(staleMe.CurrentCampaignId); - Assert.True(service.ActivateCharacter(ownerSession, ownerCharacter.Id).Succeeded); - var activeMe = GetValue(service.GetMe(ownerSession)); + Assert.True(staleMeService.ActivateCharacter(ownerSession, ownerCharacter.Id).Succeeded); + var activeMe = GetValue(staleMeService.GetMe(ownerSession)); Assert.Equal(ownerCharacter.Id, activeMe.ActiveCharacterId); Assert.Equal(campaign.Id, activeMe.CurrentCampaignId); - var staleOwner = harness.Db.Users.Single(u => u.Id == ownerUser.Id); - staleOwner.ActiveCharacterId = Guid.NewGuid(); - harness.Db.SaveChanges(); + using (var db = harness.CreateDbContext()) + { + var staleOwner = db.Users.Single(u => u.UsernameNormalized == "OWNER"); + staleOwner.ActiveCharacterId = Guid.NewGuid(); + db.SaveChanges(); + } - var staleCurrentCampaign = service.GetCurrentCampaignCharacters(ownerSession); + using var staleCurrentHarness = CreateHarnessFromPath(harness.DbPath, 2, 3, 4); + var staleCurrentService = staleCurrentHarness.Service; + + var staleCurrentCampaign = staleCurrentService.GetCurrentCampaignCharacters(ownerSession); Assert.False(staleCurrentCampaign.Succeeded); - Assert.Null(harness.Db.Users.Single(u => u.Id == ownerUser.Id).ActiveCharacterId); + using (var db = harness.CreateDbContext()) + { + Assert.Null(db.Users.Single(u => u.UsernameNormalized == "OWNER").ActiveCharacterId); + } var skill = GetValue(service.CreateSkill(ownerSession, ownerCharacter.Id, new CreateSkillRequest("Stealth", "2D+1"))); Assert.False(service.UpdateSkill(ownerSession, skill.Id, new UpdateSkillRequest("", "2D+1")).Succeeded); @@ -265,11 +280,15 @@ public sealed class GameServiceTests Assert.False(service.UpdateSkill(ownerSession, skill.Id, new UpdateSkillRequest("Stealth", "bad")).Succeeded); Assert.False(service.RollSkill(string.Empty, skill.Id, new RollSkillRequest("public")).Succeeded); - var mutableSkill = harness.Db.Skills.Single(s => s.Id == skill.Id); - mutableSkill.DiceRollDefinition = "bad"; - harness.Db.SaveChanges(); + using (var db = harness.CreateDbContext()) + { + var mutableSkill = db.Skills.Single(s => s.Id == skill.Id); + mutableSkill.DiceRollDefinition = "bad"; + db.SaveChanges(); + } - Assert.False(service.RollSkill(ownerSession, skill.Id, new RollSkillRequest("public")).Succeeded); + using var invalidExpressionHarness = CreateHarnessFromPath(harness.DbPath, 2, 3, 4); + Assert.False(invalidExpressionHarness.Service.RollSkill(ownerSession, skill.Id, new RollSkillRequest("public")).Succeeded); Assert.False(service.GetCampaignLog(string.Empty, campaign.Id).Succeeded); } @@ -346,15 +365,28 @@ public sealed class GameServiceTests private static ServiceHarness CreateHarness(IPasswordHasher passwordHasher, params int[] rollValues) { var dbPath = Path.Combine(Path.GetTempPath(), $"rpgroller-servicetests-{Guid.NewGuid():N}.db"); + return CreateHarnessFromPath(dbPath, passwordHasher, rollValues); + } + + private static ServiceHarness CreateHarnessFromPath(string dbPath, params int[] rollValues) + { + return CreateHarnessFromPath(dbPath, new PasswordHasher(), rollValues); + } + + private static ServiceHarness CreateHarnessFromPath(string dbPath, IPasswordHasher passwordHasher, params int[] rollValues) + { var options = new DbContextOptionsBuilder() .UseSqlite($"Data Source={dbPath}") .Options; - var db = new RpgRollerDbContext(options); - db.Database.EnsureCreated(); + using (var db = new RpgRollerDbContext(options)) + { + db.Database.EnsureCreated(); + } - var service = new GameService(db, passwordHasher, new FixedDiceRoller(rollValues)); - return new ServiceHarness(service, db); + var factory = new SqliteDbContextFactory(dbPath); + var service = new GameService(factory, passwordHasher, new FixedDiceRoller(rollValues)); + return new ServiceHarness(service, factory, dbPath); } private static T GetValue(ServiceResult result) @@ -382,20 +414,47 @@ public sealed class GameServiceTests private sealed class ServiceHarness : IDisposable { - private readonly RpgRollerDbContext m_Db; + private readonly SqliteDbContextFactory m_Factory; - public ServiceHarness(GameService service, RpgRollerDbContext db) + public ServiceHarness(GameService service, SqliteDbContextFactory factory, string dbPath) { Service = service; - m_Db = db; + m_Factory = factory; + DbPath = dbPath; } public GameService Service { get; } - public RpgRollerDbContext Db => m_Db; + public string DbPath { get; } + + public void Dispose() + { + m_Factory.Dispose(); + } + + public RpgRollerDbContext CreateDbContext() + { + return m_Factory.CreateDbContext(); + } + } + + private sealed class SqliteDbContextFactory : IDbContextFactory, IDisposable + { + private readonly DbContextOptions m_Options; + + public SqliteDbContextFactory(string dbPath) + { + m_Options = new DbContextOptionsBuilder() + .UseSqlite($"Data Source={dbPath}") + .Options; + } + + public RpgRollerDbContext CreateDbContext() + { + return new RpgRollerDbContext(m_Options); + } public void Dispose() { - m_Db.Dispose(); } } diff --git a/RpgRoller.Tests/UnitTest1.cs b/RpgRoller.Tests/UnitTest1.cs index f3ab976..a958a00 100644 --- a/RpgRoller.Tests/UnitTest1.cs +++ b/RpgRoller.Tests/UnitTest1.cs @@ -205,10 +205,11 @@ public sealed class UnitTest1 : IClassFixture> services.AddSingleton(new FixedDiceRoller(rollValues)); services.RemoveAll>(); + services.RemoveAll>(); services.RemoveAll(); var dbPath = Path.Combine(Path.GetTempPath(), $"rpgroller-tests-{Guid.NewGuid():N}.db"); - services.AddDbContext(options => options.UseSqlite($"Data Source={dbPath}")); + services.AddDbContextFactory(options => options.UseSqlite($"Data Source={dbPath}")); })); } diff --git a/RpgRoller/Program.cs b/RpgRoller/Program.cs index 683e3fa..15b5bf3 100644 --- a/RpgRoller/Program.cs +++ b/RpgRoller/Program.cs @@ -14,16 +14,18 @@ var sqliteConnectionString = builder.Configuration.GetConnectionString("RpgRolle EnsureSqliteDataDirectory(sqliteConnectionString, builder.Environment.ContentRootPath); builder.Services.AddSingleton, PasswordHasher>(); -builder.Services.AddDbContext(options => options.UseSqlite(sqliteConnectionString)); +builder.Services.AddDbContextFactory(options => options.UseSqlite(sqliteConnectionString)); builder.Services.AddSingleton(); -builder.Services.AddScoped(); +builder.Services.AddSingleton(); var app = builder.Build(); using (var scope = app.Services.CreateScope()) { - var db = scope.ServiceProvider.GetRequiredService(); + var dbFactory = scope.ServiceProvider.GetRequiredService>(); + using var db = dbFactory.CreateDbContext(); db.Database.EnsureCreated(); + _ = scope.ServiceProvider.GetRequiredService(); } app.UseDefaultFiles(); diff --git a/RpgRoller/Services/GameService.cs b/RpgRoller/Services/GameService.cs index c3032e0..3811df6 100644 --- a/RpgRoller/Services/GameService.cs +++ b/RpgRoller/Services/GameService.cs @@ -8,15 +8,24 @@ namespace RpgRoller.Services; public sealed class GameService : IGameService { - private readonly RpgRollerDbContext m_Db; + private readonly object m_Gate = new(); + private readonly IDbContextFactory m_DbContextFactory; private readonly IPasswordHasher m_PasswordHasher; private readonly IDiceRoller m_DiceRoller; + private readonly Dictionary m_UsersById = []; + private readonly Dictionary m_UserIdsByUsername = new(StringComparer.Ordinal); + private readonly Dictionary m_SessionsByToken = new(StringComparer.Ordinal); + private readonly Dictionary m_CampaignsById = []; + private readonly Dictionary m_CharactersById = []; + private readonly Dictionary m_SkillsById = []; + private readonly List m_RollLog = []; - public GameService(RpgRollerDbContext db, IPasswordHasher passwordHasher, IDiceRoller diceRoller) + public GameService(IDbContextFactory dbContextFactory, IPasswordHasher passwordHasher, IDiceRoller diceRoller) { - m_Db = db; + m_DbContextFactory = dbContextFactory; m_PasswordHasher = passwordHasher; m_DiceRoller = diceRoller; + LoadStateFromDatabase(); } public IReadOnlyList GetRulesets() @@ -43,29 +52,33 @@ public sealed class GameService : IGameService return ServiceResult.Failure("invalid_password", "Password must be at least 8 characters."); } - var username = request.Username.Trim(); - var normalizedUsername = NormalizeUsername(username); - if (m_Db.Users.Any(u => u.UsernameNormalized == normalizedUsername)) + lock (m_Gate) { - return ServiceResult.Failure("duplicate_username", "Username is already taken."); + var username = request.Username.Trim(); + var normalizedUsername = NormalizeUsername(username); + if (m_UserIdsByUsername.ContainsKey(normalizedUsername)) + { + return ServiceResult.Failure("duplicate_username", "Username is already taken."); + } + + var user = new UserAccount + { + Id = Guid.NewGuid(), + Username = username, + UsernameNormalized = normalizedUsername, + DisplayName = request.DisplayName.Trim(), + PasswordHash = string.Empty, + ActiveCharacterId = null + }; + + user.PasswordHash = m_PasswordHasher.HashPassword(user, request.Password); + + m_UsersById[user.Id] = user; + m_UserIdsByUsername[user.UsernameNormalized] = user.Id; + + PersistStateLocked(); + return ServiceResult.Success(ToUserSummary(user)); } - - var user = new UserAccount - { - Id = Guid.NewGuid(), - Username = username, - UsernameNormalized = normalizedUsername, - DisplayName = request.DisplayName.Trim(), - PasswordHash = string.Empty, - ActiveCharacterId = null - }; - - user.PasswordHash = m_PasswordHasher.HashPassword(user, request.Password); - - m_Db.Users.Add(user); - m_Db.SaveChanges(); - - return ServiceResult.Success(ToUserSummary(user)); } public ServiceResult<(UserSummary User, string SessionToken)> Login(LoginRequest request) @@ -75,79 +88,78 @@ public sealed class GameService : IGameService return ServiceResult<(UserSummary User, string SessionToken)>.Failure("invalid_credentials", "Invalid username or password."); } - var normalizedUsername = NormalizeUsername(request.Username.Trim()); - var user = m_Db.Users.SingleOrDefault(u => u.UsernameNormalized == normalizedUsername); - if (user is null) + lock (m_Gate) { - return ServiceResult<(UserSummary User, string SessionToken)>.Failure("invalid_credentials", "Invalid username or password."); + var normalizedUsername = NormalizeUsername(request.Username.Trim()); + if (!m_UserIdsByUsername.TryGetValue(normalizedUsername, out var userId)) + { + return ServiceResult<(UserSummary User, string SessionToken)>.Failure("invalid_credentials", "Invalid username or password."); + } + + var user = m_UsersById[userId]; + var verification = m_PasswordHasher.VerifyHashedPassword(user, user.PasswordHash, request.Password); + if (verification == PasswordVerificationResult.Failed) + { + return ServiceResult<(UserSummary User, string SessionToken)>.Failure("invalid_credentials", "Invalid username or password."); + } + + if (verification == PasswordVerificationResult.SuccessRehashNeeded) + { + user.PasswordHash = m_PasswordHasher.HashPassword(user, request.Password); + } + + var session = CreateSession(userId); + PersistStateLocked(); + return ServiceResult<(UserSummary User, string SessionToken)>.Success((ToUserSummary(user), session.Token)); } - - var verification = m_PasswordHasher.VerifyHashedPassword(user, user.PasswordHash, request.Password); - if (verification == PasswordVerificationResult.Failed) - { - return ServiceResult<(UserSummary User, string SessionToken)>.Failure("invalid_credentials", "Invalid username or password."); - } - - if (verification == PasswordVerificationResult.SuccessRehashNeeded) - { - user.PasswordHash = m_PasswordHasher.HashPassword(user, request.Password); - } - - var session = new UserSession - { - Token = Guid.NewGuid().ToString("N"), - UserId = user.Id, - CreatedAtUtc = DateTimeOffset.UtcNow - }; - - m_Db.Sessions.Add(session); - m_Db.SaveChanges(); - - return ServiceResult<(UserSummary User, string SessionToken)>.Success((ToUserSummary(user), session.Token)); } public void Logout(string sessionToken) { - var session = m_Db.Sessions.SingleOrDefault(s => s.Token == sessionToken); - if (session is null) + lock (m_Gate) { - return; + if (m_SessionsByToken.Remove(sessionToken)) + { + PersistStateLocked(); + } } - - m_Db.Sessions.Remove(session); - m_Db.SaveChanges(); } public UserSummary? GetUserBySession(string sessionToken) { - var user = ResolveUser(sessionToken); - return user is null ? null : ToUserSummary(user); + lock (m_Gate) + { + var user = ResolveUserLocked(sessionToken); + return user is null ? null : ToUserSummary(user); + } } public ServiceResult GetMe(string sessionToken) { - var user = ResolveUser(sessionToken); - if (user is null) + lock (m_Gate) { - return ServiceResult.Failure("unauthorized", "You must be logged in."); - } - - Guid? campaignId = null; - if (user.ActiveCharacterId is Guid activeCharacterId) - { - var activeCharacter = m_Db.Characters.AsNoTracking().SingleOrDefault(c => c.Id == activeCharacterId); - if (activeCharacter is null) + var user = ResolveUserLocked(sessionToken); + if (user is null) { - user.ActiveCharacterId = null; - m_Db.SaveChanges(); + return ServiceResult.Failure("unauthorized", "You must be logged in."); } - else - { - campaignId = activeCharacter.CampaignId; - } - } - return ServiceResult.Success(new MeResponse(ToUserSummary(user), user.ActiveCharacterId, campaignId)); + Guid? campaignId = null; + if (user.ActiveCharacterId is Guid activeCharacterId) + { + if (!m_CharactersById.TryGetValue(activeCharacterId, out var activeCharacter)) + { + user.ActiveCharacterId = null; + PersistStateLocked(); + } + else + { + campaignId = activeCharacter.CampaignId; + } + } + + return ServiceResult.Success(new MeResponse(ToUserSummary(user), user.ActiveCharacterId, campaignId)); + } } public ServiceResult CreateCampaign(string sessionToken, CreateCampaignRequest request) @@ -163,85 +175,92 @@ public sealed class GameService : IGameService return ServiceResult.Failure("invalid_ruleset", "Unknown ruleset."); } - var user = ResolveUser(sessionToken); - if (user is null) + lock (m_Gate) { - return ServiceResult.Failure("unauthorized", "You must be logged in."); + var user = ResolveUserLocked(sessionToken); + if (user is null) + { + return ServiceResult.Failure("unauthorized", "You must be logged in."); + } + + var campaign = new Campaign + { + Id = Guid.NewGuid(), + GmUserId = user.Id, + Name = request.Name.Trim(), + Ruleset = ruleset.Value, + Version = 1 + }; + + m_CampaignsById[campaign.Id] = campaign; + PersistStateLocked(); + return ServiceResult.Success(ToCampaignSummary(campaign)); } - - var campaign = new Campaign - { - Id = Guid.NewGuid(), - GmUserId = user.Id, - Name = request.Name.Trim(), - Ruleset = ruleset.Value, - Version = 1 - }; - - m_Db.Campaigns.Add(campaign); - m_Db.SaveChanges(); - - return ServiceResult.Success(ToCampaignSummary(campaign)); } public ServiceResult> GetCampaigns(string sessionToken) { - var user = ResolveUser(sessionToken); - if (user is null) + lock (m_Gate) { - return ServiceResult>.Failure("unauthorized", "You must be logged in."); + var user = ResolveUserLocked(sessionToken); + if (user is null) + { + return ServiceResult>.Failure("unauthorized", "You must be logged in."); + } + + var campaignIds = new HashSet(m_CampaignsById.Values.Where(c => c.GmUserId == user.Id).Select(c => c.Id)); + foreach (var character in m_CharactersById.Values.Where(c => c.OwnerUserId == user.Id)) + { + campaignIds.Add(character.CampaignId); + } + + var results = campaignIds + .Select(id => m_CampaignsById[id]) + .OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase) + .Select(ToCampaignSummary) + .ToArray(); + + return ServiceResult>.Success(results); } - - var campaignIds = m_Db.Campaigns - .Where(c => c.GmUserId == user.Id) - .Select(c => c.Id) - .Union(m_Db.Characters.Where(c => c.OwnerUserId == user.Id).Select(c => c.CampaignId)) - .Distinct(); - - var campaigns = m_Db.Campaigns - .Where(c => campaignIds.Contains(c.Id)) - .OrderBy(c => c.Name) - .AsNoTracking() - .ToList(); - - return ServiceResult>.Success(campaigns.Select(ToCampaignSummary).ToArray()); } public ServiceResult GetCampaign(string sessionToken, Guid campaignId) { - var context = ResolveContext(sessionToken, campaignId); - if (!context.Succeeded) + lock (m_Gate) { - return ServiceResult.Failure(context.Error!.Code, context.Error.Message); + var context = ResolveContextLocked(sessionToken, campaignId); + if (!context.Succeeded) + { + return ServiceResult.Failure(context.Error!.Code, context.Error.Message); + } + + var (user, campaign) = context.Value!; + var gm = m_UsersById[campaign.GmUserId]; + var isGm = campaign.GmUserId == user.Id; + + var characters = m_CharactersById.Values + .Where(c => c.CampaignId == campaign.Id) + .Where(c => isGm || c.OwnerUserId == user.Id) + .OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase) + .Select(ToCharacterSummary) + .ToArray(); + + var visibleCharacterIds = characters.Select(c => c.Id).ToHashSet(); + + var skills = m_SkillsById.Values + .Where(s => visibleCharacterIds.Contains(s.CharacterId)) + .OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase) + .Select(ToSkillSummary) + .ToArray(); + + return ServiceResult.Success(new CampaignDetails( + campaign.Id, + campaign.Name, + DiceRules.ToRulesetId(campaign.Ruleset), + ToUserSummary(gm), + characters, + skills)); } - - var (user, campaign) = context.Value!; - var gm = m_Db.Users.AsNoTracking().Single(u => u.Id == campaign.GmUserId); - var isGm = campaign.GmUserId == user.Id; - - var charactersQuery = m_Db.Characters.AsNoTracking().Where(c => c.CampaignId == campaign.Id); - if (!isGm) - { - charactersQuery = charactersQuery.Where(c => c.OwnerUserId == user.Id); - } - - var characters = charactersQuery - .OrderBy(c => c.Name) - .ToList(); - - var characterIds = characters.Select(c => c.Id).ToList(); - var skills = m_Db.Skills.AsNoTracking() - .Where(s => characterIds.Contains(s.CharacterId)) - .OrderBy(s => s.Name) - .ToList(); - - return ServiceResult.Success(new CampaignDetails( - campaign.Id, - campaign.Name, - DiceRules.ToRulesetId(campaign.Ruleset), - ToUserSummary(gm), - characters.Select(ToCharacterSummary).ToArray(), - skills.Select(ToSkillSummary).ToArray())); } public ServiceResult CreateCharacter(string sessionToken, CreateCharacterRequest request) @@ -251,31 +270,33 @@ public sealed class GameService : IGameService return ServiceResult.Failure("invalid_character_name", "Character name is required."); } - var user = ResolveUser(sessionToken); - if (user is null) + lock (m_Gate) { - return ServiceResult.Failure("unauthorized", "You must be logged in."); + var user = ResolveUserLocked(sessionToken); + if (user is null) + { + return ServiceResult.Failure("unauthorized", "You must be logged in."); + } + + if (!m_CampaignsById.ContainsKey(request.CampaignId)) + { + return ServiceResult.Failure("campaign_not_found", "Campaign was not found."); + } + + var character = new Character + { + Id = Guid.NewGuid(), + OwnerUserId = user.Id, + CampaignId = request.CampaignId, + Name = request.Name.Trim() + }; + + m_CharactersById[character.Id] = character; + TouchCampaignLocked(character.CampaignId); + + PersistStateLocked(); + return ServiceResult.Success(ToCharacterSummary(character)); } - - var campaign = m_Db.Campaigns.SingleOrDefault(c => c.Id == request.CampaignId); - if (campaign is null) - { - return ServiceResult.Failure("campaign_not_found", "Campaign was not found."); - } - - var character = new Character - { - Id = Guid.NewGuid(), - OwnerUserId = user.Id, - CampaignId = campaign.Id, - Name = request.Name.Trim() - }; - - m_Db.Characters.Add(character); - TouchCampaign(campaign); - m_Db.SaveChanges(); - - return ServiceResult.Success(ToCharacterSummary(character)); } public ServiceResult UpdateCharacter(string sessionToken, Guid characterId, UpdateCharacterRequest request) @@ -285,101 +306,97 @@ public sealed class GameService : IGameService return ServiceResult.Failure("invalid_character_name", "Character name is required."); } - var user = ResolveUser(sessionToken); - if (user is null) + lock (m_Gate) { - return ServiceResult.Failure("unauthorized", "You must be logged in."); + var user = ResolveUserLocked(sessionToken); + if (user is null) + { + return ServiceResult.Failure("unauthorized", "You must be logged in."); + } + + if (!m_CharactersById.TryGetValue(characterId, out var character)) + { + return ServiceResult.Failure("character_not_found", "Character was not found."); + } + + if (!m_CampaignsById.TryGetValue(request.CampaignId, out var targetCampaign)) + { + return ServiceResult.Failure("campaign_not_found", "Campaign was not found."); + } + + var sourceCampaign = m_CampaignsById[character.CampaignId]; + var isOwner = character.OwnerUserId == user.Id; + var isSourceGm = sourceCampaign.GmUserId == user.Id; + var isTargetGm = targetCampaign.GmUserId == user.Id; + if (!isOwner && !isSourceGm && !isTargetGm) + { + return ServiceResult.Failure("forbidden", "Only the owner or GM can edit this character."); + } + + var sourceCampaignId = character.CampaignId; + character.Name = request.Name.Trim(); + character.CampaignId = request.CampaignId; + + TouchCampaignLocked(sourceCampaignId); + if (sourceCampaignId != character.CampaignId) + { + TouchCampaignLocked(character.CampaignId); + } + + PersistStateLocked(); + return ServiceResult.Success(ToCharacterSummary(character)); } - - var character = m_Db.Characters.SingleOrDefault(c => c.Id == characterId); - if (character is null) - { - return ServiceResult.Failure("character_not_found", "Character was not found."); - } - - var sourceCampaign = m_Db.Campaigns.Single(c => c.Id == character.CampaignId); - var targetCampaign = m_Db.Campaigns.SingleOrDefault(c => c.Id == request.CampaignId); - if (targetCampaign is null) - { - return ServiceResult.Failure("campaign_not_found", "Campaign was not found."); - } - - var isOwner = character.OwnerUserId == user.Id; - var isSourceGm = sourceCampaign.GmUserId == user.Id; - var isTargetGm = targetCampaign.GmUserId == user.Id; - if (!isOwner && !isSourceGm && !isTargetGm) - { - return ServiceResult.Failure("forbidden", "Only the owner or GM can edit this character."); - } - - character.Name = request.Name.Trim(); - var sourceCampaignId = character.CampaignId; - character.CampaignId = targetCampaign.Id; - - TouchCampaign(sourceCampaign); - if (sourceCampaignId != targetCampaign.Id) - { - TouchCampaign(targetCampaign); - } - - m_Db.SaveChanges(); - - return ServiceResult.Success(ToCharacterSummary(character)); } public ServiceResult ActivateCharacter(string sessionToken, Guid characterId) { - var user = ResolveUser(sessionToken); - if (user is null) + lock (m_Gate) { - return ServiceResult.Failure("unauthorized", "You must be logged in."); + var user = ResolveUserLocked(sessionToken); + if (user is null) + { + return ServiceResult.Failure("unauthorized", "You must be logged in."); + } + + if (!m_CharactersById.TryGetValue(characterId, out var character)) + { + return ServiceResult.Failure("character_not_found", "Character was not found."); + } + + if (character.OwnerUserId != user.Id) + { + return ServiceResult.Failure("forbidden", "You can activate only your own character."); + } + + user.ActiveCharacterId = character.Id; + PersistStateLocked(); + return ServiceResult.Success(true); } - - var character = m_Db.Characters.AsNoTracking().SingleOrDefault(c => c.Id == characterId); - if (character is null) - { - return ServiceResult.Failure("character_not_found", "Character was not found."); - } - - if (character.OwnerUserId != user.Id) - { - return ServiceResult.Failure("forbidden", "You can activate only your own character."); - } - - user.ActiveCharacterId = character.Id; - m_Db.SaveChanges(); - - return ServiceResult.Success(true); } public ServiceResult> GetCurrentCampaignCharacters(string sessionToken) { - var user = ResolveUser(sessionToken); - if (user is null) + lock (m_Gate) { - return ServiceResult>.Failure("unauthorized", "You must be logged in."); + var user = ResolveUserLocked(sessionToken); + if (user is null) + { + return ServiceResult>.Failure("unauthorized", "You must be logged in."); + } + + if (!TryGetCurrentCampaignIdLocked(user, out var campaignId)) + { + return ServiceResult>.Failure("no_active_character", "No active character is selected."); + } + + var characters = m_CharactersById.Values + .Where(c => c.CampaignId == campaignId && c.OwnerUserId == user.Id) + .OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase) + .Select(ToCharacterSummary) + .ToArray(); + + return ServiceResult>.Success(characters); } - - if (user.ActiveCharacterId is null) - { - return ServiceResult>.Failure("no_active_character", "No active character is selected."); - } - - var activeCharacter = m_Db.Characters.AsNoTracking().SingleOrDefault(c => c.Id == user.ActiveCharacterId.Value); - if (activeCharacter is null) - { - user.ActiveCharacterId = null; - m_Db.SaveChanges(); - return ServiceResult>.Failure("no_active_character", "No active character is selected."); - } - - var characters = m_Db.Characters - .AsNoTracking() - .Where(c => c.CampaignId == activeCharacter.CampaignId && c.OwnerUserId == user.Id) - .OrderBy(c => c.Name) - .ToList(); - - return ServiceResult>.Success(characters.Select(ToCharacterSummary).ToArray()); } public ServiceResult CreateSkill(string sessionToken, Guid characterId, CreateSkillRequest request) @@ -389,43 +406,45 @@ public sealed class GameService : IGameService return ServiceResult.Failure("invalid_skill_name", "Skill name is required."); } - var user = ResolveUser(sessionToken); - if (user is null) + lock (m_Gate) { - return ServiceResult.Failure("unauthorized", "You must be logged in."); + var user = ResolveUserLocked(sessionToken); + if (user is null) + { + return ServiceResult.Failure("unauthorized", "You must be logged in."); + } + + if (!m_CharactersById.TryGetValue(characterId, out var character)) + { + return ServiceResult.Failure("character_not_found", "Character was not found."); + } + + var campaign = m_CampaignsById[character.CampaignId]; + if (!CanEditCharacterLocked(user.Id, character, campaign)) + { + return ServiceResult.Failure("forbidden", "Only the owner or GM can edit skills."); + } + + var expressionValidation = DiceRules.ParseExpression(campaign.Ruleset, request.DiceRollDefinition); + if (!expressionValidation.Succeeded) + { + return ServiceResult.Failure(expressionValidation.Error!.Code, expressionValidation.Error.Message); + } + + var skill = new Skill + { + Id = Guid.NewGuid(), + CharacterId = character.Id, + Name = request.Name.Trim(), + DiceRollDefinition = expressionValidation.Value!.Canonical + }; + + m_SkillsById[skill.Id] = skill; + TouchCampaignLocked(campaign.Id); + + PersistStateLocked(); + return ServiceResult.Success(ToSkillSummary(skill)); } - - var character = m_Db.Characters.SingleOrDefault(c => c.Id == characterId); - if (character is null) - { - return ServiceResult.Failure("character_not_found", "Character was not found."); - } - - var campaign = m_Db.Campaigns.Single(c => c.Id == character.CampaignId); - if (!CanEditCharacter(user.Id, character, campaign)) - { - return ServiceResult.Failure("forbidden", "Only the owner or GM can edit skills."); - } - - var expressionValidation = DiceRules.ParseExpression(campaign.Ruleset, request.DiceRollDefinition); - if (!expressionValidation.Succeeded) - { - return ServiceResult.Failure(expressionValidation.Error!.Code, expressionValidation.Error.Message); - } - - var skill = new Skill - { - Id = Guid.NewGuid(), - CharacterId = character.Id, - Name = request.Name.Trim(), - DiceRollDefinition = expressionValidation.Value!.Canonical - }; - - m_Db.Skills.Add(skill); - TouchCampaign(campaign); - m_Db.SaveChanges(); - - return ServiceResult.Success(ToSkillSummary(skill)); } public ServiceResult UpdateSkill(string sessionToken, Guid skillId, UpdateSkillRequest request) @@ -435,126 +454,132 @@ public sealed class GameService : IGameService return ServiceResult.Failure("invalid_skill_name", "Skill name is required."); } - var user = ResolveUser(sessionToken); - if (user is null) + lock (m_Gate) { - return ServiceResult.Failure("unauthorized", "You must be logged in."); + var user = ResolveUserLocked(sessionToken); + if (user is null) + { + return ServiceResult.Failure("unauthorized", "You must be logged in."); + } + + if (!m_SkillsById.TryGetValue(skillId, out var skill)) + { + return ServiceResult.Failure("skill_not_found", "Skill was not found."); + } + + var character = m_CharactersById[skill.CharacterId]; + var campaign = m_CampaignsById[character.CampaignId]; + if (!CanEditCharacterLocked(user.Id, character, campaign)) + { + return ServiceResult.Failure("forbidden", "Only the owner or GM can edit skills."); + } + + var expressionValidation = DiceRules.ParseExpression(campaign.Ruleset, request.DiceRollDefinition); + if (!expressionValidation.Succeeded) + { + return ServiceResult.Failure(expressionValidation.Error!.Code, expressionValidation.Error.Message); + } + + skill.Name = request.Name.Trim(); + skill.DiceRollDefinition = expressionValidation.Value!.Canonical; + TouchCampaignLocked(campaign.Id); + + PersistStateLocked(); + return ServiceResult.Success(ToSkillSummary(skill)); } - - var skill = m_Db.Skills.SingleOrDefault(s => s.Id == skillId); - if (skill is null) - { - return ServiceResult.Failure("skill_not_found", "Skill was not found."); - } - - var character = m_Db.Characters.Single(c => c.Id == skill.CharacterId); - var campaign = m_Db.Campaigns.Single(c => c.Id == character.CampaignId); - if (!CanEditCharacter(user.Id, character, campaign)) - { - return ServiceResult.Failure("forbidden", "Only the owner or GM can edit skills."); - } - - var expressionValidation = DiceRules.ParseExpression(campaign.Ruleset, request.DiceRollDefinition); - if (!expressionValidation.Succeeded) - { - return ServiceResult.Failure(expressionValidation.Error!.Code, expressionValidation.Error.Message); - } - - skill.Name = request.Name.Trim(); - skill.DiceRollDefinition = expressionValidation.Value!.Canonical; - - TouchCampaign(campaign); - m_Db.SaveChanges(); - - return ServiceResult.Success(ToSkillSummary(skill)); } public ServiceResult RollSkill(string sessionToken, Guid skillId, RollSkillRequest request) { - var user = ResolveUser(sessionToken); - if (user is null) + lock (m_Gate) { - return ServiceResult.Failure("unauthorized", "You must be logged in."); + var user = ResolveUserLocked(sessionToken); + if (user is null) + { + return ServiceResult.Failure("unauthorized", "You must be logged in."); + } + + if (!m_SkillsById.TryGetValue(skillId, out var skill)) + { + return ServiceResult.Failure("skill_not_found", "Skill was not found."); + } + + var character = m_CharactersById[skill.CharacterId]; + var campaign = m_CampaignsById[character.CampaignId]; + if (!CanEditCharacterLocked(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); + if (!parsedExpression.Succeeded) + { + return ServiceResult.Failure(parsedExpression.Error!.Code, parsedExpression.Error.Message); + } + + var visibility = ParseVisibility(request.Visibility); + if (!visibility.Succeeded) + { + return ServiceResult.Failure(visibility.Error!.Code, visibility.Error.Message); + } + + var roll = ComputeRoll(parsedExpression.Value!); + var entry = new RollLogEntry + { + Id = Guid.NewGuid(), + CampaignId = campaign.Id, + CharacterId = character.Id, + SkillId = skill.Id, + RollerUserId = user.Id, + Visibility = visibility.Value, + Result = roll.Total, + Breakdown = roll.Breakdown, + TimestampUtc = DateTimeOffset.UtcNow + }; + + m_RollLog.Add(entry); + TouchCampaignLocked(campaign.Id); + + PersistStateLocked(); + return ServiceResult.Success(ToRollResult(entry)); } - - var skill = m_Db.Skills.SingleOrDefault(s => s.Id == skillId); - if (skill is null) - { - return ServiceResult.Failure("skill_not_found", "Skill was not found."); - } - - var character = m_Db.Characters.Single(c => c.Id == skill.CharacterId); - var campaign = m_Db.Campaigns.Single(c => c.Id == character.CampaignId); - if (!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); - if (!parsedExpression.Succeeded) - { - return ServiceResult.Failure(parsedExpression.Error!.Code, parsedExpression.Error.Message); - } - - var visibility = ParseVisibility(request.Visibility); - if (!visibility.Succeeded) - { - return ServiceResult.Failure(visibility.Error!.Code, visibility.Error.Message); - } - - var roll = ComputeRoll(parsedExpression.Value!); - var entry = new RollLogEntry - { - Id = Guid.NewGuid(), - CampaignId = campaign.Id, - CharacterId = character.Id, - SkillId = skill.Id, - RollerUserId = user.Id, - Visibility = visibility.Value, - Result = roll.Total, - Breakdown = roll.Breakdown, - TimestampUtc = DateTimeOffset.UtcNow - }; - - m_Db.RollLogEntries.Add(entry); - TouchCampaign(campaign); - m_Db.SaveChanges(); - - return ServiceResult.Success(ToRollResult(entry)); } public ServiceResult> GetCampaignLog(string sessionToken, Guid campaignId) { - var context = ResolveContext(sessionToken, campaignId); - if (!context.Succeeded) + lock (m_Gate) { - return ServiceResult>.Failure(context.Error!.Code, context.Error.Message); + var context = ResolveContextLocked(sessionToken, campaignId); + if (!context.Succeeded) + { + return ServiceResult>.Failure(context.Error!.Code, context.Error.Message); + } + + var (user, campaign) = context.Value!; + var entries = m_RollLog + .Where(r => r.CampaignId == campaign.Id) + .Where(r => r.Visibility == RollVisibility.Public || r.RollerUserId == user.Id || campaign.GmUserId == user.Id) + .OrderBy(r => r.TimestampUtc) + .ThenBy(r => r.Id) + .Select(ToLogEntry) + .ToArray(); + + return ServiceResult>.Success(entries); } - - var (user, campaign) = context.Value!; - var isGm = campaign.GmUserId == user.Id; - - var entries = m_Db.RollLogEntries - .AsNoTracking() - .Where(r => r.CampaignId == campaign.Id) - .Where(r => r.Visibility == RollVisibility.Public || r.RollerUserId == user.Id || isGm) - .ToList() - .OrderBy(r => r.TimestampUtc) - .ThenBy(r => r.Id) - .ToList(); - - return ServiceResult>.Success(entries.Select(ToLogEntry).ToArray()); } public ServiceResult GetCampaignVersion(string sessionToken, Guid campaignId) { - var context = ResolveContext(sessionToken, campaignId); - if (!context.Succeeded) + lock (m_Gate) { - return ServiceResult.Failure(context.Error!.Code, context.Error.Message); - } + var context = ResolveContextLocked(sessionToken, campaignId); + if (!context.Succeeded) + { + return ServiceResult.Failure(context.Error!.Code, context.Error.Message); + } - return ServiceResult.Success(context.Value!.Campaign.Version); + return ServiceResult.Success(context.Value!.Campaign.Version); + } } private (int Total, string Breakdown) ComputeRoll(DiceExpression expression) @@ -588,21 +613,20 @@ public sealed class GameService : IGameService return ServiceResult.Failure("invalid_visibility", "Visibility must be 'public' or 'private'."); } - private ServiceResult<(UserAccount User, Campaign Campaign)> ResolveContext(string sessionToken, Guid campaignId) + private ServiceResult<(UserAccount User, Campaign Campaign)> ResolveContextLocked(string sessionToken, Guid campaignId) { - var user = ResolveUser(sessionToken); + var user = ResolveUserLocked(sessionToken); if (user is null) { return ServiceResult<(UserAccount User, Campaign Campaign)>.Failure("unauthorized", "You must be logged in."); } - var campaign = m_Db.Campaigns.SingleOrDefault(c => c.Id == campaignId); - if (campaign is null) + if (!m_CampaignsById.TryGetValue(campaignId, out var campaign)) { return ServiceResult<(UserAccount User, Campaign Campaign)>.Failure("campaign_not_found", "Campaign was not found."); } - if (!CanViewCampaign(user.Id, campaign.Id, campaign.GmUserId)) + if (!CanViewCampaignLocked(user.Id, campaign.Id)) { return ServiceResult<(UserAccount User, Campaign Campaign)>.Failure("forbidden", "You are not a participant in this campaign."); } @@ -610,47 +634,6 @@ public sealed class GameService : IGameService return ServiceResult<(UserAccount User, Campaign Campaign)>.Success((user, campaign)); } - private UserAccount? ResolveUser(string sessionToken) - { - if (string.IsNullOrWhiteSpace(sessionToken)) - { - return null; - } - - var session = m_Db.Sessions.SingleOrDefault(s => s.Token == sessionToken); - if (session is null) - { - return null; - } - - return m_Db.Users.SingleOrDefault(u => u.Id == session.UserId); - } - - private bool CanEditCharacter(Guid actorUserId, Character character, Campaign campaign) - { - return character.OwnerUserId == actorUserId || campaign.GmUserId == actorUserId; - } - - private bool CanViewCampaign(Guid userId, Guid campaignId, Guid gmUserId) - { - if (gmUserId == userId) - { - return true; - } - - return m_Db.Characters.Any(c => c.CampaignId == campaignId && c.OwnerUserId == userId); - } - - private void TouchCampaign(Campaign campaign) - { - campaign.Version += 1; - } - - private static string NormalizeUsername(string username) - { - return username.ToUpperInvariant(); - } - private static UserSummary ToUserSummary(UserAccount user) { return new UserSummary(user.Id, user.Username, user.DisplayName); @@ -698,4 +681,247 @@ public sealed class GameService : IGameService entry.Breakdown, entry.TimestampUtc); } + + private bool CanEditCharacterLocked(Guid actorUserId, Character character, Campaign campaign) + { + return character.OwnerUserId == actorUserId || campaign.GmUserId == actorUserId; + } + + private bool CanViewCampaignLocked(Guid userId, Guid campaignId) + { + var campaign = m_CampaignsById[campaignId]; + if (campaign.GmUserId == userId) + { + return true; + } + + return m_CharactersById.Values.Any(c => c.CampaignId == campaignId && c.OwnerUserId == userId); + } + + 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; + } + + campaignId = character.CampaignId; + return true; + } + + private UserSession CreateSession(Guid userId) + { + var token = Guid.NewGuid().ToString("N"); + var session = new UserSession + { + Token = token, + UserId = userId, + CreatedAtUtc = DateTimeOffset.UtcNow + }; + + m_SessionsByToken[token] = session; + return session; + } + + 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 void TouchCampaignLocked(Guid campaignId) + { + if (m_CampaignsById.TryGetValue(campaignId, out var campaign)) + { + campaign.Version += 1; + } + } + + 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 skills = db.Skills.AsNoTracking().ToList(); + var logEntries = db.RollLogEntries.AsNoTracking().ToList() + .OrderBy(x => x.TimestampUtc) + .ThenBy(x => x.Id) + .ToList(); + + lock (m_Gate) + { + m_UsersById.Clear(); + m_UserIdsByUsername.Clear(); + m_SessionsByToken.Clear(); + m_CampaignsById.Clear(); + m_CharactersById.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, + 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 skill in skills) + { + m_SkillsById[skill.Id] = CloneSkill(skill); + } + + m_RollLog.AddRange(logEntries.Select(CloneRollLogEntry)); + } + } + + private void PersistStateLocked() + { + using var db = m_DbContextFactory.CreateDbContext(); + using var transaction = db.Database.BeginTransaction(); + + db.RollLogEntries.ExecuteDelete(); + db.Skills.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.Skills.AddRange(m_SkillsById.Values.Select(CloneSkill)); + db.RollLogEntries.AddRange(m_RollLog.Select(CloneRollLogEntry)); + + db.SaveChanges(); + transaction.Commit(); + } + + private static string NormalizeUsername(string username) + { + return username.ToUpperInvariant(); + } + + private static UserAccount CloneUser(UserAccount user) + { + return new UserAccount + { + Id = user.Id, + Username = user.Username, + UsernameNormalized = user.UsernameNormalized, + PasswordHash = user.PasswordHash, + DisplayName = user.DisplayName, + ActiveCharacterId = user.ActiveCharacterId + }; + } + + private static UserSession CloneSession(UserSession session) + { + return new UserSession + { + Token = session.Token, + UserId = session.UserId, + CreatedAtUtc = session.CreatedAtUtc + }; + } + + private static Campaign CloneCampaign(Campaign campaign) + { + return new Campaign + { + Id = campaign.Id, + GmUserId = campaign.GmUserId, + Name = campaign.Name, + Ruleset = campaign.Ruleset, + Version = campaign.Version + }; + } + + private static Character CloneCharacter(Character character) + { + return new Character + { + Id = character.Id, + OwnerUserId = character.OwnerUserId, + CampaignId = character.CampaignId, + Name = character.Name + }; + } + + private static Skill CloneSkill(Skill skill) + { + return new Skill + { + Id = skill.Id, + CharacterId = skill.CharacterId, + Name = skill.Name, + DiceRollDefinition = skill.DiceRollDefinition + }; + } + + private static RollLogEntry CloneRollLogEntry(RollLogEntry entry) + { + return new RollLogEntry + { + Id = entry.Id, + CampaignId = entry.CampaignId, + CharacterId = entry.CharacterId, + SkillId = entry.SkillId, + RollerUserId = entry.RollerUserId, + Visibility = entry.Visibility, + Result = entry.Result, + Breakdown = entry.Breakdown, + TimestampUtc = entry.TimestampUtc + }; + } }