Use in-memory runtime state with SQLite write-through
This commit is contained in:
@@ -238,26 +238,41 @@ public sealed class GameServiceTests
|
|||||||
Assert.False(service.CreateSkill(ownerSession, Guid.NewGuid(), new CreateSkillRequest("Stealth", "2D+1")).Succeeded);
|
Assert.False(service.CreateSkill(ownerSession, Guid.NewGuid(), new CreateSkillRequest("Stealth", "2D+1")).Succeeded);
|
||||||
Assert.False(service.CreateSkill(otherSession, ownerCharacter.Id, 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");
|
using (var db = harness.CreateDbContext())
|
||||||
|
{
|
||||||
|
var ownerUser = db.Users.Single(u => u.UsernameNormalized == "OWNER");
|
||||||
ownerUser.ActiveCharacterId = Guid.NewGuid();
|
ownerUser.ActiveCharacterId = Guid.NewGuid();
|
||||||
harness.Db.SaveChanges();
|
db.SaveChanges();
|
||||||
|
}
|
||||||
|
|
||||||
var staleMe = GetValue(service.GetMe(ownerSession));
|
using var staleMeHarness = CreateHarnessFromPath(harness.DbPath, 2, 3, 4);
|
||||||
|
var staleMeService = staleMeHarness.Service;
|
||||||
|
|
||||||
|
var staleMe = GetValue(staleMeService.GetMe(ownerSession));
|
||||||
Assert.Null(staleMe.ActiveCharacterId);
|
Assert.Null(staleMe.ActiveCharacterId);
|
||||||
Assert.Null(staleMe.CurrentCampaignId);
|
Assert.Null(staleMe.CurrentCampaignId);
|
||||||
|
|
||||||
Assert.True(service.ActivateCharacter(ownerSession, ownerCharacter.Id).Succeeded);
|
Assert.True(staleMeService.ActivateCharacter(ownerSession, ownerCharacter.Id).Succeeded);
|
||||||
var activeMe = GetValue(service.GetMe(ownerSession));
|
var activeMe = GetValue(staleMeService.GetMe(ownerSession));
|
||||||
Assert.Equal(ownerCharacter.Id, activeMe.ActiveCharacterId);
|
Assert.Equal(ownerCharacter.Id, activeMe.ActiveCharacterId);
|
||||||
Assert.Equal(campaign.Id, activeMe.CurrentCampaignId);
|
Assert.Equal(campaign.Id, activeMe.CurrentCampaignId);
|
||||||
|
|
||||||
var staleOwner = harness.Db.Users.Single(u => u.Id == ownerUser.Id);
|
using (var db = harness.CreateDbContext())
|
||||||
|
{
|
||||||
|
var staleOwner = db.Users.Single(u => u.UsernameNormalized == "OWNER");
|
||||||
staleOwner.ActiveCharacterId = Guid.NewGuid();
|
staleOwner.ActiveCharacterId = Guid.NewGuid();
|
||||||
harness.Db.SaveChanges();
|
db.SaveChanges();
|
||||||
|
}
|
||||||
|
|
||||||
var staleCurrentCampaign = service.GetCurrentCampaignCharacters(ownerSession);
|
using var staleCurrentHarness = CreateHarnessFromPath(harness.DbPath, 2, 3, 4);
|
||||||
|
var staleCurrentService = staleCurrentHarness.Service;
|
||||||
|
|
||||||
|
var staleCurrentCampaign = staleCurrentService.GetCurrentCampaignCharacters(ownerSession);
|
||||||
Assert.False(staleCurrentCampaign.Succeeded);
|
Assert.False(staleCurrentCampaign.Succeeded);
|
||||||
Assert.Null(harness.Db.Users.Single(u => u.Id == ownerUser.Id).ActiveCharacterId);
|
using (var db = harness.CreateDbContext())
|
||||||
|
{
|
||||||
|
Assert.Null(db.Users.Single(u => u.UsernameNormalized == "OWNER").ActiveCharacterId);
|
||||||
|
}
|
||||||
|
|
||||||
var skill = GetValue(service.CreateSkill(ownerSession, ownerCharacter.Id, new CreateSkillRequest("Stealth", "2D+1")));
|
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(ownerSession, skill.Id, new UpdateSkillRequest("", "2D+1")).Succeeded);
|
||||||
@@ -265,11 +280,15 @@ public sealed class GameServiceTests
|
|||||||
Assert.False(service.UpdateSkill(ownerSession, skill.Id, new UpdateSkillRequest("Stealth", "bad")).Succeeded);
|
Assert.False(service.UpdateSkill(ownerSession, skill.Id, new UpdateSkillRequest("Stealth", "bad")).Succeeded);
|
||||||
Assert.False(service.RollSkill(string.Empty, skill.Id, new RollSkillRequest("public")).Succeeded);
|
Assert.False(service.RollSkill(string.Empty, skill.Id, new RollSkillRequest("public")).Succeeded);
|
||||||
|
|
||||||
var mutableSkill = harness.Db.Skills.Single(s => s.Id == skill.Id);
|
using (var db = harness.CreateDbContext())
|
||||||
|
{
|
||||||
|
var mutableSkill = db.Skills.Single(s => s.Id == skill.Id);
|
||||||
mutableSkill.DiceRollDefinition = "bad";
|
mutableSkill.DiceRollDefinition = "bad";
|
||||||
harness.Db.SaveChanges();
|
db.SaveChanges();
|
||||||
|
}
|
||||||
|
|
||||||
Assert.False(service.RollSkill(ownerSession, skill.Id, new RollSkillRequest("public")).Succeeded);
|
using var invalidExpressionHarness = CreateHarnessFromPath(harness.DbPath, 2, 3, 4);
|
||||||
|
Assert.False(invalidExpressionHarness.Service.RollSkill(ownerSession, skill.Id, new RollSkillRequest("public")).Succeeded);
|
||||||
Assert.False(service.GetCampaignLog(string.Empty, campaign.Id).Succeeded);
|
Assert.False(service.GetCampaignLog(string.Empty, campaign.Id).Succeeded);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,15 +365,28 @@ public sealed class GameServiceTests
|
|||||||
private static ServiceHarness CreateHarness(IPasswordHasher<UserAccount> passwordHasher, params int[] rollValues)
|
private static ServiceHarness CreateHarness(IPasswordHasher<UserAccount> passwordHasher, params int[] rollValues)
|
||||||
{
|
{
|
||||||
var dbPath = Path.Combine(Path.GetTempPath(), $"rpgroller-servicetests-{Guid.NewGuid():N}.db");
|
var dbPath = Path.Combine(Path.GetTempPath(), $"rpgroller-servicetests-{Guid.NewGuid():N}.db");
|
||||||
|
return CreateHarnessFromPath(dbPath, passwordHasher, rollValues);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ServiceHarness CreateHarnessFromPath(string dbPath, params int[] rollValues)
|
||||||
|
{
|
||||||
|
return CreateHarnessFromPath(dbPath, new PasswordHasher<UserAccount>(), rollValues);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ServiceHarness CreateHarnessFromPath(string dbPath, IPasswordHasher<UserAccount> passwordHasher, params int[] rollValues)
|
||||||
|
{
|
||||||
var options = new DbContextOptionsBuilder<RpgRollerDbContext>()
|
var options = new DbContextOptionsBuilder<RpgRollerDbContext>()
|
||||||
.UseSqlite($"Data Source={dbPath}")
|
.UseSqlite($"Data Source={dbPath}")
|
||||||
.Options;
|
.Options;
|
||||||
|
|
||||||
var db = new RpgRollerDbContext(options);
|
using (var db = new RpgRollerDbContext(options))
|
||||||
|
{
|
||||||
db.Database.EnsureCreated();
|
db.Database.EnsureCreated();
|
||||||
|
}
|
||||||
|
|
||||||
var service = new GameService(db, passwordHasher, new FixedDiceRoller(rollValues));
|
var factory = new SqliteDbContextFactory(dbPath);
|
||||||
return new ServiceHarness(service, db);
|
var service = new GameService(factory, passwordHasher, new FixedDiceRoller(rollValues));
|
||||||
|
return new ServiceHarness(service, factory, dbPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static T GetValue<T>(ServiceResult<T> result)
|
private static T GetValue<T>(ServiceResult<T> result)
|
||||||
@@ -382,20 +414,47 @@ public sealed class GameServiceTests
|
|||||||
|
|
||||||
private sealed class ServiceHarness : IDisposable
|
private sealed class ServiceHarness : IDisposable
|
||||||
{
|
{
|
||||||
private readonly RpgRollerDbContext m_Db;
|
private readonly SqliteDbContextFactory m_Factory;
|
||||||
|
|
||||||
public ServiceHarness(GameService service, RpgRollerDbContext db)
|
public ServiceHarness(GameService service, SqliteDbContextFactory factory, string dbPath)
|
||||||
{
|
{
|
||||||
Service = service;
|
Service = service;
|
||||||
m_Db = db;
|
m_Factory = factory;
|
||||||
|
DbPath = dbPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
public GameService Service { get; }
|
public GameService Service { get; }
|
||||||
public RpgRollerDbContext Db => m_Db;
|
public string DbPath { get; }
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
m_Factory.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
public RpgRollerDbContext CreateDbContext()
|
||||||
|
{
|
||||||
|
return m_Factory.CreateDbContext();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class SqliteDbContextFactory : IDbContextFactory<RpgRollerDbContext>, IDisposable
|
||||||
|
{
|
||||||
|
private readonly DbContextOptions<RpgRollerDbContext> m_Options;
|
||||||
|
|
||||||
|
public SqliteDbContextFactory(string dbPath)
|
||||||
|
{
|
||||||
|
m_Options = new DbContextOptionsBuilder<RpgRollerDbContext>()
|
||||||
|
.UseSqlite($"Data Source={dbPath}")
|
||||||
|
.Options;
|
||||||
|
}
|
||||||
|
|
||||||
|
public RpgRollerDbContext CreateDbContext()
|
||||||
|
{
|
||||||
|
return new RpgRollerDbContext(m_Options);
|
||||||
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
m_Db.Dispose();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -205,10 +205,11 @@ public sealed class UnitTest1 : IClassFixture<WebApplicationFactory<Program>>
|
|||||||
services.AddSingleton<IDiceRoller>(new FixedDiceRoller(rollValues));
|
services.AddSingleton<IDiceRoller>(new FixedDiceRoller(rollValues));
|
||||||
|
|
||||||
services.RemoveAll<DbContextOptions<RpgRollerDbContext>>();
|
services.RemoveAll<DbContextOptions<RpgRollerDbContext>>();
|
||||||
|
services.RemoveAll<IDbContextFactory<RpgRollerDbContext>>();
|
||||||
services.RemoveAll<RpgRollerDbContext>();
|
services.RemoveAll<RpgRollerDbContext>();
|
||||||
|
|
||||||
var dbPath = Path.Combine(Path.GetTempPath(), $"rpgroller-tests-{Guid.NewGuid():N}.db");
|
var dbPath = Path.Combine(Path.GetTempPath(), $"rpgroller-tests-{Guid.NewGuid():N}.db");
|
||||||
services.AddDbContext<RpgRollerDbContext>(options => options.UseSqlite($"Data Source={dbPath}"));
|
services.AddDbContextFactory<RpgRollerDbContext>(options => options.UseSqlite($"Data Source={dbPath}"));
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,16 +14,18 @@ var sqliteConnectionString = builder.Configuration.GetConnectionString("RpgRolle
|
|||||||
EnsureSqliteDataDirectory(sqliteConnectionString, builder.Environment.ContentRootPath);
|
EnsureSqliteDataDirectory(sqliteConnectionString, builder.Environment.ContentRootPath);
|
||||||
|
|
||||||
builder.Services.AddSingleton<IPasswordHasher<UserAccount>, PasswordHasher<UserAccount>>();
|
builder.Services.AddSingleton<IPasswordHasher<UserAccount>, PasswordHasher<UserAccount>>();
|
||||||
builder.Services.AddDbContext<RpgRollerDbContext>(options => options.UseSqlite(sqliteConnectionString));
|
builder.Services.AddDbContextFactory<RpgRollerDbContext>(options => options.UseSqlite(sqliteConnectionString));
|
||||||
builder.Services.AddSingleton<IDiceRoller, RandomDiceRoller>();
|
builder.Services.AddSingleton<IDiceRoller, RandomDiceRoller>();
|
||||||
builder.Services.AddScoped<IGameService, GameService>();
|
builder.Services.AddSingleton<IGameService, GameService>();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
using (var scope = app.Services.CreateScope())
|
using (var scope = app.Services.CreateScope())
|
||||||
{
|
{
|
||||||
var db = scope.ServiceProvider.GetRequiredService<RpgRollerDbContext>();
|
var dbFactory = scope.ServiceProvider.GetRequiredService<IDbContextFactory<RpgRollerDbContext>>();
|
||||||
|
using var db = dbFactory.CreateDbContext();
|
||||||
db.Database.EnsureCreated();
|
db.Database.EnsureCreated();
|
||||||
|
_ = scope.ServiceProvider.GetRequiredService<IGameService>();
|
||||||
}
|
}
|
||||||
|
|
||||||
app.UseDefaultFiles();
|
app.UseDefaultFiles();
|
||||||
|
|||||||
@@ -8,15 +8,24 @@ namespace RpgRoller.Services;
|
|||||||
|
|
||||||
public sealed class GameService : IGameService
|
public sealed class GameService : IGameService
|
||||||
{
|
{
|
||||||
private readonly RpgRollerDbContext m_Db;
|
private readonly object m_Gate = new();
|
||||||
|
private readonly IDbContextFactory<RpgRollerDbContext> m_DbContextFactory;
|
||||||
private readonly IPasswordHasher<UserAccount> m_PasswordHasher;
|
private readonly IPasswordHasher<UserAccount> m_PasswordHasher;
|
||||||
private readonly IDiceRoller m_DiceRoller;
|
private readonly IDiceRoller m_DiceRoller;
|
||||||
|
private readonly Dictionary<Guid, UserAccount> m_UsersById = [];
|
||||||
|
private readonly Dictionary<string, Guid> m_UserIdsByUsername = new(StringComparer.Ordinal);
|
||||||
|
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 = [];
|
||||||
|
|
||||||
public GameService(RpgRollerDbContext db, IPasswordHasher<UserAccount> passwordHasher, IDiceRoller diceRoller)
|
public GameService(IDbContextFactory<RpgRollerDbContext> dbContextFactory, IPasswordHasher<UserAccount> passwordHasher, IDiceRoller diceRoller)
|
||||||
{
|
{
|
||||||
m_Db = db;
|
m_DbContextFactory = dbContextFactory;
|
||||||
m_PasswordHasher = passwordHasher;
|
m_PasswordHasher = passwordHasher;
|
||||||
m_DiceRoller = diceRoller;
|
m_DiceRoller = diceRoller;
|
||||||
|
LoadStateFromDatabase();
|
||||||
}
|
}
|
||||||
|
|
||||||
public IReadOnlyList<RulesetDefinition> GetRulesets()
|
public IReadOnlyList<RulesetDefinition> GetRulesets()
|
||||||
@@ -43,9 +52,11 @@ public sealed class GameService : IGameService
|
|||||||
return ServiceResult<UserSummary>.Failure("invalid_password", "Password must be at least 8 characters.");
|
return ServiceResult<UserSummary>.Failure("invalid_password", "Password must be at least 8 characters.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lock (m_Gate)
|
||||||
|
{
|
||||||
var username = request.Username.Trim();
|
var username = request.Username.Trim();
|
||||||
var normalizedUsername = NormalizeUsername(username);
|
var normalizedUsername = NormalizeUsername(username);
|
||||||
if (m_Db.Users.Any(u => u.UsernameNormalized == normalizedUsername))
|
if (m_UserIdsByUsername.ContainsKey(normalizedUsername))
|
||||||
{
|
{
|
||||||
return ServiceResult<UserSummary>.Failure("duplicate_username", "Username is already taken.");
|
return ServiceResult<UserSummary>.Failure("duplicate_username", "Username is already taken.");
|
||||||
}
|
}
|
||||||
@@ -62,11 +73,13 @@ public sealed class GameService : IGameService
|
|||||||
|
|
||||||
user.PasswordHash = m_PasswordHasher.HashPassword(user, request.Password);
|
user.PasswordHash = m_PasswordHasher.HashPassword(user, request.Password);
|
||||||
|
|
||||||
m_Db.Users.Add(user);
|
m_UsersById[user.Id] = user;
|
||||||
m_Db.SaveChanges();
|
m_UserIdsByUsername[user.UsernameNormalized] = user.Id;
|
||||||
|
|
||||||
|
PersistStateLocked();
|
||||||
return ServiceResult<UserSummary>.Success(ToUserSummary(user));
|
return ServiceResult<UserSummary>.Success(ToUserSummary(user));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public ServiceResult<(UserSummary User, string SessionToken)> Login(LoginRequest request)
|
public ServiceResult<(UserSummary User, string SessionToken)> Login(LoginRequest request)
|
||||||
{
|
{
|
||||||
@@ -75,13 +88,15 @@ public sealed class GameService : IGameService
|
|||||||
return ServiceResult<(UserSummary User, string SessionToken)>.Failure("invalid_credentials", "Invalid username or password.");
|
return ServiceResult<(UserSummary User, string SessionToken)>.Failure("invalid_credentials", "Invalid username or password.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lock (m_Gate)
|
||||||
|
{
|
||||||
var normalizedUsername = NormalizeUsername(request.Username.Trim());
|
var normalizedUsername = NormalizeUsername(request.Username.Trim());
|
||||||
var user = m_Db.Users.SingleOrDefault(u => u.UsernameNormalized == normalizedUsername);
|
if (!m_UserIdsByUsername.TryGetValue(normalizedUsername, out var userId))
|
||||||
if (user is null)
|
|
||||||
{
|
{
|
||||||
return ServiceResult<(UserSummary User, string SessionToken)>.Failure("invalid_credentials", "Invalid username or password.");
|
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);
|
var verification = m_PasswordHasher.VerifyHashedPassword(user, user.PasswordHash, request.Password);
|
||||||
if (verification == PasswordVerificationResult.Failed)
|
if (verification == PasswordVerificationResult.Failed)
|
||||||
{
|
{
|
||||||
@@ -93,40 +108,37 @@ public sealed class GameService : IGameService
|
|||||||
user.PasswordHash = m_PasswordHasher.HashPassword(user, request.Password);
|
user.PasswordHash = m_PasswordHasher.HashPassword(user, request.Password);
|
||||||
}
|
}
|
||||||
|
|
||||||
var session = new UserSession
|
var session = CreateSession(userId);
|
||||||
{
|
PersistStateLocked();
|
||||||
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));
|
return ServiceResult<(UserSummary User, string SessionToken)>.Success((ToUserSummary(user), session.Token));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public void Logout(string sessionToken)
|
public void Logout(string sessionToken)
|
||||||
{
|
{
|
||||||
var session = m_Db.Sessions.SingleOrDefault(s => s.Token == sessionToken);
|
lock (m_Gate)
|
||||||
if (session is null)
|
|
||||||
{
|
{
|
||||||
return;
|
if (m_SessionsByToken.Remove(sessionToken))
|
||||||
|
{
|
||||||
|
PersistStateLocked();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
m_Db.Sessions.Remove(session);
|
|
||||||
m_Db.SaveChanges();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public UserSummary? GetUserBySession(string sessionToken)
|
public UserSummary? GetUserBySession(string sessionToken)
|
||||||
{
|
{
|
||||||
var user = ResolveUser(sessionToken);
|
lock (m_Gate)
|
||||||
|
{
|
||||||
|
var user = ResolveUserLocked(sessionToken);
|
||||||
return user is null ? null : ToUserSummary(user);
|
return user is null ? null : ToUserSummary(user);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public ServiceResult<MeResponse> GetMe(string sessionToken)
|
public ServiceResult<MeResponse> GetMe(string sessionToken)
|
||||||
{
|
{
|
||||||
var user = ResolveUser(sessionToken);
|
lock (m_Gate)
|
||||||
|
{
|
||||||
|
var user = ResolveUserLocked(sessionToken);
|
||||||
if (user is null)
|
if (user is null)
|
||||||
{
|
{
|
||||||
return ServiceResult<MeResponse>.Failure("unauthorized", "You must be logged in.");
|
return ServiceResult<MeResponse>.Failure("unauthorized", "You must be logged in.");
|
||||||
@@ -135,11 +147,10 @@ public sealed class GameService : IGameService
|
|||||||
Guid? campaignId = null;
|
Guid? campaignId = null;
|
||||||
if (user.ActiveCharacterId is Guid activeCharacterId)
|
if (user.ActiveCharacterId is Guid activeCharacterId)
|
||||||
{
|
{
|
||||||
var activeCharacter = m_Db.Characters.AsNoTracking().SingleOrDefault(c => c.Id == activeCharacterId);
|
if (!m_CharactersById.TryGetValue(activeCharacterId, out var activeCharacter))
|
||||||
if (activeCharacter is null)
|
|
||||||
{
|
{
|
||||||
user.ActiveCharacterId = null;
|
user.ActiveCharacterId = null;
|
||||||
m_Db.SaveChanges();
|
PersistStateLocked();
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -149,6 +160,7 @@ public sealed class GameService : IGameService
|
|||||||
|
|
||||||
return ServiceResult<MeResponse>.Success(new MeResponse(ToUserSummary(user), user.ActiveCharacterId, campaignId));
|
return ServiceResult<MeResponse>.Success(new MeResponse(ToUserSummary(user), user.ActiveCharacterId, campaignId));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public ServiceResult<CampaignSummary> CreateCampaign(string sessionToken, CreateCampaignRequest request)
|
public ServiceResult<CampaignSummary> CreateCampaign(string sessionToken, CreateCampaignRequest request)
|
||||||
{
|
{
|
||||||
@@ -163,7 +175,9 @@ public sealed class GameService : IGameService
|
|||||||
return ServiceResult<CampaignSummary>.Failure("invalid_ruleset", "Unknown ruleset.");
|
return ServiceResult<CampaignSummary>.Failure("invalid_ruleset", "Unknown ruleset.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var user = ResolveUser(sessionToken);
|
lock (m_Gate)
|
||||||
|
{
|
||||||
|
var user = ResolveUserLocked(sessionToken);
|
||||||
if (user is null)
|
if (user is null)
|
||||||
{
|
{
|
||||||
return ServiceResult<CampaignSummary>.Failure("unauthorized", "You must be logged in.");
|
return ServiceResult<CampaignSummary>.Failure("unauthorized", "You must be logged in.");
|
||||||
@@ -178,70 +192,75 @@ public sealed class GameService : IGameService
|
|||||||
Version = 1
|
Version = 1
|
||||||
};
|
};
|
||||||
|
|
||||||
m_Db.Campaigns.Add(campaign);
|
m_CampaignsById[campaign.Id] = campaign;
|
||||||
m_Db.SaveChanges();
|
PersistStateLocked();
|
||||||
|
|
||||||
return ServiceResult<CampaignSummary>.Success(ToCampaignSummary(campaign));
|
return ServiceResult<CampaignSummary>.Success(ToCampaignSummary(campaign));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public ServiceResult<IReadOnlyList<CampaignSummary>> GetCampaigns(string sessionToken)
|
public ServiceResult<IReadOnlyList<CampaignSummary>> GetCampaigns(string sessionToken)
|
||||||
{
|
{
|
||||||
var user = ResolveUser(sessionToken);
|
lock (m_Gate)
|
||||||
|
{
|
||||||
|
var user = ResolveUserLocked(sessionToken);
|
||||||
if (user is null)
|
if (user is null)
|
||||||
{
|
{
|
||||||
return ServiceResult<IReadOnlyList<CampaignSummary>>.Failure("unauthorized", "You must be logged in.");
|
return ServiceResult<IReadOnlyList<CampaignSummary>>.Failure("unauthorized", "You must be logged in.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var campaignIds = m_Db.Campaigns
|
var campaignIds = new HashSet<Guid>(m_CampaignsById.Values.Where(c => c.GmUserId == user.Id).Select(c => c.Id));
|
||||||
.Where(c => c.GmUserId == user.Id)
|
foreach (var character in m_CharactersById.Values.Where(c => c.OwnerUserId == user.Id))
|
||||||
.Select(c => c.Id)
|
{
|
||||||
.Union(m_Db.Characters.Where(c => c.OwnerUserId == user.Id).Select(c => c.CampaignId))
|
campaignIds.Add(character.CampaignId);
|
||||||
.Distinct();
|
}
|
||||||
|
|
||||||
var campaigns = m_Db.Campaigns
|
var results = campaignIds
|
||||||
.Where(c => campaignIds.Contains(c.Id))
|
.Select(id => m_CampaignsById[id])
|
||||||
.OrderBy(c => c.Name)
|
.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase)
|
||||||
.AsNoTracking()
|
.Select(ToCampaignSummary)
|
||||||
.ToList();
|
.ToArray();
|
||||||
|
|
||||||
return ServiceResult<IReadOnlyList<CampaignSummary>>.Success(campaigns.Select(ToCampaignSummary).ToArray());
|
return ServiceResult<IReadOnlyList<CampaignSummary>>.Success(results);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public ServiceResult<CampaignDetails> GetCampaign(string sessionToken, Guid campaignId)
|
public ServiceResult<CampaignDetails> GetCampaign(string sessionToken, Guid campaignId)
|
||||||
{
|
{
|
||||||
var context = ResolveContext(sessionToken, campaignId);
|
lock (m_Gate)
|
||||||
|
{
|
||||||
|
var context = ResolveContextLocked(sessionToken, campaignId);
|
||||||
if (!context.Succeeded)
|
if (!context.Succeeded)
|
||||||
{
|
{
|
||||||
return ServiceResult<CampaignDetails>.Failure(context.Error!.Code, context.Error.Message);
|
return ServiceResult<CampaignDetails>.Failure(context.Error!.Code, context.Error.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
var (user, campaign) = context.Value!;
|
var (user, campaign) = context.Value!;
|
||||||
var gm = m_Db.Users.AsNoTracking().Single(u => u.Id == campaign.GmUserId);
|
var gm = m_UsersById[campaign.GmUserId];
|
||||||
var isGm = campaign.GmUserId == user.Id;
|
var isGm = campaign.GmUserId == user.Id;
|
||||||
|
|
||||||
var charactersQuery = m_Db.Characters.AsNoTracking().Where(c => c.CampaignId == campaign.Id);
|
var characters = m_CharactersById.Values
|
||||||
if (!isGm)
|
.Where(c => c.CampaignId == campaign.Id)
|
||||||
{
|
.Where(c => isGm || c.OwnerUserId == user.Id)
|
||||||
charactersQuery = charactersQuery.Where(c => c.OwnerUserId == user.Id);
|
.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase)
|
||||||
}
|
.Select(ToCharacterSummary)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
var characters = charactersQuery
|
var visibleCharacterIds = characters.Select(c => c.Id).ToHashSet();
|
||||||
.OrderBy(c => c.Name)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
var characterIds = characters.Select(c => c.Id).ToList();
|
var skills = m_SkillsById.Values
|
||||||
var skills = m_Db.Skills.AsNoTracking()
|
.Where(s => visibleCharacterIds.Contains(s.CharacterId))
|
||||||
.Where(s => characterIds.Contains(s.CharacterId))
|
.OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase)
|
||||||
.OrderBy(s => s.Name)
|
.Select(ToSkillSummary)
|
||||||
.ToList();
|
.ToArray();
|
||||||
|
|
||||||
return ServiceResult<CampaignDetails>.Success(new CampaignDetails(
|
return ServiceResult<CampaignDetails>.Success(new CampaignDetails(
|
||||||
campaign.Id,
|
campaign.Id,
|
||||||
campaign.Name,
|
campaign.Name,
|
||||||
DiceRules.ToRulesetId(campaign.Ruleset),
|
DiceRules.ToRulesetId(campaign.Ruleset),
|
||||||
ToUserSummary(gm),
|
ToUserSummary(gm),
|
||||||
characters.Select(ToCharacterSummary).ToArray(),
|
characters,
|
||||||
skills.Select(ToSkillSummary).ToArray()));
|
skills));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public ServiceResult<CharacterSummary> CreateCharacter(string sessionToken, CreateCharacterRequest request)
|
public ServiceResult<CharacterSummary> CreateCharacter(string sessionToken, CreateCharacterRequest request)
|
||||||
@@ -251,14 +270,15 @@ public sealed class GameService : IGameService
|
|||||||
return ServiceResult<CharacterSummary>.Failure("invalid_character_name", "Character name is required.");
|
return ServiceResult<CharacterSummary>.Failure("invalid_character_name", "Character name is required.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var user = ResolveUser(sessionToken);
|
lock (m_Gate)
|
||||||
|
{
|
||||||
|
var user = ResolveUserLocked(sessionToken);
|
||||||
if (user is null)
|
if (user is null)
|
||||||
{
|
{
|
||||||
return ServiceResult<CharacterSummary>.Failure("unauthorized", "You must be logged in.");
|
return ServiceResult<CharacterSummary>.Failure("unauthorized", "You must be logged in.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var campaign = m_Db.Campaigns.SingleOrDefault(c => c.Id == request.CampaignId);
|
if (!m_CampaignsById.ContainsKey(request.CampaignId))
|
||||||
if (campaign is null)
|
|
||||||
{
|
{
|
||||||
return ServiceResult<CharacterSummary>.Failure("campaign_not_found", "Campaign was not found.");
|
return ServiceResult<CharacterSummary>.Failure("campaign_not_found", "Campaign was not found.");
|
||||||
}
|
}
|
||||||
@@ -267,16 +287,17 @@ public sealed class GameService : IGameService
|
|||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
OwnerUserId = user.Id,
|
OwnerUserId = user.Id,
|
||||||
CampaignId = campaign.Id,
|
CampaignId = request.CampaignId,
|
||||||
Name = request.Name.Trim()
|
Name = request.Name.Trim()
|
||||||
};
|
};
|
||||||
|
|
||||||
m_Db.Characters.Add(character);
|
m_CharactersById[character.Id] = character;
|
||||||
TouchCampaign(campaign);
|
TouchCampaignLocked(character.CampaignId);
|
||||||
m_Db.SaveChanges();
|
|
||||||
|
|
||||||
|
PersistStateLocked();
|
||||||
return ServiceResult<CharacterSummary>.Success(ToCharacterSummary(character));
|
return ServiceResult<CharacterSummary>.Success(ToCharacterSummary(character));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public ServiceResult<CharacterSummary> UpdateCharacter(string sessionToken, Guid characterId, UpdateCharacterRequest request)
|
public ServiceResult<CharacterSummary> UpdateCharacter(string sessionToken, Guid characterId, UpdateCharacterRequest request)
|
||||||
{
|
{
|
||||||
@@ -285,25 +306,25 @@ public sealed class GameService : IGameService
|
|||||||
return ServiceResult<CharacterSummary>.Failure("invalid_character_name", "Character name is required.");
|
return ServiceResult<CharacterSummary>.Failure("invalid_character_name", "Character name is required.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var user = ResolveUser(sessionToken);
|
lock (m_Gate)
|
||||||
|
{
|
||||||
|
var user = ResolveUserLocked(sessionToken);
|
||||||
if (user is null)
|
if (user is null)
|
||||||
{
|
{
|
||||||
return ServiceResult<CharacterSummary>.Failure("unauthorized", "You must be logged in.");
|
return ServiceResult<CharacterSummary>.Failure("unauthorized", "You must be logged in.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var character = m_Db.Characters.SingleOrDefault(c => c.Id == characterId);
|
if (!m_CharactersById.TryGetValue(characterId, out var character))
|
||||||
if (character is null)
|
|
||||||
{
|
{
|
||||||
return ServiceResult<CharacterSummary>.Failure("character_not_found", "Character was not found.");
|
return ServiceResult<CharacterSummary>.Failure("character_not_found", "Character was not found.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var sourceCampaign = m_Db.Campaigns.Single(c => c.Id == character.CampaignId);
|
if (!m_CampaignsById.TryGetValue(request.CampaignId, out var targetCampaign))
|
||||||
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.");
|
return ServiceResult<CharacterSummary>.Failure("campaign_not_found", "Campaign was not found.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var sourceCampaign = m_CampaignsById[character.CampaignId];
|
||||||
var isOwner = character.OwnerUserId == user.Id;
|
var isOwner = character.OwnerUserId == user.Id;
|
||||||
var isSourceGm = sourceCampaign.GmUserId == user.Id;
|
var isSourceGm = sourceCampaign.GmUserId == user.Id;
|
||||||
var isTargetGm = targetCampaign.GmUserId == user.Id;
|
var isTargetGm = targetCampaign.GmUserId == user.Id;
|
||||||
@@ -312,31 +333,32 @@ public sealed class GameService : IGameService
|
|||||||
return ServiceResult<CharacterSummary>.Failure("forbidden", "Only the owner or GM can edit this character.");
|
return ServiceResult<CharacterSummary>.Failure("forbidden", "Only the owner or GM can edit this character.");
|
||||||
}
|
}
|
||||||
|
|
||||||
character.Name = request.Name.Trim();
|
|
||||||
var sourceCampaignId = character.CampaignId;
|
var sourceCampaignId = character.CampaignId;
|
||||||
character.CampaignId = targetCampaign.Id;
|
character.Name = request.Name.Trim();
|
||||||
|
character.CampaignId = request.CampaignId;
|
||||||
|
|
||||||
TouchCampaign(sourceCampaign);
|
TouchCampaignLocked(sourceCampaignId);
|
||||||
if (sourceCampaignId != targetCampaign.Id)
|
if (sourceCampaignId != character.CampaignId)
|
||||||
{
|
{
|
||||||
TouchCampaign(targetCampaign);
|
TouchCampaignLocked(character.CampaignId);
|
||||||
}
|
}
|
||||||
|
|
||||||
m_Db.SaveChanges();
|
PersistStateLocked();
|
||||||
|
|
||||||
return ServiceResult<CharacterSummary>.Success(ToCharacterSummary(character));
|
return ServiceResult<CharacterSummary>.Success(ToCharacterSummary(character));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public ServiceResult<bool> ActivateCharacter(string sessionToken, Guid characterId)
|
public ServiceResult<bool> ActivateCharacter(string sessionToken, Guid characterId)
|
||||||
{
|
{
|
||||||
var user = ResolveUser(sessionToken);
|
lock (m_Gate)
|
||||||
|
{
|
||||||
|
var user = ResolveUserLocked(sessionToken);
|
||||||
if (user is null)
|
if (user is null)
|
||||||
{
|
{
|
||||||
return ServiceResult<bool>.Failure("unauthorized", "You must be logged in.");
|
return ServiceResult<bool>.Failure("unauthorized", "You must be logged in.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var character = m_Db.Characters.AsNoTracking().SingleOrDefault(c => c.Id == characterId);
|
if (!m_CharactersById.TryGetValue(characterId, out var character))
|
||||||
if (character is null)
|
|
||||||
{
|
{
|
||||||
return ServiceResult<bool>.Failure("character_not_found", "Character was not found.");
|
return ServiceResult<bool>.Failure("character_not_found", "Character was not found.");
|
||||||
}
|
}
|
||||||
@@ -347,39 +369,34 @@ public sealed class GameService : IGameService
|
|||||||
}
|
}
|
||||||
|
|
||||||
user.ActiveCharacterId = character.Id;
|
user.ActiveCharacterId = character.Id;
|
||||||
m_Db.SaveChanges();
|
PersistStateLocked();
|
||||||
|
|
||||||
return ServiceResult<bool>.Success(true);
|
return ServiceResult<bool>.Success(true);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public ServiceResult<IReadOnlyList<CharacterSummary>> GetCurrentCampaignCharacters(string sessionToken)
|
public ServiceResult<IReadOnlyList<CharacterSummary>> GetCurrentCampaignCharacters(string sessionToken)
|
||||||
{
|
{
|
||||||
var user = ResolveUser(sessionToken);
|
lock (m_Gate)
|
||||||
|
{
|
||||||
|
var user = ResolveUserLocked(sessionToken);
|
||||||
if (user is null)
|
if (user is null)
|
||||||
{
|
{
|
||||||
return ServiceResult<IReadOnlyList<CharacterSummary>>.Failure("unauthorized", "You must be logged in.");
|
return ServiceResult<IReadOnlyList<CharacterSummary>>.Failure("unauthorized", "You must be logged in.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.ActiveCharacterId is null)
|
if (!TryGetCurrentCampaignIdLocked(user, out var campaignId))
|
||||||
{
|
{
|
||||||
return ServiceResult<IReadOnlyList<CharacterSummary>>.Failure("no_active_character", "No active character is selected.");
|
return ServiceResult<IReadOnlyList<CharacterSummary>>.Failure("no_active_character", "No active character is selected.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var activeCharacter = m_Db.Characters.AsNoTracking().SingleOrDefault(c => c.Id == user.ActiveCharacterId.Value);
|
var characters = m_CharactersById.Values
|
||||||
if (activeCharacter is null)
|
.Where(c => c.CampaignId == campaignId && c.OwnerUserId == user.Id)
|
||||||
{
|
.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase)
|
||||||
user.ActiveCharacterId = null;
|
.Select(ToCharacterSummary)
|
||||||
m_Db.SaveChanges();
|
.ToArray();
|
||||||
return ServiceResult<IReadOnlyList<CharacterSummary>>.Failure("no_active_character", "No active character is selected.");
|
|
||||||
|
return ServiceResult<IReadOnlyList<CharacterSummary>>.Success(characters);
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
public ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, CreateSkillRequest request)
|
||||||
@@ -389,20 +406,21 @@ public sealed class GameService : IGameService
|
|||||||
return ServiceResult<SkillSummary>.Failure("invalid_skill_name", "Skill name is required.");
|
return ServiceResult<SkillSummary>.Failure("invalid_skill_name", "Skill name is required.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var user = ResolveUser(sessionToken);
|
lock (m_Gate)
|
||||||
|
{
|
||||||
|
var user = ResolveUserLocked(sessionToken);
|
||||||
if (user is null)
|
if (user is null)
|
||||||
{
|
{
|
||||||
return ServiceResult<SkillSummary>.Failure("unauthorized", "You must be logged in.");
|
return ServiceResult<SkillSummary>.Failure("unauthorized", "You must be logged in.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var character = m_Db.Characters.SingleOrDefault(c => c.Id == characterId);
|
if (!m_CharactersById.TryGetValue(characterId, out var character))
|
||||||
if (character is null)
|
|
||||||
{
|
{
|
||||||
return ServiceResult<SkillSummary>.Failure("character_not_found", "Character was not found.");
|
return ServiceResult<SkillSummary>.Failure("character_not_found", "Character was not found.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var campaign = m_Db.Campaigns.Single(c => c.Id == character.CampaignId);
|
var campaign = m_CampaignsById[character.CampaignId];
|
||||||
if (!CanEditCharacter(user.Id, character, campaign))
|
if (!CanEditCharacterLocked(user.Id, character, campaign))
|
||||||
{
|
{
|
||||||
return ServiceResult<SkillSummary>.Failure("forbidden", "Only the owner or GM can edit skills.");
|
return ServiceResult<SkillSummary>.Failure("forbidden", "Only the owner or GM can edit skills.");
|
||||||
}
|
}
|
||||||
@@ -421,12 +439,13 @@ public sealed class GameService : IGameService
|
|||||||
DiceRollDefinition = expressionValidation.Value!.Canonical
|
DiceRollDefinition = expressionValidation.Value!.Canonical
|
||||||
};
|
};
|
||||||
|
|
||||||
m_Db.Skills.Add(skill);
|
m_SkillsById[skill.Id] = skill;
|
||||||
TouchCampaign(campaign);
|
TouchCampaignLocked(campaign.Id);
|
||||||
m_Db.SaveChanges();
|
|
||||||
|
|
||||||
|
PersistStateLocked();
|
||||||
return ServiceResult<SkillSummary>.Success(ToSkillSummary(skill));
|
return ServiceResult<SkillSummary>.Success(ToSkillSummary(skill));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, UpdateSkillRequest request)
|
public ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, UpdateSkillRequest request)
|
||||||
{
|
{
|
||||||
@@ -435,21 +454,22 @@ public sealed class GameService : IGameService
|
|||||||
return ServiceResult<SkillSummary>.Failure("invalid_skill_name", "Skill name is required.");
|
return ServiceResult<SkillSummary>.Failure("invalid_skill_name", "Skill name is required.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var user = ResolveUser(sessionToken);
|
lock (m_Gate)
|
||||||
|
{
|
||||||
|
var user = ResolveUserLocked(sessionToken);
|
||||||
if (user is null)
|
if (user is null)
|
||||||
{
|
{
|
||||||
return ServiceResult<SkillSummary>.Failure("unauthorized", "You must be logged in.");
|
return ServiceResult<SkillSummary>.Failure("unauthorized", "You must be logged in.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var skill = m_Db.Skills.SingleOrDefault(s => s.Id == skillId);
|
if (!m_SkillsById.TryGetValue(skillId, out var skill))
|
||||||
if (skill is null)
|
|
||||||
{
|
{
|
||||||
return ServiceResult<SkillSummary>.Failure("skill_not_found", "Skill was not found.");
|
return ServiceResult<SkillSummary>.Failure("skill_not_found", "Skill was not found.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var character = m_Db.Characters.Single(c => c.Id == skill.CharacterId);
|
var character = m_CharactersById[skill.CharacterId];
|
||||||
var campaign = m_Db.Campaigns.Single(c => c.Id == character.CampaignId);
|
var campaign = m_CampaignsById[character.CampaignId];
|
||||||
if (!CanEditCharacter(user.Id, character, campaign))
|
if (!CanEditCharacterLocked(user.Id, character, campaign))
|
||||||
{
|
{
|
||||||
return ServiceResult<SkillSummary>.Failure("forbidden", "Only the owner or GM can edit skills.");
|
return ServiceResult<SkillSummary>.Failure("forbidden", "Only the owner or GM can edit skills.");
|
||||||
}
|
}
|
||||||
@@ -462,30 +482,31 @@ public sealed class GameService : IGameService
|
|||||||
|
|
||||||
skill.Name = request.Name.Trim();
|
skill.Name = request.Name.Trim();
|
||||||
skill.DiceRollDefinition = expressionValidation.Value!.Canonical;
|
skill.DiceRollDefinition = expressionValidation.Value!.Canonical;
|
||||||
|
TouchCampaignLocked(campaign.Id);
|
||||||
|
|
||||||
TouchCampaign(campaign);
|
PersistStateLocked();
|
||||||
m_Db.SaveChanges();
|
|
||||||
|
|
||||||
return ServiceResult<SkillSummary>.Success(ToSkillSummary(skill));
|
return ServiceResult<SkillSummary>.Success(ToSkillSummary(skill));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, RollSkillRequest request)
|
public ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, RollSkillRequest request)
|
||||||
{
|
{
|
||||||
var user = ResolveUser(sessionToken);
|
lock (m_Gate)
|
||||||
|
{
|
||||||
|
var user = ResolveUserLocked(sessionToken);
|
||||||
if (user is null)
|
if (user is null)
|
||||||
{
|
{
|
||||||
return ServiceResult<RollResult>.Failure("unauthorized", "You must be logged in.");
|
return ServiceResult<RollResult>.Failure("unauthorized", "You must be logged in.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var skill = m_Db.Skills.SingleOrDefault(s => s.Id == skillId);
|
if (!m_SkillsById.TryGetValue(skillId, out var skill))
|
||||||
if (skill is null)
|
|
||||||
{
|
{
|
||||||
return ServiceResult<RollResult>.Failure("skill_not_found", "Skill was not found.");
|
return ServiceResult<RollResult>.Failure("skill_not_found", "Skill was not found.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var character = m_Db.Characters.Single(c => c.Id == skill.CharacterId);
|
var character = m_CharactersById[skill.CharacterId];
|
||||||
var campaign = m_Db.Campaigns.Single(c => c.Id == character.CampaignId);
|
var campaign = m_CampaignsById[character.CampaignId];
|
||||||
if (!CanEditCharacter(user.Id, character, campaign))
|
if (!CanEditCharacterLocked(user.Id, character, campaign))
|
||||||
{
|
{
|
||||||
return ServiceResult<RollResult>.Failure("forbidden", "Only the owner or GM can roll this skill.");
|
return ServiceResult<RollResult>.Failure("forbidden", "Only the owner or GM can roll this skill.");
|
||||||
}
|
}
|
||||||
@@ -516,39 +537,42 @@ public sealed class GameService : IGameService
|
|||||||
TimestampUtc = DateTimeOffset.UtcNow
|
TimestampUtc = DateTimeOffset.UtcNow
|
||||||
};
|
};
|
||||||
|
|
||||||
m_Db.RollLogEntries.Add(entry);
|
m_RollLog.Add(entry);
|
||||||
TouchCampaign(campaign);
|
TouchCampaignLocked(campaign.Id);
|
||||||
m_Db.SaveChanges();
|
|
||||||
|
|
||||||
|
PersistStateLocked();
|
||||||
return ServiceResult<RollResult>.Success(ToRollResult(entry));
|
return ServiceResult<RollResult>.Success(ToRollResult(entry));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public ServiceResult<IReadOnlyList<CampaignLogEntry>> GetCampaignLog(string sessionToken, Guid campaignId)
|
public ServiceResult<IReadOnlyList<CampaignLogEntry>> GetCampaignLog(string sessionToken, Guid campaignId)
|
||||||
{
|
{
|
||||||
var context = ResolveContext(sessionToken, campaignId);
|
lock (m_Gate)
|
||||||
|
{
|
||||||
|
var context = ResolveContextLocked(sessionToken, campaignId);
|
||||||
if (!context.Succeeded)
|
if (!context.Succeeded)
|
||||||
{
|
{
|
||||||
return ServiceResult<IReadOnlyList<CampaignLogEntry>>.Failure(context.Error!.Code, context.Error.Message);
|
return ServiceResult<IReadOnlyList<CampaignLogEntry>>.Failure(context.Error!.Code, context.Error.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
var (user, campaign) = context.Value!;
|
var (user, campaign) = context.Value!;
|
||||||
var isGm = campaign.GmUserId == user.Id;
|
var entries = m_RollLog
|
||||||
|
|
||||||
var entries = m_Db.RollLogEntries
|
|
||||||
.AsNoTracking()
|
|
||||||
.Where(r => r.CampaignId == campaign.Id)
|
.Where(r => r.CampaignId == campaign.Id)
|
||||||
.Where(r => r.Visibility == RollVisibility.Public || r.RollerUserId == user.Id || isGm)
|
.Where(r => r.Visibility == RollVisibility.Public || r.RollerUserId == user.Id || campaign.GmUserId == user.Id)
|
||||||
.ToList()
|
|
||||||
.OrderBy(r => r.TimestampUtc)
|
.OrderBy(r => r.TimestampUtc)
|
||||||
.ThenBy(r => r.Id)
|
.ThenBy(r => r.Id)
|
||||||
.ToList();
|
.Select(ToLogEntry)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
return ServiceResult<IReadOnlyList<CampaignLogEntry>>.Success(entries.Select(ToLogEntry).ToArray());
|
return ServiceResult<IReadOnlyList<CampaignLogEntry>>.Success(entries);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public ServiceResult<long> GetCampaignVersion(string sessionToken, Guid campaignId)
|
public ServiceResult<long> GetCampaignVersion(string sessionToken, Guid campaignId)
|
||||||
{
|
{
|
||||||
var context = ResolveContext(sessionToken, campaignId);
|
lock (m_Gate)
|
||||||
|
{
|
||||||
|
var context = ResolveContextLocked(sessionToken, campaignId);
|
||||||
if (!context.Succeeded)
|
if (!context.Succeeded)
|
||||||
{
|
{
|
||||||
return ServiceResult<long>.Failure(context.Error!.Code, context.Error.Message);
|
return ServiceResult<long>.Failure(context.Error!.Code, context.Error.Message);
|
||||||
@@ -556,6 +580,7 @@ public sealed class GameService : IGameService
|
|||||||
|
|
||||||
return ServiceResult<long>.Success(context.Value!.Campaign.Version);
|
return ServiceResult<long>.Success(context.Value!.Campaign.Version);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private (int Total, string Breakdown) ComputeRoll(DiceExpression expression)
|
private (int Total, string Breakdown) ComputeRoll(DiceExpression expression)
|
||||||
{
|
{
|
||||||
@@ -588,21 +613,20 @@ public sealed class GameService : IGameService
|
|||||||
return ServiceResult<RollVisibility>.Failure("invalid_visibility", "Visibility must be 'public' or 'private'.");
|
return ServiceResult<RollVisibility>.Failure("invalid_visibility", "Visibility must be 'public' or 'private'.");
|
||||||
}
|
}
|
||||||
|
|
||||||
private ServiceResult<(UserAccount User, Campaign Campaign)> ResolveContext(string sessionToken, Guid campaignId)
|
private ServiceResult<(UserAccount User, Campaign Campaign)> ResolveContextLocked(string sessionToken, Guid campaignId)
|
||||||
{
|
{
|
||||||
var user = ResolveUser(sessionToken);
|
var user = ResolveUserLocked(sessionToken);
|
||||||
if (user is null)
|
if (user is null)
|
||||||
{
|
{
|
||||||
return ServiceResult<(UserAccount User, Campaign Campaign)>.Failure("unauthorized", "You must be logged in.");
|
return ServiceResult<(UserAccount User, Campaign Campaign)>.Failure("unauthorized", "You must be logged in.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var campaign = m_Db.Campaigns.SingleOrDefault(c => c.Id == campaignId);
|
if (!m_CampaignsById.TryGetValue(campaignId, out var campaign))
|
||||||
if (campaign is null)
|
|
||||||
{
|
{
|
||||||
return ServiceResult<(UserAccount User, Campaign Campaign)>.Failure("campaign_not_found", "Campaign was not found.");
|
return ServiceResult<(UserAccount User, Campaign Campaign)>.Failure("campaign_not_found", "Campaign was not found.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!CanViewCampaign(user.Id, campaign.Id, campaign.GmUserId))
|
if (!CanViewCampaignLocked(user.Id, campaign.Id))
|
||||||
{
|
{
|
||||||
return ServiceResult<(UserAccount User, Campaign Campaign)>.Failure("forbidden", "You are not a participant in this campaign.");
|
return ServiceResult<(UserAccount User, Campaign Campaign)>.Failure("forbidden", "You are not a participant in this campaign.");
|
||||||
}
|
}
|
||||||
@@ -610,47 +634,6 @@ public sealed class GameService : IGameService
|
|||||||
return ServiceResult<(UserAccount User, Campaign Campaign)>.Success((user, campaign));
|
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)
|
private static UserSummary ToUserSummary(UserAccount user)
|
||||||
{
|
{
|
||||||
return new UserSummary(user.Id, user.Username, user.DisplayName);
|
return new UserSummary(user.Id, user.Username, user.DisplayName);
|
||||||
@@ -698,4 +681,247 @@ public sealed class GameService : IGameService
|
|||||||
entry.Breakdown,
|
entry.Breakdown,
|
||||||
entry.TimestampUtc);
|
entry.TimestampUtc);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private bool CanEditCharacterLocked(Guid actorUserId, Character character, Campaign campaign)
|
||||||
|
{
|
||||||
|
return character.OwnerUserId == actorUserId || campaign.GmUserId == actorUserId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool CanViewCampaignLocked(Guid userId, Guid campaignId)
|
||||||
|
{
|
||||||
|
var campaign = m_CampaignsById[campaignId];
|
||||||
|
if (campaign.GmUserId == userId)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return m_CharactersById.Values.Any(c => c.CampaignId == campaignId && c.OwnerUserId == userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryGetCurrentCampaignIdLocked(UserAccount user, out Guid campaignId)
|
||||||
|
{
|
||||||
|
campaignId = Guid.Empty;
|
||||||
|
if (user.ActiveCharacterId is not Guid activeCharacterId)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!m_CharactersById.TryGetValue(activeCharacterId, out var character))
|
||||||
|
{
|
||||||
|
user.ActiveCharacterId = null;
|
||||||
|
PersistStateLocked();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
campaignId = character.CampaignId;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private UserSession CreateSession(Guid userId)
|
||||||
|
{
|
||||||
|
var token = Guid.NewGuid().ToString("N");
|
||||||
|
var session = new UserSession
|
||||||
|
{
|
||||||
|
Token = token,
|
||||||
|
UserId = userId,
|
||||||
|
CreatedAtUtc = DateTimeOffset.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
m_SessionsByToken[token] = session;
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
private UserAccount? ResolveUserLocked(string sessionToken)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(sessionToken))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!m_SessionsByToken.TryGetValue(sessionToken, out var session))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return m_UsersById.GetValueOrDefault(session.UserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TouchCampaignLocked(Guid campaignId)
|
||||||
|
{
|
||||||
|
if (m_CampaignsById.TryGetValue(campaignId, out var campaign))
|
||||||
|
{
|
||||||
|
campaign.Version += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void LoadStateFromDatabase()
|
||||||
|
{
|
||||||
|
using var db = m_DbContextFactory.CreateDbContext();
|
||||||
|
var users = db.Users.AsNoTracking().ToList();
|
||||||
|
var sessions = db.Sessions.AsNoTracking().ToList();
|
||||||
|
var campaigns = db.Campaigns.AsNoTracking().ToList();
|
||||||
|
var characters = db.Characters.AsNoTracking().ToList();
|
||||||
|
var skills = db.Skills.AsNoTracking().ToList();
|
||||||
|
var logEntries = db.RollLogEntries.AsNoTracking().ToList()
|
||||||
|
.OrderBy(x => x.TimestampUtc)
|
||||||
|
.ThenBy(x => x.Id)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
lock (m_Gate)
|
||||||
|
{
|
||||||
|
m_UsersById.Clear();
|
||||||
|
m_UserIdsByUsername.Clear();
|
||||||
|
m_SessionsByToken.Clear();
|
||||||
|
m_CampaignsById.Clear();
|
||||||
|
m_CharactersById.Clear();
|
||||||
|
m_SkillsById.Clear();
|
||||||
|
m_RollLog.Clear();
|
||||||
|
|
||||||
|
foreach (var user in users)
|
||||||
|
{
|
||||||
|
var normalizedUsername = string.IsNullOrWhiteSpace(user.UsernameNormalized)
|
||||||
|
? NormalizeUsername(user.Username)
|
||||||
|
: user.UsernameNormalized;
|
||||||
|
|
||||||
|
var storedUser = new UserAccount
|
||||||
|
{
|
||||||
|
Id = user.Id,
|
||||||
|
Username = user.Username,
|
||||||
|
UsernameNormalized = normalizedUsername,
|
||||||
|
PasswordHash = user.PasswordHash,
|
||||||
|
DisplayName = user.DisplayName,
|
||||||
|
ActiveCharacterId = user.ActiveCharacterId
|
||||||
|
};
|
||||||
|
m_UsersById[storedUser.Id] = storedUser;
|
||||||
|
|
||||||
|
m_UserIdsByUsername[normalizedUsername] = storedUser.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var session in sessions)
|
||||||
|
{
|
||||||
|
if (m_UsersById.ContainsKey(session.UserId))
|
||||||
|
{
|
||||||
|
m_SessionsByToken[session.Token] = CloneSession(session);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var campaign in campaigns)
|
||||||
|
{
|
||||||
|
m_CampaignsById[campaign.Id] = CloneCampaign(campaign);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var character in characters)
|
||||||
|
{
|
||||||
|
m_CharactersById[character.Id] = CloneCharacter(character);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var skill in skills)
|
||||||
|
{
|
||||||
|
m_SkillsById[skill.Id] = CloneSkill(skill);
|
||||||
|
}
|
||||||
|
|
||||||
|
m_RollLog.AddRange(logEntries.Select(CloneRollLogEntry));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PersistStateLocked()
|
||||||
|
{
|
||||||
|
using var db = m_DbContextFactory.CreateDbContext();
|
||||||
|
using var transaction = db.Database.BeginTransaction();
|
||||||
|
|
||||||
|
db.RollLogEntries.ExecuteDelete();
|
||||||
|
db.Skills.ExecuteDelete();
|
||||||
|
db.Characters.ExecuteDelete();
|
||||||
|
db.Campaigns.ExecuteDelete();
|
||||||
|
db.Sessions.ExecuteDelete();
|
||||||
|
db.Users.ExecuteDelete();
|
||||||
|
|
||||||
|
db.Users.AddRange(m_UsersById.Values.Select(CloneUser));
|
||||||
|
db.Sessions.AddRange(m_SessionsByToken.Values.Select(CloneSession));
|
||||||
|
db.Campaigns.AddRange(m_CampaignsById.Values.Select(CloneCampaign));
|
||||||
|
db.Characters.AddRange(m_CharactersById.Values.Select(CloneCharacter));
|
||||||
|
db.Skills.AddRange(m_SkillsById.Values.Select(CloneSkill));
|
||||||
|
db.RollLogEntries.AddRange(m_RollLog.Select(CloneRollLogEntry));
|
||||||
|
|
||||||
|
db.SaveChanges();
|
||||||
|
transaction.Commit();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeUsername(string username)
|
||||||
|
{
|
||||||
|
return username.ToUpperInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static UserAccount CloneUser(UserAccount user)
|
||||||
|
{
|
||||||
|
return new UserAccount
|
||||||
|
{
|
||||||
|
Id = user.Id,
|
||||||
|
Username = user.Username,
|
||||||
|
UsernameNormalized = user.UsernameNormalized,
|
||||||
|
PasswordHash = user.PasswordHash,
|
||||||
|
DisplayName = user.DisplayName,
|
||||||
|
ActiveCharacterId = user.ActiveCharacterId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static UserSession CloneSession(UserSession session)
|
||||||
|
{
|
||||||
|
return new UserSession
|
||||||
|
{
|
||||||
|
Token = session.Token,
|
||||||
|
UserId = session.UserId,
|
||||||
|
CreatedAtUtc = session.CreatedAtUtc
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Campaign CloneCampaign(Campaign campaign)
|
||||||
|
{
|
||||||
|
return new Campaign
|
||||||
|
{
|
||||||
|
Id = campaign.Id,
|
||||||
|
GmUserId = campaign.GmUserId,
|
||||||
|
Name = campaign.Name,
|
||||||
|
Ruleset = campaign.Ruleset,
|
||||||
|
Version = campaign.Version
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Character CloneCharacter(Character character)
|
||||||
|
{
|
||||||
|
return new Character
|
||||||
|
{
|
||||||
|
Id = character.Id,
|
||||||
|
OwnerUserId = character.OwnerUserId,
|
||||||
|
CampaignId = character.CampaignId,
|
||||||
|
Name = character.Name
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Skill CloneSkill(Skill skill)
|
||||||
|
{
|
||||||
|
return new Skill
|
||||||
|
{
|
||||||
|
Id = skill.Id,
|
||||||
|
CharacterId = skill.CharacterId,
|
||||||
|
Name = skill.Name,
|
||||||
|
DiceRollDefinition = skill.DiceRollDefinition
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RollLogEntry CloneRollLogEntry(RollLogEntry entry)
|
||||||
|
{
|
||||||
|
return new RollLogEntry
|
||||||
|
{
|
||||||
|
Id = entry.Id,
|
||||||
|
CampaignId = entry.CampaignId,
|
||||||
|
CharacterId = entry.CharacterId,
|
||||||
|
SkillId = entry.SkillId,
|
||||||
|
RollerUserId = entry.RollerUserId,
|
||||||
|
Visibility = entry.Visibility,
|
||||||
|
Result = entry.Result,
|
||||||
|
Breakdown = entry.Breakdown,
|
||||||
|
TimestampUtc = entry.TimestampUtc
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user