diff --git a/RpgRoller.Tests/GameServiceTests.cs b/RpgRoller.Tests/GameServiceTests.cs index de272de..4393749 100644 --- a/RpgRoller.Tests/GameServiceTests.cs +++ b/RpgRoller.Tests/GameServiceTests.cs @@ -1,5 +1,7 @@ using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; using RpgRoller.Contracts; +using RpgRoller.Data; using RpgRoller.Domain; using RpgRoller.Services; @@ -10,7 +12,8 @@ public sealed class GameServiceTests [Fact] public void Register_ValidatesRequiredFieldsAndDuplicates() { - var service = CreateService(); + using var harness = CreateHarness(); + var service = harness.Service; var invalidUsername = service.Register(new RegisterRequest("", "Password123", "Display")); var invalidDisplay = service.Register(new RegisterRequest("user", "Password123", "")); @@ -28,7 +31,8 @@ public sealed class GameServiceTests [Fact] public void Login_ValidatesCredentialsAndSessionLookup() { - var service = CreateService(); + using var harness = CreateHarness(); + var service = harness.Service; service.Register(new RegisterRequest("user", "Password123", "Display")); var invalidUser = service.Login(new LoginRequest("missing", "Password123")); @@ -46,10 +50,25 @@ public sealed class GameServiceTests Assert.Null(service.GetUserBySession(valid.Value.SessionToken)); } + [Fact] + public void Login_RehashesPasswordWhenHasherRequestsIt() + { + var hasher = new RehashingPasswordHasher(); + using var harness = CreateHarness(hasher); + var service = harness.Service; + + service.Register(new RegisterRequest("user", "Password123", "Display")); + var login = service.Login(new LoginRequest("user", "Password123")); + + Assert.True(login.Succeeded); + Assert.Equal(2, hasher.HashCalls); + } + [Fact] public void CampaignAndCharacterOperations_CheckUnauthorizedAndNotFoundCases() { - var service = CreateService(); + using var harness = CreateHarness(); + var service = harness.Service; var unauthorizedCampaign = service.CreateCampaign("missing", new CreateCampaignRequest("Name", "d6")); Assert.False(unauthorizedCampaign.Succeeded); @@ -83,7 +102,8 @@ public sealed class GameServiceTests [Fact] public void CharacterSkillAndRollOperations_CheckAuthorizationAndValidationBranches() { - var service = CreateService(3, 4, 5, 6); + using var harness = CreateHarness(3, 4, 5, 6); + var service = harness.Service; service.Register(new RegisterRequest("gm", "Password123", "GM")); service.Register(new RegisterRequest("owner", "Password123", "Owner")); service.Register(new RegisterRequest("other", "Password123", "Other")); @@ -152,7 +172,8 @@ public sealed class GameServiceTests [Fact] public void CurrentCampaignCharacters_ReturnsNoActiveCharacterWhenUnset() { - var service = CreateService(); + using var harness = CreateHarness(); + var service = harness.Service; service.Register(new RegisterRequest("user", "Password123", "User")); var sessionToken = GetValue(service.Login(new LoginRequest("user", "Password123"))).SessionToken; @@ -163,7 +184,8 @@ public sealed class GameServiceTests [Fact] public void GetCampaigns_ReturnsOwnedAndParticipatingCampaigns() { - var service = CreateService(); + using var harness = CreateHarness(); + var service = harness.Service; service.Register(new RegisterRequest("gm", "Password123", "GM")); service.Register(new RegisterRequest("player", "Password123", "Player")); var gmSession = GetValue(service.Login(new LoginRequest("gm", "Password123"))).SessionToken; @@ -180,6 +202,105 @@ public sealed class GameServiceTests Assert.Equal(gmCampaign.Id, campaigns[0].Id); } + [Fact] + public void ServiceGuardAndPersistenceBranches_AreHandled() + { + using var harness = CreateHarness(2, 3, 4); + var service = harness.Service; + + var invalidCredentials = service.Login(new LoginRequest("", "")); + Assert.False(invalidCredentials.Succeeded); + + service.Logout("missing-session"); + + service.Register(new RegisterRequest("gm", "Password123", "GM")); + service.Register(new RegisterRequest("owner", "Password123", "Owner")); + service.Register(new RegisterRequest("other", "Password123", "Other")); + + var gmSession = GetValue(service.Login(new LoginRequest("gm", "Password123"))).SessionToken; + var ownerSession = GetValue(service.Login(new LoginRequest("owner", "Password123"))).SessionToken; + var otherSession = GetValue(service.Login(new LoginRequest("other", "Password123"))).SessionToken; + + var campaign = GetValue(service.CreateCampaign(gmSession, new CreateCampaignRequest("Main", "d6"))); + var ownerCharacter = GetValue(service.CreateCharacter(ownerSession, new CreateCharacterRequest("Owner Character", campaign.Id))); + + Assert.False(service.GetMe(string.Empty).Succeeded); + Assert.False(service.CreateCampaign(gmSession, new CreateCampaignRequest("", "d6")).Succeeded); + Assert.False(service.GetCampaigns(string.Empty).Succeeded); + Assert.False(service.CreateCharacter(ownerSession, new CreateCharacterRequest("", campaign.Id)).Succeeded); + Assert.False(service.CreateCharacter(string.Empty, new CreateCharacterRequest("Name", campaign.Id)).Succeeded); + Assert.False(service.UpdateCharacter(string.Empty, ownerCharacter.Id, new UpdateCharacterRequest("Renamed", campaign.Id)).Succeeded); + Assert.False(service.UpdateCharacter(ownerSession, Guid.NewGuid(), new UpdateCharacterRequest("Renamed", campaign.Id)).Succeeded); + Assert.False(service.ActivateCharacter(string.Empty, ownerCharacter.Id).Succeeded); + Assert.False(service.ActivateCharacter(gmSession, ownerCharacter.Id).Succeeded); + Assert.False(service.GetCurrentCampaignCharacters(string.Empty).Succeeded); + Assert.False(service.CreateSkill(string.Empty, ownerCharacter.Id, new CreateSkillRequest("Stealth", "2D+1")).Succeeded); + 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(); + + var staleMe = GetValue(service.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.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(); + + var staleCurrentCampaign = service.GetCurrentCampaignCharacters(ownerSession); + Assert.False(staleCurrentCampaign.Succeeded); + Assert.Null(harness.Db.Users.Single(u => u.Id == ownerUser.Id).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); + Assert.False(service.UpdateSkill(string.Empty, skill.Id, new UpdateSkillRequest("Stealth", "2D+1")).Succeeded); + 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(); + + Assert.False(service.RollSkill(ownerSession, skill.Id, new RollSkillRequest("public")).Succeeded); + Assert.False(service.GetCampaignLog(string.Empty, campaign.Id).Succeeded); + } + + [Fact] + public void GetCampaign_ForNonGm_ReturnsOnlyOwnedCharactersAndSkills() + { + using var harness = CreateHarness(); + var service = harness.Service; + + service.Register(new RegisterRequest("gm", "Password123", "GM")); + service.Register(new RegisterRequest("owner", "Password123", "Owner")); + service.Register(new RegisterRequest("other", "Password123", "Other")); + + var gmSession = GetValue(service.Login(new LoginRequest("gm", "Password123"))).SessionToken; + var ownerSession = GetValue(service.Login(new LoginRequest("owner", "Password123"))).SessionToken; + var otherSession = GetValue(service.Login(new LoginRequest("other", "Password123"))).SessionToken; + + var campaign = GetValue(service.CreateCampaign(gmSession, new CreateCampaignRequest("Main", "d6"))); + var ownerCharacter = GetValue(service.CreateCharacter(ownerSession, new CreateCharacterRequest("Owner Character", campaign.Id))); + var otherCharacter = GetValue(service.CreateCharacter(otherSession, new CreateCharacterRequest("Other Character", campaign.Id))); + + var ownerSkill = GetValue(service.CreateSkill(ownerSession, ownerCharacter.Id, new CreateSkillRequest("Stealth", "2D+1"))); + _ = GetValue(service.CreateSkill(otherSession, otherCharacter.Id, new CreateSkillRequest("Perception", "1D+2"))); + + var ownerView = GetValue(service.GetCampaign(ownerSession, campaign.Id)); + Assert.Single(ownerView.Characters); + Assert.Equal(ownerCharacter.Id, ownerView.Characters[0].Id); + Assert.Single(ownerView.Skills); + Assert.Equal(ownerSkill.Id, ownerView.Skills[0].Id); + } + [Fact] public void DiceRules_CoversParsingAndMappingBranches() { @@ -217,9 +338,23 @@ public sealed class GameServiceTests } } - private static GameService CreateService(params int[] rollValues) + private static ServiceHarness CreateHarness(params int[] rollValues) { - return new GameService(new PasswordHasher(), new FixedDiceRoller(rollValues)); + return CreateHarness(new PasswordHasher(), rollValues); + } + + private static ServiceHarness CreateHarness(IPasswordHasher passwordHasher, params int[] rollValues) + { + var dbPath = Path.Combine(Path.GetTempPath(), $"rpgroller-servicetests-{Guid.NewGuid():N}.db"); + var options = new DbContextOptionsBuilder() + .UseSqlite($"Data Source={dbPath}") + .Options; + + var db = new RpgRollerDbContext(options); + db.Database.EnsureCreated(); + + var service = new GameService(db, passwordHasher, new FixedDiceRoller(rollValues)); + return new ServiceHarness(service, db); } private static T GetValue(ServiceResult result) @@ -244,4 +379,41 @@ public sealed class GameServiceTests return Math.Clamp(next, 1, sides); } } + + private sealed class ServiceHarness : IDisposable + { + private readonly RpgRollerDbContext m_Db; + + public ServiceHarness(GameService service, RpgRollerDbContext db) + { + Service = service; + m_Db = db; + } + + public GameService Service { get; } + public RpgRollerDbContext Db => m_Db; + + public void Dispose() + { + m_Db.Dispose(); + } + } + + private sealed class RehashingPasswordHasher : IPasswordHasher + { + public int HashCalls { get; private set; } + + public string HashPassword(UserAccount user, string password) + { + HashCalls += 1; + return $"hash:{HashCalls}:{password}"; + } + + public PasswordVerificationResult VerifyHashedPassword(UserAccount user, string hashedPassword, string providedPassword) + { + return providedPassword == "Password123" + ? PasswordVerificationResult.SuccessRehashNeeded + : PasswordVerificationResult.Failed; + } + } } diff --git a/RpgRoller.Tests/UnitTest1.cs b/RpgRoller.Tests/UnitTest1.cs index 5bd0677..f3ab976 100644 --- a/RpgRoller.Tests/UnitTest1.cs +++ b/RpgRoller.Tests/UnitTest1.cs @@ -1,9 +1,11 @@ using System.Net; using System.Net.Http.Json; using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using RpgRoller.Contracts; +using RpgRoller.Data; using RpgRoller.Services; namespace RpgRoller.Tests; @@ -201,6 +203,12 @@ public sealed class UnitTest1 : IClassFixture> { services.RemoveAll(); services.AddSingleton(new FixedDiceRoller(rollValues)); + + services.RemoveAll>(); + services.RemoveAll(); + + var dbPath = Path.Combine(Path.GetTempPath(), $"rpgroller-tests-{Guid.NewGuid():N}.db"); + services.AddDbContext(options => options.UseSqlite($"Data Source={dbPath}")); })); } diff --git a/RpgRoller/Data/RpgRollerDbContext.cs b/RpgRoller/Data/RpgRollerDbContext.cs new file mode 100644 index 0000000..307498c --- /dev/null +++ b/RpgRoller/Data/RpgRollerDbContext.cs @@ -0,0 +1,77 @@ +using Microsoft.EntityFrameworkCore; +using RpgRoller.Domain; + +namespace RpgRoller.Data; + +public sealed class RpgRollerDbContext : DbContext +{ + public RpgRollerDbContext(DbContextOptions options) + : base(options) + { + } + + public DbSet Users => Set(); + public DbSet Sessions => Set(); + public DbSet Campaigns => Set(); + public DbSet Characters => Set(); + public DbSet Skills => Set(); + public DbSet RollLogEntries => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasKey(x => x.Id); + entity.Property(x => x.Username).IsRequired().HasMaxLength(64); + entity.Property(x => x.UsernameNormalized).IsRequired().HasMaxLength(64); + entity.Property(x => x.PasswordHash).IsRequired(); + entity.Property(x => x.DisplayName).IsRequired().HasMaxLength(128); + entity.HasIndex(x => x.UsernameNormalized).IsUnique(); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(x => x.Token); + entity.Property(x => x.Token).HasMaxLength(64); + entity.Property(x => x.CreatedAtUtc).IsRequired(); + entity.HasIndex(x => x.UserId); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(x => x.Id); + entity.Property(x => x.Name).IsRequired().HasMaxLength(128); + entity.Property(x => x.Ruleset).HasConversion().IsRequired(); + entity.Property(x => x.Version).IsRequired(); + entity.HasIndex(x => x.GmUserId); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(x => x.Id); + entity.Property(x => x.Name).IsRequired().HasMaxLength(128); + entity.HasIndex(x => x.OwnerUserId); + entity.HasIndex(x => x.CampaignId); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(x => x.Id); + entity.Property(x => x.Name).IsRequired().HasMaxLength(128); + entity.Property(x => x.DiceRollDefinition).IsRequired().HasMaxLength(128); + entity.HasIndex(x => x.CharacterId); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(x => x.Id); + entity.Property(x => x.Visibility).HasConversion().IsRequired(); + entity.Property(x => x.Breakdown).IsRequired().HasMaxLength(256); + entity.Property(x => x.TimestampUtc).IsRequired(); + entity.HasIndex(x => x.CampaignId); + entity.HasIndex(x => x.RollerUserId); + entity.HasIndex(x => x.SkillId); + entity.HasIndex(x => x.CharacterId); + }); + } +} diff --git a/RpgRoller/Domain/GameModels.cs b/RpgRoller/Domain/GameModels.cs index 89e66d4..2f21828 100644 --- a/RpgRoller/Domain/GameModels.cs +++ b/RpgRoller/Domain/GameModels.cs @@ -16,8 +16,10 @@ public sealed class UserAccount { public required Guid Id { get; init; } public required string Username { get; init; } + public required string UsernameNormalized { get; init; } public required string PasswordHash { get; set; } public required string DisplayName { get; set; } + public Guid? ActiveCharacterId { get; set; } } public sealed class UserSession diff --git a/RpgRoller/Program.cs b/RpgRoller/Program.cs index 09415e3..683e3fa 100644 --- a/RpgRoller/Program.cs +++ b/RpgRoller/Program.cs @@ -1,18 +1,31 @@ using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Identity; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; using RpgRoller.Contracts; +using RpgRoller.Data; using RpgRoller.Domain; using RpgRoller.Services; const string SessionCookieName = "rpgroller_session"; var builder = WebApplication.CreateBuilder(args); +var sqliteConnectionString = builder.Configuration.GetConnectionString("RpgRoller") ?? "Data Source=App_Data/rpgroller.db"; +EnsureSqliteDataDirectory(sqliteConnectionString, builder.Environment.ContentRootPath); + builder.Services.AddSingleton, PasswordHasher>(); +builder.Services.AddDbContext(options => options.UseSqlite(sqliteConnectionString)); builder.Services.AddSingleton(); -builder.Services.AddSingleton(); +builder.Services.AddScoped(); var app = builder.Build(); +using (var scope = app.Services.CreateScope()) +{ + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.EnsureCreated(); +} + app.UseDefaultFiles(); app.UseStaticFiles(); @@ -287,4 +300,23 @@ static BadRequest ToBadRequest(ServiceError error) return TypedResults.BadRequest(new ApiError(error.Message)); } +static void EnsureSqliteDataDirectory(string connectionString, string contentRootPath) +{ + var builder = new SqliteConnectionStringBuilder(connectionString); + if (string.IsNullOrWhiteSpace(builder.DataSource) || builder.DataSource == ":memory:") + { + return; + } + + var fullPath = Path.IsPathRooted(builder.DataSource) + ? builder.DataSource + : Path.Combine(contentRootPath, builder.DataSource); + + var directory = Path.GetDirectoryName(fullPath); + if (!string.IsNullOrWhiteSpace(directory)) + { + Directory.CreateDirectory(directory); + } +} + public partial class Program; diff --git a/RpgRoller/RpgRoller.csproj b/RpgRoller/RpgRoller.csproj index a3a34b6..b66ecb9 100644 --- a/RpgRoller/RpgRoller.csproj +++ b/RpgRoller/RpgRoller.csproj @@ -6,4 +6,8 @@ enable + + + + diff --git a/RpgRoller/Services/GameService.cs b/RpgRoller/Services/GameService.cs index b5d512e..c3032e0 100644 --- a/RpgRoller/Services/GameService.cs +++ b/RpgRoller/Services/GameService.cs @@ -1,26 +1,20 @@ -using System.Collections.ObjectModel; using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; using RpgRoller.Contracts; +using RpgRoller.Data; using RpgRoller.Domain; namespace RpgRoller.Services; public sealed class GameService : IGameService { - private readonly object m_Gate = new(); + private readonly RpgRollerDbContext m_Db; private readonly IPasswordHasher m_PasswordHasher; private readonly IDiceRoller m_DiceRoller; - private readonly Dictionary m_UsersById = []; - private readonly Dictionary m_UserIdsByUsername = new(StringComparer.OrdinalIgnoreCase); - 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 = []; - private readonly Dictionary m_ActiveCharacterByUserId = []; - public GameService(IPasswordHasher passwordHasher, IDiceRoller diceRoller) + public GameService(RpgRollerDbContext db, IPasswordHasher passwordHasher, IDiceRoller diceRoller) { + m_Db = db; m_PasswordHasher = passwordHasher; m_DiceRoller = diceRoller; } @@ -49,28 +43,29 @@ public sealed class GameService : IGameService return ServiceResult.Failure("invalid_password", "Password must be at least 8 characters."); } - lock (m_Gate) + var username = request.Username.Trim(); + var normalizedUsername = NormalizeUsername(username); + if (m_Db.Users.Any(u => u.UsernameNormalized == normalizedUsername)) { - if (m_UserIdsByUsername.ContainsKey(request.Username)) - { - return ServiceResult.Failure("duplicate_username", "Username is already taken."); - } - - var user = new UserAccount - { - Id = Guid.NewGuid(), - Username = request.Username.Trim(), - DisplayName = request.DisplayName.Trim(), - PasswordHash = string.Empty - }; - - user.PasswordHash = m_PasswordHasher.HashPassword(user, request.Password); - - m_UsersById[user.Id] = user; - m_UserIdsByUsername[user.Username] = user.Id; - - return ServiceResult.Success(ToUserSummary(user)); + 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_Db.Users.Add(user); + m_Db.SaveChanges(); + + return ServiceResult.Success(ToUserSummary(user)); } public ServiceResult<(UserSummary User, string SessionToken)> Login(LoginRequest request) @@ -80,64 +75,79 @@ public sealed class GameService : IGameService return ServiceResult<(UserSummary User, string SessionToken)>.Failure("invalid_credentials", "Invalid username or password."); } - lock (m_Gate) + var normalizedUsername = NormalizeUsername(request.Username.Trim()); + var user = m_Db.Users.SingleOrDefault(u => u.UsernameNormalized == normalizedUsername); + if (user is null) { - if (!m_UserIdsByUsername.TryGetValue(request.Username, 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); - return ServiceResult<(UserSummary User, string SessionToken)>.Success((ToUserSummary(user), session.Token)); + return ServiceResult<(UserSummary User, string SessionToken)>.Failure("invalid_credentials", "Invalid username or password."); } + + 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) { - lock (m_Gate) + var session = m_Db.Sessions.SingleOrDefault(s => s.Token == sessionToken); + if (session is null) { - m_SessionsByToken.Remove(sessionToken); + return; } + + m_Db.Sessions.Remove(session); + m_Db.SaveChanges(); } public UserSummary? GetUserBySession(string sessionToken) { - lock (m_Gate) - { - var user = ResolveUserLocked(sessionToken); - return user is null ? null : ToUserSummary(user); - } + var user = ResolveUser(sessionToken); + return user is null ? null : ToUserSummary(user); } public ServiceResult GetMe(string sessionToken) { - lock (m_Gate) + var user = ResolveUser(sessionToken); + if (user is null) { - var user = ResolveUserLocked(sessionToken); - if (user is null) - { - return ServiceResult.Failure("unauthorized", "You must be logged in."); - } - - m_ActiveCharacterByUserId.TryGetValue(user.Id, out var activeCharacterId); - var campaignId = activeCharacterId != Guid.Empty && m_CharactersById.TryGetValue(activeCharacterId, out var activeCharacter) - ? activeCharacter.CampaignId - : (Guid?)null; - - return ServiceResult.Success(new MeResponse(ToUserSummary(user), activeCharacterId == Guid.Empty ? null : activeCharacterId, campaignId)); + 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) + { + user.ActiveCharacterId = null; + m_Db.SaveChanges(); + } + else + { + campaignId = activeCharacter.CampaignId; + } + } + + return ServiceResult.Success(new MeResponse(ToUserSummary(user), user.ActiveCharacterId, campaignId)); } public ServiceResult CreateCampaign(string sessionToken, CreateCampaignRequest request) @@ -153,91 +163,85 @@ public sealed class GameService : IGameService return ServiceResult.Failure("invalid_ruleset", "Unknown ruleset."); } - lock (m_Gate) + var user = ResolveUser(sessionToken); + if (user is null) { - 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; - return ServiceResult.Success(ToCampaignSummary(campaign)); + 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_Db.Campaigns.Add(campaign); + m_Db.SaveChanges(); + + return ServiceResult.Success(ToCampaignSummary(campaign)); } public ServiceResult> GetCampaigns(string sessionToken) { - lock (m_Gate) + var user = ResolveUser(sessionToken); + if (user is null) { - 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); + return ServiceResult>.Failure("unauthorized", "You must be logged in."); } + + 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) { - lock (m_Gate) + var context = ResolveContext(sessionToken, campaignId); + if (!context.Succeeded) { - 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)); + return ServiceResult.Failure(context.Error!.Code, context.Error.Message); } + + 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) @@ -247,32 +251,31 @@ public sealed class GameService : IGameService return ServiceResult.Failure("invalid_character_name", "Character name is required."); } - lock (m_Gate) + var user = ResolveUser(sessionToken); + if (user is null) { - 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); - - return ServiceResult.Success(ToCharacterSummary(character)); + return ServiceResult.Failure("unauthorized", "You must be logged in."); } + + 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) @@ -282,92 +285,101 @@ public sealed class GameService : IGameService return ServiceResult.Failure("invalid_character_name", "Character name is required."); } - lock (m_Gate) + var user = ResolveUser(sessionToken); + if (user is null) { - 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); - TouchCampaignLocked(character.CampaignId); - - return ServiceResult.Success(ToCharacterSummary(character)); + return ServiceResult.Failure("unauthorized", "You must be logged in."); } + + 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) { - lock (m_Gate) + var user = ResolveUser(sessionToken); + if (user is null) { - 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."); - } - - m_ActiveCharacterByUserId[user.Id] = character.Id; - return ServiceResult.Success(true); + return ServiceResult.Failure("unauthorized", "You must be logged in."); } + + 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) { - lock (m_Gate) + var user = ResolveUser(sessionToken); + if (user is null) { - var user = ResolveUserLocked(sessionToken); - if (user is null) - { - return ServiceResult>.Failure("unauthorized", "You must be logged in."); - } - - if (!TryGetCurrentCampaignIdLocked(user.Id, 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); + return ServiceResult>.Failure("unauthorized", "You must be logged in."); } + + 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) @@ -377,44 +389,43 @@ public sealed class GameService : IGameService return ServiceResult.Failure("invalid_skill_name", "Skill name is required."); } - lock (m_Gate) + var user = ResolveUser(sessionToken); + if (user is null) { - 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); - - return ServiceResult.Success(ToSkillSummary(skill)); + return ServiceResult.Failure("unauthorized", "You must be logged in."); } + + 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) @@ -424,130 +435,126 @@ public sealed class GameService : IGameService return ServiceResult.Failure("invalid_skill_name", "Skill name is required."); } - lock (m_Gate) + var user = ResolveUser(sessionToken); + if (user is null) { - 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); - - return ServiceResult.Success(ToSkillSummary(skill)); + return ServiceResult.Failure("unauthorized", "You must be logged in."); } + + 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) { - lock (m_Gate) + var user = ResolveUser(sessionToken); + if (user is null) { - 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); - - return ServiceResult.Success(ToRollResult(entry)); + return ServiceResult.Failure("unauthorized", "You must be logged in."); } + + 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) { - lock (m_Gate) + var context = ResolveContext(sessionToken, campaignId); + if (!context.Succeeded) { - 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); + return ServiceResult>.Failure(context.Error!.Code, context.Error.Message); } + + 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) { - lock (m_Gate) + var context = ResolveContext(sessionToken, campaignId); + if (!context.Succeeded) { - 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.Failure(context.Error!.Code, context.Error.Message); } + + return ServiceResult.Success(context.Value!.Campaign.Version); } private (int Total, string Breakdown) ComputeRoll(DiceExpression expression) @@ -581,20 +588,21 @@ public sealed class GameService : IGameService return ServiceResult.Failure("invalid_visibility", "Visibility must be 'public' or 'private'."); } - private ServiceResult<(UserAccount User, Campaign Campaign)> ResolveContextLocked(string sessionToken, Guid campaignId) + private ServiceResult<(UserAccount User, Campaign Campaign)> ResolveContext(string sessionToken, Guid campaignId) { - var user = ResolveUserLocked(sessionToken); + var user = ResolveUser(sessionToken); if (user is null) { return ServiceResult<(UserAccount User, Campaign Campaign)>.Failure("unauthorized", "You must be logged in."); } - if (!m_CampaignsById.TryGetValue(campaignId, out var campaign)) + var campaign = m_Db.Campaigns.SingleOrDefault(c => c.Id == campaignId); + if (campaign is null) { return ServiceResult<(UserAccount User, Campaign Campaign)>.Failure("campaign_not_found", "Campaign was not found."); } - if (!CanViewCampaignLocked(user.Id, campaign.Id)) + if (!CanViewCampaign(user.Id, campaign.Id, campaign.GmUserId)) { return ServiceResult<(UserAccount User, Campaign Campaign)>.Failure("forbidden", "You are not a participant in this campaign."); } @@ -602,6 +610,47 @@ 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); @@ -649,74 +698,4 @@ 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(Guid userId, out Guid campaignId) - { - campaignId = Guid.Empty; - if (!m_ActiveCharacterByUserId.TryGetValue(userId, out var activeCharacterId)) - { - return false; - } - - if (!m_CharactersById.TryGetValue(activeCharacterId, out var character)) - { - 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; - } - } } diff --git a/RpgRoller/appsettings.json b/RpgRoller/appsettings.json index 10f68b8..69fd2a8 100644 --- a/RpgRoller/appsettings.json +++ b/RpgRoller/appsettings.json @@ -1,4 +1,7 @@ { + "ConnectionStrings": { + "RpgRoller": "Data Source=App_Data/rpgroller.db" + }, "Logging": { "LogLevel": { "Default": "Information",