Persist backend state with EF Core SQLite
This commit is contained in:
@@ -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<UserAccount>(), new FixedDiceRoller(rollValues));
|
||||
return CreateHarness(new PasswordHasher<UserAccount>(), rollValues);
|
||||
}
|
||||
|
||||
private static ServiceHarness CreateHarness(IPasswordHasher<UserAccount> passwordHasher, params int[] rollValues)
|
||||
{
|
||||
var dbPath = Path.Combine(Path.GetTempPath(), $"rpgroller-servicetests-{Guid.NewGuid():N}.db");
|
||||
var options = new DbContextOptionsBuilder<RpgRollerDbContext>()
|
||||
.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<T>(ServiceResult<T> 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<UserAccount>
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<WebApplicationFactory<Program>>
|
||||
{
|
||||
services.RemoveAll<IDiceRoller>();
|
||||
services.AddSingleton<IDiceRoller>(new FixedDiceRoller(rollValues));
|
||||
|
||||
services.RemoveAll<DbContextOptions<RpgRollerDbContext>>();
|
||||
services.RemoveAll<RpgRollerDbContext>();
|
||||
|
||||
var dbPath = Path.Combine(Path.GetTempPath(), $"rpgroller-tests-{Guid.NewGuid():N}.db");
|
||||
services.AddDbContext<RpgRollerDbContext>(options => options.UseSqlite($"Data Source={dbPath}"));
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
77
RpgRoller/Data/RpgRollerDbContext.cs
Normal file
77
RpgRoller/Data/RpgRollerDbContext.cs
Normal file
@@ -0,0 +1,77 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using RpgRoller.Domain;
|
||||
|
||||
namespace RpgRoller.Data;
|
||||
|
||||
public sealed class RpgRollerDbContext : DbContext
|
||||
{
|
||||
public RpgRollerDbContext(DbContextOptions<RpgRollerDbContext> options)
|
||||
: base(options)
|
||||
{
|
||||
}
|
||||
|
||||
public DbSet<UserAccount> Users => Set<UserAccount>();
|
||||
public DbSet<UserSession> Sessions => Set<UserSession>();
|
||||
public DbSet<Campaign> Campaigns => Set<Campaign>();
|
||||
public DbSet<Character> Characters => Set<Character>();
|
||||
public DbSet<Skill> Skills => Set<Skill>();
|
||||
public DbSet<RollLogEntry> RollLogEntries => Set<RollLogEntry>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<UserAccount>(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<UserSession>(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<Campaign>(entity =>
|
||||
{
|
||||
entity.HasKey(x => x.Id);
|
||||
entity.Property(x => x.Name).IsRequired().HasMaxLength(128);
|
||||
entity.Property(x => x.Ruleset).HasConversion<string>().IsRequired();
|
||||
entity.Property(x => x.Version).IsRequired();
|
||||
entity.HasIndex(x => x.GmUserId);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Character>(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<Skill>(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<RollLogEntry>(entity =>
|
||||
{
|
||||
entity.HasKey(x => x.Id);
|
||||
entity.Property(x => x.Visibility).HasConversion<string>().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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<IPasswordHasher<UserAccount>, PasswordHasher<UserAccount>>();
|
||||
builder.Services.AddDbContext<RpgRollerDbContext>(options => options.UseSqlite(sqliteConnectionString));
|
||||
builder.Services.AddSingleton<IDiceRoller, RandomDiceRoller>();
|
||||
builder.Services.AddSingleton<IGameService, GameService>();
|
||||
builder.Services.AddScoped<IGameService, GameService>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<RpgRollerDbContext>();
|
||||
db.Database.EnsureCreated();
|
||||
}
|
||||
|
||||
app.UseDefaultFiles();
|
||||
app.UseStaticFiles();
|
||||
|
||||
@@ -287,4 +300,23 @@ static BadRequest<ApiError> 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;
|
||||
|
||||
@@ -6,4 +6,8 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.2" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -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<UserAccount> m_PasswordHasher;
|
||||
private readonly IDiceRoller m_DiceRoller;
|
||||
private readonly Dictionary<Guid, UserAccount> m_UsersById = [];
|
||||
private readonly Dictionary<string, Guid> m_UserIdsByUsername = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, UserSession> m_SessionsByToken = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<Guid, Campaign> m_CampaignsById = [];
|
||||
private readonly Dictionary<Guid, Character> m_CharactersById = [];
|
||||
private readonly Dictionary<Guid, Skill> m_SkillsById = [];
|
||||
private readonly List<RollLogEntry> m_RollLog = [];
|
||||
private readonly Dictionary<Guid, Guid> m_ActiveCharacterByUserId = [];
|
||||
|
||||
public GameService(IPasswordHasher<UserAccount> passwordHasher, IDiceRoller diceRoller)
|
||||
public GameService(RpgRollerDbContext db, IPasswordHasher<UserAccount> passwordHasher, IDiceRoller diceRoller)
|
||||
{
|
||||
m_Db = db;
|
||||
m_PasswordHasher = passwordHasher;
|
||||
m_DiceRoller = diceRoller;
|
||||
}
|
||||
@@ -49,9 +43,9 @@ public sealed class GameService : IGameService
|
||||
return ServiceResult<UserSummary>.Failure("invalid_password", "Password must be at least 8 characters.");
|
||||
}
|
||||
|
||||
lock (m_Gate)
|
||||
{
|
||||
if (m_UserIdsByUsername.ContainsKey(request.Username))
|
||||
var username = request.Username.Trim();
|
||||
var normalizedUsername = NormalizeUsername(username);
|
||||
if (m_Db.Users.Any(u => u.UsernameNormalized == normalizedUsername))
|
||||
{
|
||||
return ServiceResult<UserSummary>.Failure("duplicate_username", "Username is already taken.");
|
||||
}
|
||||
@@ -59,19 +53,20 @@ public sealed class GameService : IGameService
|
||||
var user = new UserAccount
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Username = request.Username.Trim(),
|
||||
Username = username,
|
||||
UsernameNormalized = normalizedUsername,
|
||||
DisplayName = request.DisplayName.Trim(),
|
||||
PasswordHash = string.Empty
|
||||
PasswordHash = string.Empty,
|
||||
ActiveCharacterId = null
|
||||
};
|
||||
|
||||
user.PasswordHash = m_PasswordHasher.HashPassword(user, request.Password);
|
||||
|
||||
m_UsersById[user.Id] = user;
|
||||
m_UserIdsByUsername[user.Username] = user.Id;
|
||||
m_Db.Users.Add(user);
|
||||
m_Db.SaveChanges();
|
||||
|
||||
return ServiceResult<UserSummary>.Success(ToUserSummary(user));
|
||||
}
|
||||
}
|
||||
|
||||
public ServiceResult<(UserSummary User, string SessionToken)> Login(LoginRequest request)
|
||||
{
|
||||
@@ -80,14 +75,13 @@ public sealed class GameService : IGameService
|
||||
return ServiceResult<(UserSummary User, string SessionToken)>.Failure("invalid_credentials", "Invalid username or password.");
|
||||
}
|
||||
|
||||
lock (m_Gate)
|
||||
{
|
||||
if (!m_UserIdsByUsername.TryGetValue(request.Username, out var userId))
|
||||
var normalizedUsername = NormalizeUsername(request.Username.Trim());
|
||||
var user = m_Db.Users.SingleOrDefault(u => u.UsernameNormalized == normalizedUsername);
|
||||
if (user is null)
|
||||
{
|
||||
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)
|
||||
{
|
||||
@@ -99,45 +93,61 @@ public sealed class GameService : IGameService
|
||||
user.PasswordHash = m_PasswordHasher.HashPassword(user, request.Password);
|
||||
}
|
||||
|
||||
var session = CreateSession(userId);
|
||||
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);
|
||||
var user = ResolveUser(sessionToken);
|
||||
return user is null ? null : ToUserSummary(user);
|
||||
}
|
||||
}
|
||||
|
||||
public ServiceResult<MeResponse> GetMe(string sessionToken)
|
||||
{
|
||||
lock (m_Gate)
|
||||
{
|
||||
var user = ResolveUserLocked(sessionToken);
|
||||
var user = ResolveUser(sessionToken);
|
||||
if (user is null)
|
||||
{
|
||||
return ServiceResult<MeResponse>.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<MeResponse>.Success(new MeResponse(ToUserSummary(user), activeCharacterId == Guid.Empty ? null : activeCharacterId, campaignId));
|
||||
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<MeResponse>.Success(new MeResponse(ToUserSummary(user), user.ActiveCharacterId, campaignId));
|
||||
}
|
||||
|
||||
public ServiceResult<CampaignSummary> CreateCampaign(string sessionToken, CreateCampaignRequest request)
|
||||
@@ -153,9 +163,7 @@ public sealed class GameService : IGameService
|
||||
return ServiceResult<CampaignSummary>.Failure("invalid_ruleset", "Unknown ruleset.");
|
||||
}
|
||||
|
||||
lock (m_Gate)
|
||||
{
|
||||
var user = ResolveUserLocked(sessionToken);
|
||||
var user = ResolveUser(sessionToken);
|
||||
if (user is null)
|
||||
{
|
||||
return ServiceResult<CampaignSummary>.Failure("unauthorized", "You must be logged in.");
|
||||
@@ -170,74 +178,70 @@ public sealed class GameService : IGameService
|
||||
Version = 1
|
||||
};
|
||||
|
||||
m_CampaignsById[campaign.Id] = campaign;
|
||||
m_Db.Campaigns.Add(campaign);
|
||||
m_Db.SaveChanges();
|
||||
|
||||
return ServiceResult<CampaignSummary>.Success(ToCampaignSummary(campaign));
|
||||
}
|
||||
}
|
||||
|
||||
public ServiceResult<IReadOnlyList<CampaignSummary>> GetCampaigns(string sessionToken)
|
||||
{
|
||||
lock (m_Gate)
|
||||
{
|
||||
var user = ResolveUserLocked(sessionToken);
|
||||
var user = ResolveUser(sessionToken);
|
||||
if (user is null)
|
||||
{
|
||||
return ServiceResult<IReadOnlyList<CampaignSummary>>.Failure("unauthorized", "You must be logged in.");
|
||||
}
|
||||
|
||||
var campaignIds = new HashSet<Guid>(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 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 results = campaignIds
|
||||
.Select(id => m_CampaignsById[id])
|
||||
.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(ToCampaignSummary)
|
||||
.ToArray();
|
||||
var campaigns = m_Db.Campaigns
|
||||
.Where(c => campaignIds.Contains(c.Id))
|
||||
.OrderBy(c => c.Name)
|
||||
.AsNoTracking()
|
||||
.ToList();
|
||||
|
||||
return ServiceResult<IReadOnlyList<CampaignSummary>>.Success(results);
|
||||
}
|
||||
return ServiceResult<IReadOnlyList<CampaignSummary>>.Success(campaigns.Select(ToCampaignSummary).ToArray());
|
||||
}
|
||||
|
||||
public ServiceResult<CampaignDetails> GetCampaign(string sessionToken, Guid campaignId)
|
||||
{
|
||||
lock (m_Gate)
|
||||
{
|
||||
var context = ResolveContextLocked(sessionToken, campaignId);
|
||||
var context = ResolveContext(sessionToken, campaignId);
|
||||
if (!context.Succeeded)
|
||||
{
|
||||
return ServiceResult<CampaignDetails>.Failure(context.Error!.Code, context.Error.Message);
|
||||
}
|
||||
|
||||
var (user, campaign) = context.Value!;
|
||||
var gm = m_UsersById[campaign.GmUserId];
|
||||
var gm = m_Db.Users.AsNoTracking().Single(u => u.Id == 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 charactersQuery = m_Db.Characters.AsNoTracking().Where(c => c.CampaignId == campaign.Id);
|
||||
if (!isGm)
|
||||
{
|
||||
charactersQuery = charactersQuery.Where(c => c.OwnerUserId == user.Id);
|
||||
}
|
||||
|
||||
var visibleCharacterIds = characters.Select(c => c.Id).ToHashSet();
|
||||
var characters = charactersQuery
|
||||
.OrderBy(c => c.Name)
|
||||
.ToList();
|
||||
|
||||
var skills = m_SkillsById.Values
|
||||
.Where(s => visibleCharacterIds.Contains(s.CharacterId))
|
||||
.OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(ToSkillSummary)
|
||||
.ToArray();
|
||||
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<CampaignDetails>.Success(new CampaignDetails(
|
||||
campaign.Id,
|
||||
campaign.Name,
|
||||
DiceRules.ToRulesetId(campaign.Ruleset),
|
||||
ToUserSummary(gm),
|
||||
characters,
|
||||
skills));
|
||||
}
|
||||
characters.Select(ToCharacterSummary).ToArray(),
|
||||
skills.Select(ToSkillSummary).ToArray()));
|
||||
}
|
||||
|
||||
public ServiceResult<CharacterSummary> CreateCharacter(string sessionToken, CreateCharacterRequest request)
|
||||
@@ -247,15 +251,14 @@ public sealed class GameService : IGameService
|
||||
return ServiceResult<CharacterSummary>.Failure("invalid_character_name", "Character name is required.");
|
||||
}
|
||||
|
||||
lock (m_Gate)
|
||||
{
|
||||
var user = ResolveUserLocked(sessionToken);
|
||||
var user = ResolveUser(sessionToken);
|
||||
if (user is null)
|
||||
{
|
||||
return ServiceResult<CharacterSummary>.Failure("unauthorized", "You must be logged in.");
|
||||
}
|
||||
|
||||
if (!m_CampaignsById.ContainsKey(request.CampaignId))
|
||||
var campaign = m_Db.Campaigns.SingleOrDefault(c => c.Id == request.CampaignId);
|
||||
if (campaign is null)
|
||||
{
|
||||
return ServiceResult<CharacterSummary>.Failure("campaign_not_found", "Campaign was not found.");
|
||||
}
|
||||
@@ -264,16 +267,16 @@ public sealed class GameService : IGameService
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OwnerUserId = user.Id,
|
||||
CampaignId = request.CampaignId,
|
||||
CampaignId = campaign.Id,
|
||||
Name = request.Name.Trim()
|
||||
};
|
||||
|
||||
m_CharactersById[character.Id] = character;
|
||||
TouchCampaignLocked(character.CampaignId);
|
||||
m_Db.Characters.Add(character);
|
||||
TouchCampaign(campaign);
|
||||
m_Db.SaveChanges();
|
||||
|
||||
return ServiceResult<CharacterSummary>.Success(ToCharacterSummary(character));
|
||||
}
|
||||
}
|
||||
|
||||
public ServiceResult<CharacterSummary> UpdateCharacter(string sessionToken, Guid characterId, UpdateCharacterRequest request)
|
||||
{
|
||||
@@ -282,25 +285,25 @@ public sealed class GameService : IGameService
|
||||
return ServiceResult<CharacterSummary>.Failure("invalid_character_name", "Character name is required.");
|
||||
}
|
||||
|
||||
lock (m_Gate)
|
||||
{
|
||||
var user = ResolveUserLocked(sessionToken);
|
||||
var user = ResolveUser(sessionToken);
|
||||
if (user is null)
|
||||
{
|
||||
return ServiceResult<CharacterSummary>.Failure("unauthorized", "You must be logged in.");
|
||||
}
|
||||
|
||||
if (!m_CharactersById.TryGetValue(characterId, out var character))
|
||||
var character = m_Db.Characters.SingleOrDefault(c => c.Id == characterId);
|
||||
if (character is null)
|
||||
{
|
||||
return ServiceResult<CharacterSummary>.Failure("character_not_found", "Character was not found.");
|
||||
}
|
||||
|
||||
if (!m_CampaignsById.TryGetValue(request.CampaignId, out var targetCampaign))
|
||||
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<CharacterSummary>.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;
|
||||
@@ -309,28 +312,31 @@ public sealed class GameService : IGameService
|
||||
return ServiceResult<CharacterSummary>.Failure("forbidden", "Only the owner or GM can edit this character.");
|
||||
}
|
||||
|
||||
var sourceCampaignId = character.CampaignId;
|
||||
character.Name = request.Name.Trim();
|
||||
character.CampaignId = request.CampaignId;
|
||||
var sourceCampaignId = character.CampaignId;
|
||||
character.CampaignId = targetCampaign.Id;
|
||||
|
||||
TouchCampaignLocked(sourceCampaignId);
|
||||
TouchCampaignLocked(character.CampaignId);
|
||||
TouchCampaign(sourceCampaign);
|
||||
if (sourceCampaignId != targetCampaign.Id)
|
||||
{
|
||||
TouchCampaign(targetCampaign);
|
||||
}
|
||||
|
||||
m_Db.SaveChanges();
|
||||
|
||||
return ServiceResult<CharacterSummary>.Success(ToCharacterSummary(character));
|
||||
}
|
||||
}
|
||||
|
||||
public ServiceResult<bool> ActivateCharacter(string sessionToken, Guid characterId)
|
||||
{
|
||||
lock (m_Gate)
|
||||
{
|
||||
var user = ResolveUserLocked(sessionToken);
|
||||
var user = ResolveUser(sessionToken);
|
||||
if (user is null)
|
||||
{
|
||||
return ServiceResult<bool>.Failure("unauthorized", "You must be logged in.");
|
||||
}
|
||||
|
||||
if (!m_CharactersById.TryGetValue(characterId, out var character))
|
||||
var character = m_Db.Characters.AsNoTracking().SingleOrDefault(c => c.Id == characterId);
|
||||
if (character is null)
|
||||
{
|
||||
return ServiceResult<bool>.Failure("character_not_found", "Character was not found.");
|
||||
}
|
||||
@@ -340,34 +346,40 @@ public sealed class GameService : IGameService
|
||||
return ServiceResult<bool>.Failure("forbidden", "You can activate only your own character.");
|
||||
}
|
||||
|
||||
m_ActiveCharacterByUserId[user.Id] = character.Id;
|
||||
user.ActiveCharacterId = character.Id;
|
||||
m_Db.SaveChanges();
|
||||
|
||||
return ServiceResult<bool>.Success(true);
|
||||
}
|
||||
}
|
||||
|
||||
public ServiceResult<IReadOnlyList<CharacterSummary>> GetCurrentCampaignCharacters(string sessionToken)
|
||||
{
|
||||
lock (m_Gate)
|
||||
{
|
||||
var user = ResolveUserLocked(sessionToken);
|
||||
var user = ResolveUser(sessionToken);
|
||||
if (user is null)
|
||||
{
|
||||
return ServiceResult<IReadOnlyList<CharacterSummary>>.Failure("unauthorized", "You must be logged in.");
|
||||
}
|
||||
|
||||
if (!TryGetCurrentCampaignIdLocked(user.Id, out var campaignId))
|
||||
if (user.ActiveCharacterId is null)
|
||||
{
|
||||
return ServiceResult<IReadOnlyList<CharacterSummary>>.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<IReadOnlyList<CharacterSummary>>.Success(characters);
|
||||
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<IReadOnlyList<CharacterSummary>>.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<IReadOnlyList<CharacterSummary>>.Success(characters.Select(ToCharacterSummary).ToArray());
|
||||
}
|
||||
|
||||
public ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, CreateSkillRequest request)
|
||||
@@ -377,21 +389,20 @@ public sealed class GameService : IGameService
|
||||
return ServiceResult<SkillSummary>.Failure("invalid_skill_name", "Skill name is required.");
|
||||
}
|
||||
|
||||
lock (m_Gate)
|
||||
{
|
||||
var user = ResolveUserLocked(sessionToken);
|
||||
var user = ResolveUser(sessionToken);
|
||||
if (user is null)
|
||||
{
|
||||
return ServiceResult<SkillSummary>.Failure("unauthorized", "You must be logged in.");
|
||||
}
|
||||
|
||||
if (!m_CharactersById.TryGetValue(characterId, out var character))
|
||||
var character = m_Db.Characters.SingleOrDefault(c => c.Id == characterId);
|
||||
if (character is null)
|
||||
{
|
||||
return ServiceResult<SkillSummary>.Failure("character_not_found", "Character was not found.");
|
||||
}
|
||||
|
||||
var campaign = m_CampaignsById[character.CampaignId];
|
||||
if (!CanEditCharacterLocked(user.Id, character, campaign))
|
||||
var campaign = m_Db.Campaigns.Single(c => c.Id == character.CampaignId);
|
||||
if (!CanEditCharacter(user.Id, character, campaign))
|
||||
{
|
||||
return ServiceResult<SkillSummary>.Failure("forbidden", "Only the owner or GM can edit skills.");
|
||||
}
|
||||
@@ -410,12 +421,12 @@ public sealed class GameService : IGameService
|
||||
DiceRollDefinition = expressionValidation.Value!.Canonical
|
||||
};
|
||||
|
||||
m_SkillsById[skill.Id] = skill;
|
||||
TouchCampaignLocked(campaign.Id);
|
||||
m_Db.Skills.Add(skill);
|
||||
TouchCampaign(campaign);
|
||||
m_Db.SaveChanges();
|
||||
|
||||
return ServiceResult<SkillSummary>.Success(ToSkillSummary(skill));
|
||||
}
|
||||
}
|
||||
|
||||
public ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, UpdateSkillRequest request)
|
||||
{
|
||||
@@ -424,22 +435,21 @@ public sealed class GameService : IGameService
|
||||
return ServiceResult<SkillSummary>.Failure("invalid_skill_name", "Skill name is required.");
|
||||
}
|
||||
|
||||
lock (m_Gate)
|
||||
{
|
||||
var user = ResolveUserLocked(sessionToken);
|
||||
var user = ResolveUser(sessionToken);
|
||||
if (user is null)
|
||||
{
|
||||
return ServiceResult<SkillSummary>.Failure("unauthorized", "You must be logged in.");
|
||||
}
|
||||
|
||||
if (!m_SkillsById.TryGetValue(skillId, out var skill))
|
||||
var skill = m_Db.Skills.SingleOrDefault(s => s.Id == skillId);
|
||||
if (skill is null)
|
||||
{
|
||||
return ServiceResult<SkillSummary>.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))
|
||||
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<SkillSummary>.Failure("forbidden", "Only the owner or GM can edit skills.");
|
||||
}
|
||||
@@ -452,30 +462,30 @@ public sealed class GameService : IGameService
|
||||
|
||||
skill.Name = request.Name.Trim();
|
||||
skill.DiceRollDefinition = expressionValidation.Value!.Canonical;
|
||||
TouchCampaignLocked(campaign.Id);
|
||||
|
||||
TouchCampaign(campaign);
|
||||
m_Db.SaveChanges();
|
||||
|
||||
return ServiceResult<SkillSummary>.Success(ToSkillSummary(skill));
|
||||
}
|
||||
}
|
||||
|
||||
public ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, RollSkillRequest request)
|
||||
{
|
||||
lock (m_Gate)
|
||||
{
|
||||
var user = ResolveUserLocked(sessionToken);
|
||||
var user = ResolveUser(sessionToken);
|
||||
if (user is null)
|
||||
{
|
||||
return ServiceResult<RollResult>.Failure("unauthorized", "You must be logged in.");
|
||||
}
|
||||
|
||||
if (!m_SkillsById.TryGetValue(skillId, out var skill))
|
||||
var skill = m_Db.Skills.SingleOrDefault(s => s.Id == skillId);
|
||||
if (skill is null)
|
||||
{
|
||||
return ServiceResult<RollResult>.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))
|
||||
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<RollResult>.Failure("forbidden", "Only the owner or GM can roll this skill.");
|
||||
}
|
||||
@@ -506,41 +516,39 @@ public sealed class GameService : IGameService
|
||||
TimestampUtc = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
m_RollLog.Add(entry);
|
||||
TouchCampaignLocked(campaign.Id);
|
||||
m_Db.RollLogEntries.Add(entry);
|
||||
TouchCampaign(campaign);
|
||||
m_Db.SaveChanges();
|
||||
|
||||
return ServiceResult<RollResult>.Success(ToRollResult(entry));
|
||||
}
|
||||
}
|
||||
|
||||
public ServiceResult<IReadOnlyList<CampaignLogEntry>> GetCampaignLog(string sessionToken, Guid campaignId)
|
||||
{
|
||||
lock (m_Gate)
|
||||
{
|
||||
var context = ResolveContextLocked(sessionToken, campaignId);
|
||||
var context = ResolveContext(sessionToken, campaignId);
|
||||
if (!context.Succeeded)
|
||||
{
|
||||
return ServiceResult<IReadOnlyList<CampaignLogEntry>>.Failure(context.Error!.Code, context.Error.Message);
|
||||
}
|
||||
|
||||
var (user, campaign) = context.Value!;
|
||||
var entries = m_RollLog
|
||||
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 || campaign.GmUserId == user.Id)
|
||||
.Where(r => r.Visibility == RollVisibility.Public || r.RollerUserId == user.Id || isGm)
|
||||
.ToList()
|
||||
.OrderBy(r => r.TimestampUtc)
|
||||
.ThenBy(r => r.Id)
|
||||
.Select(ToLogEntry)
|
||||
.ToArray();
|
||||
.ToList();
|
||||
|
||||
return ServiceResult<IReadOnlyList<CampaignLogEntry>>.Success(entries);
|
||||
}
|
||||
return ServiceResult<IReadOnlyList<CampaignLogEntry>>.Success(entries.Select(ToLogEntry).ToArray());
|
||||
}
|
||||
|
||||
public ServiceResult<long> GetCampaignVersion(string sessionToken, Guid campaignId)
|
||||
{
|
||||
lock (m_Gate)
|
||||
{
|
||||
var context = ResolveContextLocked(sessionToken, campaignId);
|
||||
var context = ResolveContext(sessionToken, campaignId);
|
||||
if (!context.Succeeded)
|
||||
{
|
||||
return ServiceResult<long>.Failure(context.Error!.Code, context.Error.Message);
|
||||
@@ -548,7 +556,6 @@ public sealed class GameService : IGameService
|
||||
|
||||
return ServiceResult<long>.Success(context.Value!.Campaign.Version);
|
||||
}
|
||||
}
|
||||
|
||||
private (int Total, string Breakdown) ComputeRoll(DiceExpression expression)
|
||||
{
|
||||
@@ -581,20 +588,21 @@ public sealed class GameService : IGameService
|
||||
return ServiceResult<RollVisibility>.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
{
|
||||
"ConnectionStrings": {
|
||||
"RpgRoller": "Data Source=App_Data/rpgroller.db"
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
|
||||
Reference in New Issue
Block a user