Code cleanup

This commit is contained in:
2026-02-26 11:08:02 +01:00
parent 9036a3a157
commit e7114d8798
72 changed files with 1069 additions and 1604 deletions

View File

@@ -1,11 +1,8 @@
using Microsoft.AspNetCore.Mvc.Testing;
namespace RpgRoller.Tests; namespace RpgRoller.Tests;
public sealed class AuthApiTests : ApiTestBase public sealed class AuthApiTests : ApiTestBase
{ {
public AuthApiTests(WebApplicationFactory<Program> factory) public AuthApiTests(WebApplicationFactory<Program> factory) : base(factory)
: base(factory)
{ {
} }
@@ -13,7 +10,7 @@ public sealed class AuthApiTests : ApiTestBase
public async Task RegisterLoginAndMeFlow_WorksWithDuplicateUsernameGuard() public async Task RegisterLoginAndMeFlow_WorksWithDuplicateUsernameGuard()
{ {
using var factory = CreateFactory(4, 4, 4); using var factory = CreateFactory(4, 4, 4);
using var client = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); using var client = factory.CreateClient(new() { AllowAutoRedirect = false });
var registerResult = await RegisterAsync(client, "alice", "Password123", "Alice"); var registerResult = await RegisterAsync(client, "alice", "Password123", "Alice");
Assert.Equal("alice", registerResult.Username); Assert.Equal("alice", registerResult.Username);

View File

@@ -1,11 +1,8 @@
using Microsoft.AspNetCore.Mvc.Testing;
namespace RpgRoller.Tests; namespace RpgRoller.Tests;
public sealed class CampaignApiTests : ApiTestBase public sealed class CampaignApiTests : ApiTestBase
{ {
public CampaignApiTests(WebApplicationFactory<Program> factory) public CampaignApiTests(WebApplicationFactory<Program> factory) : base(factory)
: base(factory)
{ {
} }
@@ -13,44 +10,30 @@ public sealed class CampaignApiTests : ApiTestBase
public async Task CampaignCharacterAndSkillFlow_EnforcesRulesetValidation() public async Task CampaignCharacterAndSkillFlow_EnforcesRulesetValidation()
{ {
using var factory = CreateFactory(6, 6, 6); using var factory = CreateFactory(6, 6, 6);
using var gmClient = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); using var gmClient = factory.CreateClient(new() { AllowAutoRedirect = false });
await RegisterAsync(gmClient, "gm", "Password123", "Game Master"); await RegisterAsync(gmClient, "gm", "Password123", "Game Master");
await LoginAsync(gmClient, "gm", "Password123"); await LoginAsync(gmClient, "gm", "Password123");
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>( var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns", new("Alpha Campaign", "dnd5e"));
gmClient,
"/api/campaigns",
new CreateCampaignRequest("Alpha Campaign", "dnd5e"));
var gmCharacter = await PostAsync<CreateCharacterRequest, CharacterSummary>( var gmCharacter = await PostAsync<CreateCharacterRequest, CharacterSummary>(gmClient, "/api/characters", new("Arin", campaign.Id));
gmClient,
"/api/characters",
new CreateCharacterRequest("Arin", campaign.Id));
var activateResponse = await gmClient.PostAsync($"/api/characters/{gmCharacter.Id}/activate", null); var activateResponse = await gmClient.PostAsync($"/api/characters/{gmCharacter.Id}/activate", null);
Assert.Equal(HttpStatusCode.OK, activateResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, activateResponse.StatusCode);
var createdSkill = await PostAsync<CreateSkillRequest, SkillSummary>( var createdSkill = await PostAsync<CreateSkillRequest, SkillSummary>(gmClient, $"/api/characters/{gmCharacter.Id}/skills", new("Arcana", "2d12+2", 0, false));
gmClient,
$"/api/characters/{gmCharacter.Id}/skills",
new CreateSkillRequest("Arcana", "2d12+2", 0, false));
Assert.Equal("2d12+2", createdSkill.DiceRollDefinition); Assert.Equal("2d12+2", createdSkill.DiceRollDefinition);
Assert.Equal(0, createdSkill.WildDice); Assert.Equal(0, createdSkill.WildDice);
Assert.False(createdSkill.AllowFumble); Assert.False(createdSkill.AllowFumble);
var updatedSkill = await PutAsync<UpdateSkillRequest, SkillSummary>( var updatedSkill = await PutAsync<UpdateSkillRequest, SkillSummary>(gmClient, $"/api/skills/{createdSkill.Id}", new("Arcana Mastery", "2d12+3", 0, false));
gmClient,
$"/api/skills/{createdSkill.Id}",
new UpdateSkillRequest("Arcana Mastery", "2d12+3", 0, false));
Assert.Equal("Arcana Mastery", updatedSkill.Name); Assert.Equal("Arcana Mastery", updatedSkill.Name);
Assert.Equal("2d12+3", updatedSkill.DiceRollDefinition); Assert.Equal("2d12+3", updatedSkill.DiceRollDefinition);
Assert.Equal(0, updatedSkill.WildDice); Assert.Equal(0, updatedSkill.WildDice);
Assert.False(updatedSkill.AllowFumble); Assert.False(updatedSkill.AllowFumble);
var invalidSkill = await gmClient.PostAsJsonAsync( var invalidSkill = await gmClient.PostAsJsonAsync($"/api/characters/{gmCharacter.Id}/skills", new CreateSkillRequest("Broken", "5D+4", 0, false));
$"/api/characters/{gmCharacter.Id}/skills",
new CreateSkillRequest("Broken", "5D+4", 0, false));
Assert.Equal(HttpStatusCode.BadRequest, invalidSkill.StatusCode); Assert.Equal(HttpStatusCode.BadRequest, invalidSkill.StatusCode);
var details = await GetAsync<CampaignDetails>(gmClient, $"/api/campaigns/{campaign.Id}"); var details = await GetAsync<CampaignDetails>(gmClient, $"/api/campaigns/{campaign.Id}");
@@ -62,15 +45,9 @@ public sealed class CampaignApiTests : ApiTestBase
Assert.Single(currentCampaignCharacters); Assert.Single(currentCampaignCharacters);
Assert.Equal(gmCharacter.Id, currentCampaignCharacters[0].Id); Assert.Equal(gmCharacter.Id, currentCampaignCharacters[0].Id);
var otherCampaign = await PostAsync<CreateCampaignRequest, CampaignSummary>( var otherCampaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns", new("Beta Campaign", "d6"));
gmClient,
"/api/campaigns",
new CreateCampaignRequest("Beta Campaign", "d6"));
var updatedCharacter = await PutAsync<UpdateCharacterRequest, CharacterSummary>( var updatedCharacter = await PutAsync<UpdateCharacterRequest, CharacterSummary>(gmClient, $"/api/characters/{gmCharacter.Id}", new("Arin Updated", otherCampaign.Id));
gmClient,
$"/api/characters/{gmCharacter.Id}",
new UpdateCharacterRequest("Arin Updated", otherCampaign.Id));
Assert.Equal("Arin Updated", updatedCharacter.Name); Assert.Equal("Arin Updated", updatedCharacter.Name);
Assert.Equal(otherCampaign.Id, updatedCharacter.CampaignId); Assert.Equal(otherCampaign.Id, updatedCharacter.CampaignId);

View File

@@ -1,11 +1,8 @@
using Microsoft.AspNetCore.Mvc.Testing;
namespace RpgRoller.Tests; namespace RpgRoller.Tests;
public sealed class FrontendHostTests : ApiTestBase public sealed class FrontendHostTests : ApiTestBase
{ {
public FrontendHostTests(WebApplicationFactory<Program> factory) public FrontendHostTests(WebApplicationFactory<Program> factory) : base(factory)
: base(factory)
{ {
} }
@@ -13,7 +10,7 @@ public sealed class FrontendHostTests : ApiTestBase
public async Task RootPath_ServesBlazorFrontendShell() public async Task RootPath_ServesBlazorFrontendShell()
{ {
using var factory = CreateFactory(1); using var factory = CreateFactory(1);
using var client = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); using var client = factory.CreateClient(new() { AllowAutoRedirect = false });
var response = await client.GetAsync("/"); var response = await client.GetAsync("/");
Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal(HttpStatusCode.OK, response.StatusCode);

View File

@@ -1,11 +1,8 @@
using Microsoft.AspNetCore.Mvc.Testing;
namespace RpgRoller.Tests; namespace RpgRoller.Tests;
public sealed class RollVisibilityApiTests : ApiTestBase public sealed class RollVisibilityApiTests : ApiTestBase
{ {
public RollVisibilityApiTests(WebApplicationFactory<Program> factory) public RollVisibilityApiTests(WebApplicationFactory<Program> factory) : base(factory)
: base(factory)
{ {
} }
@@ -13,47 +10,29 @@ public sealed class RollVisibilityApiTests : ApiTestBase
public async Task RollVisibilityAndAuthorization_AreEnforced() public async Task RollVisibilityAndAuthorization_AreEnforced()
{ {
using var factory = CreateFactory(4, 3, 5, 2, 6); using var factory = CreateFactory(4, 3, 5, 2, 6);
using var gmClient = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); using var gmClient = factory.CreateClient(new() { AllowAutoRedirect = false });
using var playerClient = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); using var playerClient = factory.CreateClient(new() { AllowAutoRedirect = false });
using var observerClient = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); using var observerClient = factory.CreateClient(new() { AllowAutoRedirect = false });
using var outsiderClient = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); using var outsiderClient = factory.CreateClient(new() { AllowAutoRedirect = false });
await RegisterAsync(gmClient, "gm", "Password123", "GM"); await RegisterAsync(gmClient, "gm", "Password123", "GM");
await LoginAsync(gmClient, "gm", "Password123"); await LoginAsync(gmClient, "gm", "Password123");
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>( var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns", new("Main", "d6"));
gmClient,
"/api/campaigns",
new CreateCampaignRequest("Main", "d6"));
await RegisterAsync(playerClient, "player", "Password123", "Player"); await RegisterAsync(playerClient, "player", "Password123", "Player");
await LoginAsync(playerClient, "player", "Password123"); await LoginAsync(playerClient, "player", "Password123");
var playerCharacter = await PostAsync<CreateCharacterRequest, CharacterSummary>( var playerCharacter = await PostAsync<CreateCharacterRequest, CharacterSummary>(playerClient, "/api/characters", new("Rogue", campaign.Id));
playerClient,
"/api/characters",
new CreateCharacterRequest("Rogue", campaign.Id));
var skill = await PostAsync<CreateSkillRequest, SkillSummary>( var skill = await PostAsync<CreateSkillRequest, SkillSummary>(playerClient, $"/api/characters/{playerCharacter.Id}/skills", new("Stealth", "2D+1", 1, true));
playerClient,
$"/api/characters/{playerCharacter.Id}/skills",
new CreateSkillRequest("Stealth", "2D+1", 1, true));
Assert.Equal(1, skill.WildDice); Assert.Equal(1, skill.WildDice);
Assert.True(skill.AllowFumble); Assert.True(skill.AllowFumble);
await RegisterAsync(observerClient, "observer", "Password123", "Observer"); await RegisterAsync(observerClient, "observer", "Password123", "Observer");
await LoginAsync(observerClient, "observer", "Password123"); await LoginAsync(observerClient, "observer", "Password123");
await PostAsync<CreateCharacterRequest, CharacterSummary>( await PostAsync<CreateCharacterRequest, CharacterSummary>(observerClient, "/api/characters", new("Watcher", campaign.Id));
observerClient,
"/api/characters",
new CreateCharacterRequest("Watcher", campaign.Id));
var privateRoll = await PostAsync<RollSkillRequest, RollResult>( var privateRoll = await PostAsync<RollSkillRequest, RollResult>(playerClient, $"/api/skills/{skill.Id}/roll", new("private"));
playerClient, var publicRoll = await PostAsync<RollSkillRequest, RollResult>(playerClient, $"/api/skills/{skill.Id}/roll", new("public"));
$"/api/skills/{skill.Id}/roll",
new RollSkillRequest("private"));
var publicRoll = await PostAsync<RollSkillRequest, RollResult>(
playerClient,
$"/api/skills/{skill.Id}/roll",
new RollSkillRequest("public"));
Assert.Equal("private", privateRoll.Visibility); Assert.Equal("private", privateRoll.Visibility);
Assert.Equal("public", publicRoll.Visibility); Assert.Equal("public", publicRoll.Visibility);
@@ -77,15 +56,11 @@ public sealed class RollVisibilityApiTests : ApiTestBase
var forbiddenCampaign = await outsiderClient.GetAsync($"/api/campaigns/{campaign.Id}"); var forbiddenCampaign = await outsiderClient.GetAsync($"/api/campaigns/{campaign.Id}");
Assert.Equal(HttpStatusCode.BadRequest, forbiddenCampaign.StatusCode); Assert.Equal(HttpStatusCode.BadRequest, forbiddenCampaign.StatusCode);
var invalidVisibility = await playerClient.PostAsJsonAsync( var invalidVisibility = await playerClient.PostAsJsonAsync($"/api/skills/{skill.Id}/roll", new RollSkillRequest("hidden"));
$"/api/skills/{skill.Id}/roll",
new RollSkillRequest("hidden"));
Assert.Equal(HttpStatusCode.BadRequest, invalidVisibility.StatusCode); Assert.Equal(HttpStatusCode.BadRequest, invalidVisibility.StatusCode);
using var anonymousClient = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); using var anonymousClient = factory.CreateClient(new() { AllowAutoRedirect = false });
var unauthorizedCampaignCreate = await anonymousClient.PostAsJsonAsync( var unauthorizedCampaignCreate = await anonymousClient.PostAsJsonAsync("/api/campaigns", new CreateCampaignRequest("Nope", "d6"));
"/api/campaigns",
new CreateCampaignRequest("Nope", "d6"));
Assert.Equal(HttpStatusCode.Unauthorized, unauthorizedCampaignCreate.StatusCode); Assert.Equal(HttpStatusCode.Unauthorized, unauthorizedCampaignCreate.StatusCode);
var invalidSessionRequest = new HttpRequestMessage(HttpMethod.Get, "/api/campaigns"); var invalidSessionRequest = new HttpRequestMessage(HttpMethod.Get, "/api/campaigns");

View File

@@ -1,11 +1,8 @@
using Microsoft.AspNetCore.Mvc.Testing;
namespace RpgRoller.Tests; namespace RpgRoller.Tests;
public sealed class SystemApiTests : ApiTestBase public sealed class SystemApiTests : ApiTestBase
{ {
public SystemApiTests(WebApplicationFactory<Program> factory) public SystemApiTests(WebApplicationFactory<Program> factory) : base(factory)
: base(factory)
{ {
} }
@@ -13,7 +10,7 @@ public sealed class SystemApiTests : ApiTestBase
public async Task RulesetAndSseEndpoints_ReturnExpectedResponses() public async Task RulesetAndSseEndpoints_ReturnExpectedResponses()
{ {
using var factory = CreateFactory(2, 2, 2); using var factory = CreateFactory(2, 2, 2);
using var client = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); using var client = factory.CreateClient(new() { AllowAutoRedirect = false });
var rulesets = await GetAsync<IReadOnlyList<RulesetDefinition>>(client, "/api/rulesets"); var rulesets = await GetAsync<IReadOnlyList<RulesetDefinition>>(client, "/api/rulesets");
Assert.Equal(2, rulesets.Count); Assert.Equal(2, rulesets.Count);
@@ -21,10 +18,7 @@ public sealed class SystemApiTests : ApiTestBase
await RegisterAsync(client, "sse", "Password123", "Sse User"); await RegisterAsync(client, "sse", "Password123", "Sse User");
await LoginAsync(client, "sse", "Password123"); await LoginAsync(client, "sse", "Password123");
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>( var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(client, "/api/campaigns", new("SSE", "d6"));
client,
"/api/campaigns",
new CreateCampaignRequest("SSE", "d6"));
var sseResponse = await client.GetAsync($"/api/events/state?campaignId={campaign.Id}", HttpCompletionOption.ResponseHeadersRead); var sseResponse = await client.GetAsync($"/api/events/state?campaignId={campaign.Id}", HttpCompletionOption.ResponseHeadersRead);
Assert.Equal(HttpStatusCode.OK, sseResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, sseResponse.StatusCode);

View File

@@ -11,9 +11,7 @@ public sealed class BackendCoverageTests
var extensionsType = typeof(Program).Assembly.GetType("RpgRoller.Api.SessionTokenHttpContextExtensions"); var extensionsType = typeof(Program).Assembly.GetType("RpgRoller.Api.SessionTokenHttpContextExtensions");
Assert.NotNull(extensionsType); Assert.NotNull(extensionsType);
var method = extensionsType!.GetMethod( var method = extensionsType!.GetMethod("GetRequiredSessionToken", BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic);
"GetRequiredSessionToken",
BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic);
Assert.NotNull(method); Assert.NotNull(method);
var context = new DefaultHttpContext(); var context = new DefaultHttpContext();

View File

@@ -1,9 +1,9 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Data.Sqlite; using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using RpgRoller.Hosting; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using RpgRoller.Data; using RpgRoller.Data;
using RpgRoller.Hosting;
namespace RpgRoller.Tests; namespace RpgRoller.Tests;
@@ -13,22 +13,14 @@ public sealed class HostingCoverageTests
public void AddRpgRollerCore_WithInMemoryConnectionString_RegistersCoreServices() public void AddRpgRollerCore_WithInMemoryConnectionString_RegistersCoreServices()
{ {
var services = new ServiceCollection(); var services = new ServiceCollection();
var configuration = new ConfigurationBuilder() var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?> { ["ConnectionStrings:RpgRoller"] = "Data Source=:memory:" }).Build();
.AddInMemoryCollection(new Dictionary<string, string?>
{
["ConnectionStrings:RpgRoller"] = "Data Source=:memory:"
})
.Build();
var environment = new TestWebHostEnvironment var environment = new TestWebHostEnvironment { ContentRootPath = Path.GetTempPath() };
{
ContentRootPath = Path.GetTempPath()
};
services.AddRpgRollerCore(configuration, environment); services.AddRpgRollerCore(configuration, environment);
Assert.Contains(services, d => d.ServiceType == typeof(RpgRoller.Services.IGameService)); Assert.Contains(services, d => d.ServiceType == typeof(IGameService));
Assert.Contains(services, d => d.ServiceType == typeof(RpgRoller.Services.IDiceRoller)); Assert.Contains(services, d => d.ServiceType == typeof(IDiceRoller));
} }
[Fact] [Fact]
@@ -41,8 +33,7 @@ public sealed class HostingCoverageTests
{ {
connection.Open(); connection.Open();
using var command = connection.CreateCommand(); using var command = connection.CreateCommand();
command.CommandText = command.CommandText = """
"""
CREATE TABLE "Users" ( CREATE TABLE "Users" (
"Id" TEXT NOT NULL CONSTRAINT "PK_Users" PRIMARY KEY, "Id" TEXT NOT NULL CONSTRAINT "PK_Users" PRIMARY KEY,
"Username" TEXT NOT NULL, "Username" TEXT NOT NULL,
@@ -95,9 +86,7 @@ public sealed class HostingCoverageTests
_ = command.ExecuteNonQuery(); _ = command.ExecuteNonQuery();
} }
var options = new DbContextOptionsBuilder<RpgRollerDbContext>() var options = new DbContextOptionsBuilder<RpgRollerDbContext>().UseSqlite(connectionString).Options;
.UseSqlite(connectionString)
.Options;
using (var db = new RpgRollerDbContext(options)) using (var db = new RpgRollerDbContext(options))
{ {
@@ -112,9 +101,7 @@ public sealed class HostingCoverageTests
using var tableInfoReader = tableInfoCommand.ExecuteReader(); using var tableInfoReader = tableInfoCommand.ExecuteReader();
var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase); var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
while (tableInfoReader.Read()) while (tableInfoReader.Read())
{
columns.Add(tableInfoReader.GetString(1)); columns.Add(tableInfoReader.GetString(1));
}
Assert.Contains("WildDice", columns); Assert.Contains("WildDice", columns);
Assert.Contains("AllowFumble", columns); Assert.Contains("AllowFumble", columns);
@@ -124,9 +111,7 @@ public sealed class HostingCoverageTests
using var rollTableInfoReader = rollTableInfoCommand.ExecuteReader(); using var rollTableInfoReader = rollTableInfoCommand.ExecuteReader();
var rollColumns = new HashSet<string>(StringComparer.OrdinalIgnoreCase); var rollColumns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
while (rollTableInfoReader.Read()) while (rollTableInfoReader.Read())
{
rollColumns.Add(rollTableInfoReader.GetString(1)); rollColumns.Add(rollTableInfoReader.GetString(1));
}
Assert.Contains("Dice", rollColumns); Assert.Contains("Dice", rollColumns);

View File

@@ -1,5 +1,3 @@
using RpgRoller.Services;
namespace RpgRoller.Tests; namespace RpgRoller.Tests;
public sealed class DiceRulesTests public sealed class DiceRulesTests

View File

@@ -1,5 +1,3 @@
using RpgRoller.Services;
namespace RpgRoller.Tests; namespace RpgRoller.Tests;
public sealed class RandomDiceRollerTests public sealed class RandomDiceRollerTests

View File

@@ -1,18 +1,27 @@
using System.Net;
using System.Net.Http.Json;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.DependencyInjection.Extensions;
using RpgRoller.Contracts;
using RpgRoller.Data; using RpgRoller.Data;
using RpgRoller.Services;
namespace RpgRoller.Tests; namespace RpgRoller.Tests;
public abstract class ApiTestBase : IClassFixture<WebApplicationFactory<Program>> public abstract class ApiTestBase : IClassFixture<WebApplicationFactory<Program>>
{ {
private readonly WebApplicationFactory<Program> m_BaseFactory; private sealed class FixedDiceRoller : IDiceRoller
{
public FixedDiceRoller(IEnumerable<int> values)
{
m_Values = new(values);
}
public int Roll(int sides)
{
var next = m_Values.Count > 0 ? m_Values.Dequeue() : 1;
return Math.Clamp(next, 1, sides);
}
private readonly Queue<int> m_Values;
}
protected ApiTestBase(WebApplicationFactory<Program> factory) protected ApiTestBase(WebApplicationFactory<Program> factory)
{ {
@@ -21,8 +30,7 @@ public abstract class ApiTestBase : IClassFixture<WebApplicationFactory<Program>
protected WebApplicationFactory<Program> CreateFactory(params int[] rollValues) protected WebApplicationFactory<Program> CreateFactory(params int[] rollValues)
{ {
return m_BaseFactory.WithWebHostBuilder(builder => return m_BaseFactory.WithWebHostBuilder(builder => builder.ConfigureServices(services =>
builder.ConfigureServices(services =>
{ {
services.RemoveAll<IDiceRoller>(); services.RemoveAll<IDiceRoller>();
services.AddSingleton<IDiceRoller>(new FixedDiceRoller(rollValues)); services.AddSingleton<IDiceRoller>(new FixedDiceRoller(rollValues));
@@ -32,17 +40,13 @@ public abstract class ApiTestBase : IClassFixture<WebApplicationFactory<Program>
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.AddDbContextFactory<RpgRollerDbContext>(options => services.AddDbContextFactory<RpgRollerDbContext>(options => options.UseSqlite($"Data Source={dbPath}"));
options.UseSqlite($"Data Source={dbPath}"));
})); }));
} }
protected static async Task<UserSummary> RegisterAsync(HttpClient client, string username, string password, string displayName) protected static async Task<UserSummary> RegisterAsync(HttpClient client, string username, string password, string displayName)
{ {
return await PostAsync<RegisterRequest, UserSummary>( return await PostAsync<RegisterRequest, UserSummary>(client, "/api/auth/register", new(username, password, displayName));
client,
"/api/auth/register",
new RegisterRequest(username, password, displayName));
} }
protected static async Task LoginAsync(HttpClient client, string username, string password) protected static async Task LoginAsync(HttpClient client, string username, string password)
@@ -78,19 +82,5 @@ public abstract class ApiTestBase : IClassFixture<WebApplicationFactory<Program>
return result; return result;
} }
private sealed class FixedDiceRoller : IDiceRoller private readonly WebApplicationFactory<Program> m_BaseFactory;
{
private readonly Queue<int> m_Values;
public FixedDiceRoller(IEnumerable<int> values)
{
m_Values = new Queue<int>(values);
}
public int Roll(int sides)
{
var next = m_Values.Count > 0 ? m_Values.Dequeue() : 1;
return Math.Clamp(next, 1, sides);
}
}
} }

View File

@@ -1,13 +1,86 @@
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using RpgRoller.Data; using RpgRoller.Data;
using RpgRoller.Domain;
using RpgRoller.Services;
namespace RpgRoller.Tests; namespace RpgRoller.Tests;
internal static class ServiceTestSupport internal static class ServiceTestSupport
{ {
internal sealed class ServiceHarness : IDisposable
{
internal ServiceHarness(GameService service, SqliteDbContextFactory factory, string dbPath)
{
Service = service;
m_Factory = factory;
DbPath = dbPath;
}
public void Dispose()
{
m_Factory.Dispose();
}
public RpgRollerDbContext CreateDbContext()
{
return m_Factory.CreateDbContext();
}
public GameService Service { get; }
public string DbPath { get; }
private readonly SqliteDbContextFactory m_Factory;
}
internal sealed class RehashingPasswordHasher : IPasswordHasher<UserAccount>
{
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;
}
public int HashCalls { get; private set; }
}
private sealed class FixedDiceRoller : IDiceRoller
{
public FixedDiceRoller(IEnumerable<int> values)
{
m_Values = new(values);
}
public int Roll(int sides)
{
var next = m_Values.Count > 0 ? m_Values.Dequeue() : 1;
return Math.Clamp(next, 1, sides);
}
private readonly Queue<int> m_Values;
}
internal sealed class SqliteDbContextFactory : IDbContextFactory<RpgRollerDbContext>, IDisposable
{
public SqliteDbContextFactory(string dbPath)
{
m_Options = new DbContextOptionsBuilder<RpgRollerDbContext>().UseSqlite($"Data Source={dbPath}").Options;
}
public RpgRollerDbContext CreateDbContext()
{
return new(m_Options);
}
public void Dispose()
{
}
private readonly DbContextOptions<RpgRollerDbContext> m_Options;
}
internal static ServiceHarness CreateHarness(params int[] rollValues) internal static ServiceHarness CreateHarness(params int[] rollValues)
{ {
return CreateHarness(new PasswordHasher<UserAccount>(), rollValues); return CreateHarness(new PasswordHasher<UserAccount>(), rollValues);
@@ -26,9 +99,7 @@ internal static class ServiceTestSupport
internal static ServiceHarness CreateHarnessFromPath(string dbPath, IPasswordHasher<UserAccount> passwordHasher, params int[] rollValues) internal 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}").Options;
.UseSqlite($"Data Source={dbPath}")
.Options;
using (var db = new RpgRollerDbContext(options)) using (var db = new RpgRollerDbContext(options))
{ {
@@ -37,7 +108,7 @@ internal static class ServiceTestSupport
var factory = new SqliteDbContextFactory(dbPath); var factory = new SqliteDbContextFactory(dbPath);
var service = new GameService(factory, passwordHasher, new FixedDiceRoller(rollValues)); var service = new GameService(factory, passwordHasher, new FixedDiceRoller(rollValues));
return new ServiceHarness(service, factory, dbPath); return new(service, factory, dbPath);
} }
internal static T GetValue<T>(ServiceResult<T> result) internal static T GetValue<T>(ServiceResult<T> result)
@@ -46,84 +117,4 @@ internal static class ServiceTestSupport
Assert.NotNull(result.Value); Assert.NotNull(result.Value);
return result.Value!; return result.Value!;
} }
internal sealed class ServiceHarness : IDisposable
{
private readonly SqliteDbContextFactory m_Factory;
internal ServiceHarness(GameService service, SqliteDbContextFactory factory, string dbPath)
{
Service = service;
m_Factory = factory;
DbPath = dbPath;
}
public GameService Service { get; }
public string DbPath { get; }
public void Dispose()
{
m_Factory.Dispose();
}
public RpgRollerDbContext CreateDbContext()
{
return m_Factory.CreateDbContext();
}
}
internal 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;
}
}
private sealed class FixedDiceRoller : IDiceRoller
{
private readonly Queue<int> m_Values;
public FixedDiceRoller(IEnumerable<int> values)
{
m_Values = new Queue<int>(values);
}
public int Roll(int sides)
{
var next = m_Values.Count > 0 ? m_Values.Dequeue() : 1;
return Math.Clamp(next, 1, sides);
}
}
internal 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()
{
}
}
} }

View File

@@ -8,8 +8,7 @@ public static class ApiEndpointRegistration
api.MapSystemEndpoints(); api.MapSystemEndpoints();
api.MapAuthEndpoints(); api.MapAuthEndpoints();
var authenticatedApi = api.MapGroup(string.Empty) var authenticatedApi = api.MapGroup(string.Empty).AddEndpointFilter<RequireSessionTokenFilter>();
.AddEndpointFilter<RequireSessionTokenFilter>();
authenticatedApi.MapMeEndpoints(); authenticatedApi.MapMeEndpoints();
authenticatedApi.MapCampaignEndpoints(); authenticatedApi.MapCampaignEndpoints();

View File

@@ -9,14 +9,10 @@ internal static class ApiResultMapper
public static Results<Ok<T>, BadRequest<ApiError>, UnauthorizedHttpResult> ToApiResult<T>(ServiceResult<T> result) public static Results<Ok<T>, BadRequest<ApiError>, UnauthorizedHttpResult> ToApiResult<T>(ServiceResult<T> result)
{ {
if (result.Succeeded) if (result.Succeeded)
{
return TypedResults.Ok(result.Value!); return TypedResults.Ok(result.Value!);
}
if (result.Error!.Code == "unauthorized") if (result.Error!.Code == "unauthorized")
{
return TypedResults.Unauthorized(); return TypedResults.Unauthorized();
}
return TypedResults.BadRequest(new ApiError(result.Error.Message)); return TypedResults.BadRequest(new ApiError(result.Error.Message));
} }

View File

@@ -12,9 +12,7 @@ internal static class AuthEndpoints
{ {
var result = game.Register(request.Username, request.Password, request.DisplayName); var result = game.Register(request.Username, request.Password, request.DisplayName);
if (!result.Succeeded) if (!result.Succeeded)
{
return ApiResultMapper.ToBadRequest(result.Error!); return ApiResultMapper.ToBadRequest(result.Error!);
}
return TypedResults.Ok(result.Value!); return TypedResults.Ok(result.Value!);
}); });
@@ -23,11 +21,9 @@ internal static class AuthEndpoints
{ {
var result = game.Login(request.Username, request.Password); var result = game.Login(request.Username, request.Password);
if (!result.Succeeded) if (!result.Succeeded)
{
return ApiResultMapper.ToBadRequest(result.Error!); return ApiResultMapper.ToBadRequest(result.Error!);
}
context.Response.Cookies.Append(SessionCookie.Name, result.Value.SessionToken, new CookieOptions context.Response.Cookies.Append(SessionCookie.Name, result.Value.SessionToken, new()
{ {
HttpOnly = true, HttpOnly = true,
SameSite = SameSiteMode.Strict, SameSite = SameSiteMode.Strict,
@@ -41,9 +37,7 @@ internal static class AuthEndpoints
group.MapPost("/auth/logout", (HttpContext context, IGameService game) => group.MapPost("/auth/logout", (HttpContext context, IGameService game) =>
{ {
if (context.TryReadSessionTokenFromCookie(out var sessionToken)) if (context.TryReadSessionTokenFromCookie(out var sessionToken))
{
game.Logout(sessionToken); game.Logout(sessionToken);
}
context.Response.Cookies.Delete(SessionCookie.Name); context.Response.Cookies.Delete(SessionCookie.Name);
return TypedResults.NoContent(); return TypedResults.NoContent();

View File

@@ -5,9 +5,7 @@ internal sealed class RequireSessionTokenFilter : IEndpointFilter
public ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) public ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
{ {
if (!context.HttpContext.TryReadSessionTokenFromCookie(out var sessionToken)) if (!context.HttpContext.TryReadSessionTokenFromCookie(out var sessionToken))
{
return ValueTask.FromResult<object?>(TypedResults.Unauthorized()); return ValueTask.FromResult<object?>(TypedResults.Unauthorized());
}
context.HttpContext.StoreSessionToken(sessionToken); context.HttpContext.StoreSessionToken(sessionToken);
return next(context); return next(context);

View File

@@ -2,8 +2,6 @@ namespace RpgRoller.Api;
internal static class SessionTokenHttpContextExtensions internal static class SessionTokenHttpContextExtensions
{ {
private const string SessionTokenItemKey = "__rpgroller.session-token";
public static bool TryReadSessionTokenFromCookie(this HttpContext context, out string sessionToken) public static bool TryReadSessionTokenFromCookie(this HttpContext context, out string sessionToken)
{ {
sessionToken = context.Request.Cookies[SessionCookie.Name] ?? string.Empty; sessionToken = context.Request.Cookies[SessionCookie.Name] ?? string.Empty;
@@ -17,13 +15,11 @@ internal static class SessionTokenHttpContextExtensions
public static string GetRequiredSessionToken(this HttpContext context) public static string GetRequiredSessionToken(this HttpContext context)
{ {
if (context.Items.TryGetValue(SessionTokenItemKey, out var token) if (context.Items.TryGetValue(SessionTokenItemKey, out var token) && token is string sessionToken && !string.IsNullOrWhiteSpace(sessionToken))
&& token is string sessionToken
&& !string.IsNullOrWhiteSpace(sessionToken))
{
return sessionToken; return sessionToken;
}
throw new InvalidOperationException("Session token is not available in this request."); throw new InvalidOperationException("Session token is not available in this request.");
} }
private const string SessionTokenItemKey = "__rpgroller.session-token";
} }

View File

@@ -7,18 +7,13 @@ internal static class StateEventEndpoints
{ {
public static RouteGroupBuilder MapStateEventEndpoints(this RouteGroupBuilder group) public static RouteGroupBuilder MapStateEventEndpoints(this RouteGroupBuilder group)
{ {
group.MapGet("/events/state", async Task<IResult> ( group.MapGet("/events/state", async Task<IResult> (Guid campaignId, HttpContext context, IGameService game) =>
Guid campaignId,
HttpContext context,
IGameService game) =>
{ {
var sessionToken = context.GetRequiredSessionToken(); var sessionToken = context.GetRequiredSessionToken();
var versionResult = game.GetCampaignVersion(sessionToken, campaignId); var versionResult = game.GetCampaignVersion(sessionToken, campaignId);
if (!versionResult.Succeeded) if (!versionResult.Succeeded)
{ {
return versionResult.Error!.Code == "unauthorized" return versionResult.Error!.Code == "unauthorized" ? TypedResults.Unauthorized() : TypedResults.BadRequest(new ApiError(versionResult.Error.Message));
? TypedResults.Unauthorized()
: TypedResults.BadRequest(new ApiError(versionResult.Error.Message));
} }
context.Response.Headers.CacheControl = "no-cache"; context.Response.Headers.CacheControl = "no-cache";
@@ -37,9 +32,7 @@ internal static class StateEventEndpoints
var currentVersionResult = game.GetCampaignVersion(sessionToken, campaignId); var currentVersionResult = game.GetCampaignVersion(sessionToken, campaignId);
if (!currentVersionResult.Succeeded) if (!currentVersionResult.Succeeded)
{
break; break;
}
if (currentVersionResult.Value != lastVersion) if (currentVersionResult.Value != lastVersion)
{ {
@@ -47,9 +40,7 @@ internal static class StateEventEndpoints
await context.Response.WriteAsync($"event: state\ndata: {{\"campaignId\":\"{campaignId}\",\"version\":{lastVersion}}}\n\n"); await context.Response.WriteAsync($"event: state\ndata: {{\"campaignId\":\"{campaignId}\",\"version\":{lastVersion}}}\n\n");
} }
else else
{
await context.Response.WriteAsync(": heartbeat\n\n"); await context.Response.WriteAsync(": heartbeat\n\n");
}
await context.Response.Body.FlushAsync(); await context.Response.Body.FlushAsync();
} }

View File

@@ -1,4 +1,3 @@
@using Microsoft.AspNetCore.Components.Web
@attribute [ExcludeFromCodeCoverage] @attribute [ExcludeFromCodeCoverage]
<!DOCTYPE html> <!DOCTYPE html>

View File

@@ -1,17 +1,16 @@
namespace RpgRoller.Components.Pages; namespace RpgRoller.Components.Pages;
public sealed class FormState<TModel> public sealed class FormState<TModel> where TModel : new()
where TModel : new()
{ {
public TModel Model { get; } = new();
public Dictionary<string, string> Errors { get; } = [];
public string? ErrorMessage { get; set; }
public void ResetValidation() public void ResetValidation()
{ {
Errors.Clear(); Errors.Clear();
ErrorMessage = null; ErrorMessage = null;
} }
public TModel Model { get; } = new();
public Dictionary<string, string> Errors { get; } = [];
public string? ErrorMessage { get; set; }
} }
public sealed class RegisterFormModel public sealed class RegisterFormModel

View File

@@ -1,6 +1,5 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using RpgRoller.Components;
using RpgRoller.Contracts; using RpgRoller.Contracts;
namespace RpgRoller.Components.Pages; namespace RpgRoller.Components.Pages;
@@ -8,20 +7,10 @@ namespace RpgRoller.Components.Pages;
[ExcludeFromCodeCoverage] [ExcludeFromCodeCoverage]
public partial class Home public partial class Home
{ {
private HomeViewMode CurrentView { get; set; } = HomeViewMode.Loading;
private string? StatusMessage { get; set; }
private bool StatusIsError { get; set; }
private bool HasInitialized { get; set; }
[Inject]
private RpgRollerApiClient ApiClient { get; set; } = default!;
protected override async Task OnAfterRenderAsync(bool firstRender) protected override async Task OnAfterRenderAsync(bool firstRender)
{ {
if (!firstRender || HasInitialized) if (!firstRender || HasInitialized)
{
return; return;
}
HasInitialized = true; HasInitialized = true;
await InitializeAsync(); await InitializeAsync();
@@ -78,4 +67,12 @@ public partial class Home
StatusMessage = null; StatusMessage = null;
StatusIsError = false; StatusIsError = false;
} }
private HomeViewMode CurrentView { get; set; } = HomeViewMode.Loading;
private string? StatusMessage { get; set; }
private bool StatusIsError { get; set; }
private bool HasInitialized { get; set; }
[Inject]
private RpgRollerApiClient ApiClient { get; set; } = null!;
} }

View File

@@ -1,8 +1,3 @@
@using System.Diagnostics.CodeAnalysis
@using RpgRoller.Components
@using RpgRoller.Components.Pages
@using RpgRoller.Contracts
<main class="auth-shell"> <main class="auth-shell">
<h1>RpgRoller</h1> <h1>RpgRoller</h1>
<p class="auth-subtitle">Register or log in to join a campaign session.</p> <p class="auth-subtitle">Register or log in to join a campaign session.</p>
@@ -19,19 +14,22 @@
} }
<form class="form-grid" @onsubmit="SubmitRegisterAsync" @onsubmit:preventDefault> <form class="form-grid" @onsubmit="SubmitRegisterAsync" @onsubmit:preventDefault>
<label for="register-username">Username</label> <label for="register-username">Username</label>
<input id="register-username" @bind="RegisterState.Model.Username" @bind:event="oninput" autocomplete="username" /> <input id="register-username" @bind="RegisterState.Model.Username" @bind:event="oninput"
autocomplete="username"/>
@if (RegisterState.Errors.TryGetValue("username", out var registerUsernameError)) @if (RegisterState.Errors.TryGetValue("username", out var registerUsernameError))
{ {
<p class="field-error">@registerUsernameError</p> <p class="field-error">@registerUsernameError</p>
} }
<label for="register-display-name">Display name</label> <label for="register-display-name">Display name</label>
<input id="register-display-name" @bind="RegisterState.Model.DisplayName" @bind:event="oninput" autocomplete="name" /> <input id="register-display-name" @bind="RegisterState.Model.DisplayName" @bind:event="oninput"
autocomplete="name"/>
@if (RegisterState.Errors.TryGetValue("displayName", out var registerDisplayNameError)) @if (RegisterState.Errors.TryGetValue("displayName", out var registerDisplayNameError))
{ {
<p class="field-error">@registerDisplayNameError</p> <p class="field-error">@registerDisplayNameError</p>
} }
<label for="register-password">Password</label> <label for="register-password">Password</label>
<input id="register-password" type="password" @bind="RegisterState.Model.Password" @bind:event="oninput" autocomplete="new-password" /> <input id="register-password" type="password" @bind="RegisterState.Model.Password" @bind:event="oninput"
autocomplete="new-password"/>
@if (RegisterState.Errors.TryGetValue("password", out var registerPasswordError)) @if (RegisterState.Errors.TryGetValue("password", out var registerPasswordError))
{ {
<p class="field-error">@registerPasswordError</p> <p class="field-error">@registerPasswordError</p>
@@ -48,13 +46,15 @@
} }
<form class="form-grid" @onsubmit="SubmitLoginAsync" @onsubmit:preventDefault> <form class="form-grid" @onsubmit="SubmitLoginAsync" @onsubmit:preventDefault>
<label for="login-username">Username</label> <label for="login-username">Username</label>
<input id="login-username" @bind="LoginState.Model.Username" @bind:event="oninput" autocomplete="username" /> <input id="login-username" @bind="LoginState.Model.Username" @bind:event="oninput"
autocomplete="username"/>
@if (LoginState.Errors.TryGetValue("username", out var loginUsernameError)) @if (LoginState.Errors.TryGetValue("username", out var loginUsernameError))
{ {
<p class="field-error">@loginUsernameError</p> <p class="field-error">@loginUsernameError</p>
} }
<label for="login-password">Password</label> <label for="login-password">Password</label>
<input id="login-password" type="password" @bind="LoginState.Model.Password" @bind:event="oninput" autocomplete="current-password" /> <input id="login-password" type="password" @bind="LoginState.Model.Password" @bind:event="oninput"
autocomplete="current-password"/>
@if (LoginState.Errors.TryGetValue("password", out var loginPasswordError)) @if (LoginState.Errors.TryGetValue("password", out var loginPasswordError))
{ {
<p class="field-error">@loginPasswordError</p> <p class="field-error">@loginPasswordError</p>

View File

@@ -1,49 +1,25 @@
using Microsoft.AspNetCore.Components;
using RpgRoller.Components.Pages;
using RpgRoller.Components;
using RpgRoller.Contracts;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Components;
using RpgRoller.Contracts;
namespace RpgRoller.Components.Pages.HomeControls; namespace RpgRoller.Components.Pages.HomeControls;
[ExcludeFromCodeCoverage] [ExcludeFromCodeCoverage]
public partial class AuthSection public partial class AuthSection
{ {
[Inject]
private RpgRollerApiClient ApiClient { get; set; } = default!;
private FormState<RegisterFormModel> RegisterState { get; } = new();
private FormState<LoginFormModel> LoginState { get; } = new();
private bool IsSubmitting { get; set; }
[Parameter]
public string? StatusMessage { get; set; }
[Parameter]
public bool StatusIsError { get; set; }
[Parameter]
public EventCallback LoggedIn { get; set; }
private async Task SubmitRegisterAsync() private async Task SubmitRegisterAsync()
{ {
RegisterState.ResetValidation(); RegisterState.ResetValidation();
var model = RegisterState.Model; var model = RegisterState.Model;
if (string.IsNullOrWhiteSpace(model.Username)) if (string.IsNullOrWhiteSpace(model.Username))
{
RegisterState.Errors["username"] = "Username is required."; RegisterState.Errors["username"] = "Username is required.";
}
if (string.IsNullOrWhiteSpace(model.DisplayName)) if (string.IsNullOrWhiteSpace(model.DisplayName))
{
RegisterState.Errors["displayName"] = "Display name is required."; RegisterState.Errors["displayName"] = "Display name is required.";
}
if (string.IsNullOrWhiteSpace(model.Password) || model.Password.Length < 8) if (string.IsNullOrWhiteSpace(model.Password) || model.Password.Length < 8)
{
RegisterState.Errors["password"] = "Password must be at least 8 characters."; RegisterState.Errors["password"] = "Password must be at least 8 characters.";
}
if (RegisterState.Errors.Count > 0) if (RegisterState.Errors.Count > 0)
{ {
@@ -54,10 +30,7 @@ public partial class AuthSection
IsSubmitting = true; IsSubmitting = true;
try try
{ {
_ = await ApiClient.RequestAsync<UserSummary>( _ = await ApiClient.RequestAsync<UserSummary>("POST", "/api/auth/register", new RegisterRequest(model.Username.Trim(), model.Password, model.DisplayName.Trim()));
"POST",
"/api/auth/register",
new RegisterRequest(model.Username.Trim(), model.Password, model.DisplayName.Trim()));
model.Password = string.Empty; model.Password = string.Empty;
RegisterState.ErrorMessage = "Registration successful. You can log in now."; RegisterState.ErrorMessage = "Registration successful. You can log in now.";
@@ -65,14 +38,10 @@ public partial class AuthSection
catch (ApiRequestException ex) catch (ApiRequestException ex)
{ {
if (ex.Message.Contains("already taken", StringComparison.OrdinalIgnoreCase)) if (ex.Message.Contains("already taken", StringComparison.OrdinalIgnoreCase))
{
RegisterState.Errors["username"] = "Username is already taken. Choose another one."; RegisterState.Errors["username"] = "Username is already taken. Choose another one.";
}
else else
{
RegisterState.ErrorMessage = ex.Message; RegisterState.ErrorMessage = ex.Message;
} }
}
finally finally
{ {
IsSubmitting = false; IsSubmitting = false;
@@ -85,14 +54,10 @@ public partial class AuthSection
var model = LoginState.Model; var model = LoginState.Model;
if (string.IsNullOrWhiteSpace(model.Username)) if (string.IsNullOrWhiteSpace(model.Username))
{
LoginState.Errors["username"] = "Username is required."; LoginState.Errors["username"] = "Username is required.";
}
if (string.IsNullOrWhiteSpace(model.Password)) if (string.IsNullOrWhiteSpace(model.Password))
{
LoginState.Errors["password"] = "Password is required."; LoginState.Errors["password"] = "Password is required.";
}
if (LoginState.Errors.Count > 0) if (LoginState.Errors.Count > 0)
{ {
@@ -103,10 +68,7 @@ public partial class AuthSection
IsSubmitting = true; IsSubmitting = true;
try try
{ {
_ = await ApiClient.RequestAsync<UserSummary>( _ = await ApiClient.RequestAsync<UserSummary>("POST", "/api/auth/login", new LoginRequest(model.Username.Trim(), model.Password));
"POST",
"/api/auth/login",
new LoginRequest(model.Username.Trim(), model.Password));
model.Password = string.Empty; model.Password = string.Empty;
await LoggedIn.InvokeAsync(); await LoggedIn.InvokeAsync();
@@ -120,4 +82,20 @@ public partial class AuthSection
IsSubmitting = false; IsSubmitting = false;
} }
} }
[Inject]
private RpgRollerApiClient ApiClient { get; set; } = null!;
private FormState<RegisterFormModel> RegisterState { get; } = new();
private FormState<LoginFormModel> LoginState { get; } = new();
private bool IsSubmitting { get; set; }
[Parameter]
public string? StatusMessage { get; set; }
[Parameter]
public bool StatusIsError { get; set; }
[Parameter]
public EventCallback LoggedIn { get; set; }
} }

View File

@@ -1,11 +1,12 @@
@using System.Diagnostics.CodeAnalysis
@using RpgRoller.Contracts
<aside class="card log-panel"> <aside class="card log-panel">
<div class="section-head"><h2>Campaign Log</h2></div> <div class="section-head"><h2>Campaign Log</h2></div>
@if (IsCampaignDataLoading) @if (IsCampaignDataLoading)
{ {
<div class="skeleton-stack"><div class="skeleton-line"></div><div class="skeleton-line"></div><div class="skeleton-line short"></div></div> <div class="skeleton-stack">
<div class="skeleton-line"></div>
<div class="skeleton-line"></div>
<div class="skeleton-line short"></div>
</div>
} }
else if (CampaignLog.Count == 0) else if (CampaignLog.Count == 0)
{ {
@@ -17,11 +18,16 @@
@foreach (var entry in CampaignLog) @foreach (var entry in CampaignLog)
{ {
<li class="log-entry @LogEntryCssClass(entry)"> <li class="log-entry @LogEntryCssClass(entry)">
<p><strong>@RollerLabel(entry)</strong> rolled <strong>@SkillLabel(entry.SkillId)</strong> with <strong>@CharacterLabel(entry.CharacterId)</strong></p> <p><strong>@RollerLabel(entry)</strong> rolled <strong>@SkillLabel(entry.SkillId)</strong> with
<strong>@CharacterLabel(entry.CharacterId)</strong></p>
<p class="roll-total inline">@entry.Result</p> <p class="roll-total inline">@entry.Result</p>
<RollDiceStrip Dice="entry.Dice" AriaLabel="Log roll dice"/> <RollDiceStrip Dice="entry.Dice" AriaLabel="Log roll dice"/>
<p>@entry.Breakdown</p> <p>@entry.Breakdown</p>
<p class="log-meta"><span class="badge @VisibilityBadgeCssClass(entry)">@VisibilityLabel(entry)</span> <time title="@entry.TimestampUtc.ToString("O")">@entry.TimestampUtc.ToLocalTime().ToString("g")</time></p> <p class="log-meta"><span
class="badge @VisibilityBadgeCssClass(entry)">@VisibilityLabel(entry)</span>
<time
title="@entry.TimestampUtc.ToString("O")">@entry.TimestampUtc.ToLocalTime().ToString("g")</time>
</p>
</li> </li>
} }
</ul> </ul>

View File

@@ -1,6 +1,6 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using RpgRoller.Contracts; using RpgRoller.Contracts;
using System.Diagnostics.CodeAnalysis;
namespace RpgRoller.Components.Pages.HomeControls; namespace RpgRoller.Components.Pages.HomeControls;

View File

@@ -1,8 +1,3 @@
@using System.Diagnostics.CodeAnalysis
@using RpgRoller.Components
@using RpgRoller.Components.Pages
@using RpgRoller.Contracts
<main class="management-screen"> <main class="management-screen">
<section class="card"> <section class="card">
<h2>Campaign Selector</h2> <h2>Campaign Selector</h2>
@@ -16,7 +11,9 @@
<select id="campaign-select" @onchange="CampaignSelectionChanged"> <select id="campaign-select" @onchange="CampaignSelectionChanged">
@foreach (var campaign in Campaigns) @foreach (var campaign in Campaigns)
{ {
<option value="@campaign.Id" selected="@(campaign.Id == SelectedCampaignId)">@campaign.Name (@campaign.RulesetId)</option> <option value="@campaign.Id"
selected="@(campaign.Id == SelectedCampaignId)">@campaign.Name (@campaign.RulesetId)
</option>
} }
</select> </select>
} }
@@ -48,7 +45,8 @@
{ {
<p class="field-error">@campaignRulesetError</p> <p class="field-error">@campaignRulesetError</p>
} }
<button type="submit" disabled="@(IsMutating || IsCreatingCampaign)">@(IsCreatingCampaign ? "Creating..." : "Create Campaign")</button> <button type="submit"
disabled="@(IsMutating || IsCreatingCampaign)">@(IsCreatingCampaign ? "Creating..." : "Create Campaign")</button>
</form> </form>
</section> </section>
@@ -62,7 +60,8 @@
{ {
<p>Name: <strong>@SelectedCampaign.Name</strong></p> <p>Name: <strong>@SelectedCampaign.Name</strong></p>
<p>Ruleset: <strong>@SelectedCampaign.RulesetId</strong></p> <p>Ruleset: <strong>@SelectedCampaign.RulesetId</strong></p>
<p>GM: <strong>@SelectedCampaign.Gm.DisplayName</strong> <span class="muted">(@SelectedCampaign.Gm.Username)</span></p> <p>GM: <strong>@SelectedCampaign.Gm.DisplayName</strong> <span
class="muted">(@SelectedCampaign.Gm.Username)</span></p>
<p>Characters visible: <strong>@SelectedCampaign.Characters.Count</strong></p> <p>Characters visible: <strong>@SelectedCampaign.Characters.Count</strong></p>
} }
</section> </section>
@@ -70,7 +69,9 @@
<section class="card"> <section class="card">
<div class="section-head"> <div class="section-head">
<h2>Character Management</h2> <h2>Character Management</h2>
<button type="button" disabled="@(IsMutating || IsCreatingCampaign || SelectedCampaign is null)" @onclick="CreateCharacterRequested">Create Character</button> <button type="button" disabled="@(IsMutating || IsCreatingCampaign || SelectedCampaign is null)"
@onclick="CreateCharacterRequested">Create Character
</button>
</div> </div>
@if (SelectedCampaign is null) @if (SelectedCampaign is null)
{ {
@@ -86,9 +87,13 @@
@foreach (var character in SelectedCampaign.Characters) @foreach (var character in SelectedCampaign.Characters)
{ {
<li> <li>
<div><strong>@character.Name</strong><p class="muted">Owner: @OwnerLabel(character.OwnerUserId)</p></div> <div><strong>@character.Name</strong>
<p class="muted">Owner: @OwnerLabel(character.OwnerUserId)</p></div>
<div class="inline-actions"> <div class="inline-actions">
<button type="button" disabled="@(IsMutating || IsCreatingCampaign || !CanEditCharacter(character))" @onclick="() => EditCharacterRequested.InvokeAsync(character)">Edit</button> <button type="button"
disabled="@(IsMutating || IsCreatingCampaign || !CanEditCharacter(character))"
@onclick="() => EditCharacterRequested.InvokeAsync(character)">Edit
</button>
</div> </div>
</li> </li>
} }

View File

@@ -1,16 +1,54 @@
using Microsoft.AspNetCore.Components;
using RpgRoller.Components.Pages;
using RpgRoller.Components;
using RpgRoller.Contracts;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Components;
using RpgRoller.Contracts;
namespace RpgRoller.Components.Pages.HomeControls; namespace RpgRoller.Components.Pages.HomeControls;
[ExcludeFromCodeCoverage] [ExcludeFromCodeCoverage]
public partial class CampaignManagementPanel public partial class CampaignManagementPanel
{ {
protected override void OnParametersSet()
{
if (string.IsNullOrWhiteSpace(CampaignState.Model.RulesetId) && Rulesets.Count > 0)
CampaignState.Model.RulesetId = Rulesets[0].Id;
}
private async Task SubmitCreateCampaignAsync()
{
CampaignState.ResetValidation();
if (string.IsNullOrWhiteSpace(CampaignState.Model.Name))
CampaignState.Errors["name"] = "Campaign name is required.";
if (string.IsNullOrWhiteSpace(CampaignState.Model.RulesetId))
CampaignState.Errors["rulesetId"] = "Ruleset is required.";
if (CampaignState.Errors.Count > 0)
{
CampaignState.ErrorMessage = "Resolve validation issues before submitting.";
return;
}
IsCreatingCampaign = true;
try
{
var campaign = await ApiClient.RequestAsync<CampaignSummary>("POST", "/api/campaigns", new CreateCampaignRequest(CampaignState.Model.Name.Trim(), CampaignState.Model.RulesetId));
CampaignState.Model.Name = string.Empty;
await CampaignCreated.InvokeAsync(campaign.Id);
}
catch (ApiRequestException ex)
{
CampaignState.ErrorMessage = ex.Message;
}
finally
{
IsCreatingCampaign = false;
}
}
[Inject] [Inject]
private RpgRollerApiClient ApiClient { get; set; } = default!; private RpgRollerApiClient ApiClient { get; set; } = null!;
private FormState<CampaignFormModel> CampaignState { get; } = new(); private FormState<CampaignFormModel> CampaignState { get; } = new();
private bool IsCreatingCampaign { get; set; } private bool IsCreatingCampaign { get; set; }
@@ -50,53 +88,4 @@ public partial class CampaignManagementPanel
[Parameter] [Parameter]
public EventCallback<CharacterSummary> EditCharacterRequested { get; set; } public EventCallback<CharacterSummary> EditCharacterRequested { get; set; }
protected override void OnParametersSet()
{
if (string.IsNullOrWhiteSpace(CampaignState.Model.RulesetId) && Rulesets.Count > 0)
{
CampaignState.Model.RulesetId = Rulesets[0].Id;
}
}
private async Task SubmitCreateCampaignAsync()
{
CampaignState.ResetValidation();
if (string.IsNullOrWhiteSpace(CampaignState.Model.Name))
{
CampaignState.Errors["name"] = "Campaign name is required.";
}
if (string.IsNullOrWhiteSpace(CampaignState.Model.RulesetId))
{
CampaignState.Errors["rulesetId"] = "Ruleset is required.";
}
if (CampaignState.Errors.Count > 0)
{
CampaignState.ErrorMessage = "Resolve validation issues before submitting.";
return;
}
IsCreatingCampaign = true;
try
{
var campaign = await ApiClient.RequestAsync<CampaignSummary>(
"POST",
"/api/campaigns",
new CreateCampaignRequest(CampaignState.Model.Name.Trim(), CampaignState.Model.RulesetId));
CampaignState.Model.Name = string.Empty;
await CampaignCreated.InvokeAsync(campaign.Id);
}
catch (ApiRequestException ex)
{
CampaignState.ErrorMessage = ex.Message;
}
finally
{
IsCreatingCampaign = false;
}
}
} }

View File

@@ -1,8 +1,3 @@
@using System.Diagnostics.CodeAnalysis
@using RpgRoller.Components
@using RpgRoller.Components.Pages
@using RpgRoller.Contracts
@if (Visible) @if (Visible)
{ {
<div class="modal-overlay" role="presentation"> <div class="modal-overlay" role="presentation">

View File

@@ -1,16 +1,66 @@
using Microsoft.AspNetCore.Components;
using RpgRoller.Components.Pages;
using RpgRoller.Components;
using RpgRoller.Contracts;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Components;
using RpgRoller.Contracts;
namespace RpgRoller.Components.Pages.HomeControls; namespace RpgRoller.Components.Pages.HomeControls;
[ExcludeFromCodeCoverage] [ExcludeFromCodeCoverage]
public partial class CharacterFormModal public partial class CharacterFormModal
{ {
protected override void OnParametersSet()
{
if (!Visible || FormVersion == AppliedFormVersion)
return;
FormState.Model.Name = InitialModel.Name;
FormState.Model.CampaignId = InitialModel.CampaignId;
FormState.ResetValidation();
AppliedFormVersion = FormVersion;
}
private async Task SubmitAsync()
{
FormState.ResetValidation();
if (string.IsNullOrWhiteSpace(FormState.Model.Name))
FormState.Errors["name"] = "Character name is required.";
if (!Guid.TryParse(FormState.Model.CampaignId, out var campaignId))
FormState.Errors["campaignId"] = "Campaign is required.";
if (FormState.Errors.Count > 0)
{
FormState.ErrorMessage = "Resolve validation issues before submitting.";
return;
}
IsSubmitting = true;
try
{
CharacterSummary character;
if (EditingCharacterId.HasValue)
{
character = await ApiClient.RequestAsync<CharacterSummary>("PUT", $"/api/characters/{EditingCharacterId.Value}", new UpdateCharacterRequest(FormState.Model.Name.Trim(), campaignId));
}
else
{
character = await ApiClient.RequestAsync<CharacterSummary>("POST", "/api/characters", new CreateCharacterRequest(FormState.Model.Name.Trim(), campaignId));
}
await CharacterSaved.InvokeAsync(character.CampaignId);
}
catch (ApiRequestException ex)
{
FormState.ErrorMessage = ex.Message;
}
finally
{
IsSubmitting = false;
}
}
[Inject] [Inject]
private RpgRollerApiClient ApiClient { get; set; } = default!; private RpgRollerApiClient ApiClient { get; set; } = null!;
private FormState<CharacterFormModel> FormState { get; } = new(); private FormState<CharacterFormModel> FormState { get; } = new();
private int AppliedFormVersion { get; set; } = -1; private int AppliedFormVersion { get; set; } = -1;
@@ -51,68 +101,4 @@ public partial class CharacterFormModal
[Parameter] [Parameter]
public EventCallback CancelRequested { get; set; } public EventCallback CancelRequested { get; set; }
protected override void OnParametersSet()
{
if (!Visible || FormVersion == AppliedFormVersion)
{
return;
}
FormState.Model.Name = InitialModel.Name;
FormState.Model.CampaignId = InitialModel.CampaignId;
FormState.ResetValidation();
AppliedFormVersion = FormVersion;
}
private async Task SubmitAsync()
{
FormState.ResetValidation();
if (string.IsNullOrWhiteSpace(FormState.Model.Name))
{
FormState.Errors["name"] = "Character name is required.";
}
if (!Guid.TryParse(FormState.Model.CampaignId, out var campaignId))
{
FormState.Errors["campaignId"] = "Campaign is required.";
}
if (FormState.Errors.Count > 0)
{
FormState.ErrorMessage = "Resolve validation issues before submitting.";
return;
}
IsSubmitting = true;
try
{
CharacterSummary character;
if (EditingCharacterId.HasValue)
{
character = await ApiClient.RequestAsync<CharacterSummary>(
"PUT",
$"/api/characters/{EditingCharacterId.Value}",
new UpdateCharacterRequest(FormState.Model.Name.Trim(), campaignId));
}
else
{
character = await ApiClient.RequestAsync<CharacterSummary>(
"POST",
"/api/characters",
new CreateCharacterRequest(FormState.Model.Name.Trim(), campaignId));
}
await CharacterSaved.InvokeAsync(character.CampaignId);
}
catch (ApiRequestException ex)
{
FormState.ErrorMessage = ex.Message;
}
finally
{
IsSubmitting = false;
}
}
} }

View File

@@ -1,12 +1,12 @@
@using System.Diagnostics.CodeAnalysis
@using RpgRoller.Components.Pages
@using RpgRoller.Contracts
<section class="card character-panel"> <section class="card character-panel">
<div class="section-head"><h2>Character Context</h2></div> <div class="section-head"><h2>Character Context</h2></div>
@if (IsCampaignDataLoading) @if (IsCampaignDataLoading)
{ {
<div class="skeleton-stack"><div class="skeleton-line"></div><div class="skeleton-line short"></div><div class="skeleton-line"></div></div> <div class="skeleton-stack">
<div class="skeleton-line"></div>
<div class="skeleton-line short"></div>
<div class="skeleton-line"></div>
</div>
} }
else if (SelectedCampaign is null) else if (SelectedCampaign is null)
{ {
@@ -22,7 +22,8 @@
@foreach (var character in SelectedCampaign.Characters) @foreach (var character in SelectedCampaign.Characters)
{ {
var isSelectedCharacter = SelectedCharacterId == character.Id; var isSelectedCharacter = SelectedCharacterId == character.Id;
<button type="button" class="icon-tab @(isSelectedCharacter ? "active" : string.Empty)" aria-label="@character.Name" @onclick="() => CharacterSelected.InvokeAsync(character.Id)"> <button type="button" class="icon-tab @(isSelectedCharacter ? "active" : string.Empty)"
aria-label="@character.Name" @onclick="() => CharacterSelected.InvokeAsync(character.Id)">
<span class="icon-tab-glyph">@InitialsFor(character.Name)</span> <span class="icon-tab-glyph">@InitialsFor(character.Name)</span>
<span class="icon-tab-text">@character.Name</span> <span class="icon-tab-text">@character.Name</span>
</button> </button>
@@ -36,15 +37,22 @@
<p>Campaign: @SelectedCampaign.Name</p> <p>Campaign: @SelectedCampaign.Name</p>
<span class="badge active">Active</span> <span class="badge active">Active</span>
<div class="inline-actions"> <div class="inline-actions">
<button type="button" disabled="@(IsMutating || !CanEditCharacter(SelectedCharacter))" @onclick="() => EditCharacterRequested.InvokeAsync(SelectedCharacter)">Edit Character</button> <button type="button" disabled="@(IsMutating || !CanEditCharacter(SelectedCharacter))"
@onclick="() => EditCharacterRequested.InvokeAsync(SelectedCharacter)">Edit Character
</button>
</div> </div>
</article> </article>
<article class="skills-section"> <article class="skills-section">
<div class="section-head"> <div class="section-head">
<h3>Skills</h3> <h3>Skills</h3>
<div class="inline-actions"> <div class="inline-actions">
<button type="button" disabled="@(IsMutating || !CanEditCharacter(SelectedCharacter))" @onclick="OpenCreateSkillModal">Create Skill</button> <button type="button" disabled="@(IsMutating || !CanEditCharacter(SelectedCharacter))"
<button type="button" disabled="@(IsMutating || SelectedSkill is null || !CanEditSkill(SelectedSkill))" @onclick="OpenEditSkillModal">Edit Skill</button> @onclick="OpenCreateSkillModal">Create Skill
</button>
<button type="button"
disabled="@(IsMutating || SelectedSkill is null || !CanEditSkill(SelectedSkill))"
@onclick="OpenEditSkillModal">Edit Skill
</button>
</div> </div>
</div> </div>
@if (SelectedCharacterSkills.Count == 0) @if (SelectedCharacterSkills.Count == 0)
@@ -57,7 +65,8 @@
@foreach (var skill in SelectedCharacterSkills) @foreach (var skill in SelectedCharacterSkills)
{ {
var isSelectedSkill = SelectedSkillId == skill.Id; var isSelectedSkill = SelectedSkillId == skill.Id;
<button type="button" class="skill-item @(isSelectedSkill ? "active" : string.Empty)" @onclick="() => SkillSelected.InvokeAsync(skill.Id)"> <button type="button" class="skill-item @(isSelectedSkill ? "active" : string.Empty)"
@onclick="() => SkillSelected.InvokeAsync(skill.Id)">
<strong>@skill.Name</strong><span>@SkillDefinitionLabel(skill)</span> <strong>@skill.Name</strong><span>@SkillDefinitionLabel(skill)</span>
</button> </button>
} }
@@ -69,7 +78,9 @@
<option value="public">Public</option> <option value="public">Public</option>
<option value="private">Private</option> <option value="private">Private</option>
</select> </select>
<button type="submit" disabled="@(IsMutating || SelectedSkill is null || !CanRollSkill(SelectedSkill))">Roll Skill</button> <button type="submit"
disabled="@(IsMutating || SelectedSkill is null || !CanRollSkill(SelectedSkill))">Roll Skill
</button>
</form> </form>
</article> </article>
} }
@@ -85,7 +96,11 @@
<p class="roll-total">@LastRoll.Result</p> <p class="roll-total">@LastRoll.Result</p>
<RollDiceStrip Dice="LastRoll.Dice" AriaLabel="Last roll dice"/> <RollDiceStrip Dice="LastRoll.Dice" AriaLabel="Last roll dice"/>
<p>@LastRoll.Breakdown</p> <p>@LastRoll.Breakdown</p>
<p><span class="badge @(LastRoll.Visibility == "private" ? "private-self" : "public")">@LastRoll.Visibility</span> <time title="@LastRoll.TimestampUtc.ToString("O")">@LastRoll.TimestampUtc.ToLocalTime().ToString("g")</time></p> <p><span
class="badge @(LastRoll.Visibility == "private" ? "private-self" : "public")">@LastRoll.Visibility</span>
<time
title="@LastRoll.TimestampUtc.ToString("O")">@LastRoll.TimestampUtc.ToLocalTime().ToString("g")</time>
</p>
} }
</article> </article>
</section> </section>

View File

@@ -1,13 +1,86 @@
using Microsoft.AspNetCore.Components;
using RpgRoller.Components.Pages;
using RpgRoller.Contracts;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Components;
using RpgRoller.Contracts;
namespace RpgRoller.Components.Pages.HomeControls; namespace RpgRoller.Components.Pages.HomeControls;
[ExcludeFromCodeCoverage] [ExcludeFromCodeCoverage]
public partial class CharacterPanel public partial class CharacterPanel
{ {
private void OpenCreateSkillModal()
{
CreateSkillInitialModel = new()
{
Name = string.Empty,
DiceRollDefinition = string.Empty,
WildDice = IsD6 ? 1 : 0,
AllowFumble = IsD6
};
CreateSkillFormVersion++;
ShowCreateSkillModal = true;
}
private void OpenEditSkillModal()
{
if (SelectedSkill is null)
return;
EditingSkillId = SelectedSkill.Id;
EditSkillInitialModel = new()
{
Name = SelectedSkill.Name,
DiceRollDefinition = SelectedSkill.DiceRollDefinition,
WildDice = SelectedSkill.WildDice,
AllowFumble = SelectedSkill.AllowFumble
};
EditSkillFormVersion++;
ShowEditSkillModal = true;
}
private void CloseSkillModals()
{
ShowCreateSkillModal = false;
ShowEditSkillModal = false;
EditingSkillId = null;
}
private async Task OnSkillCreatedAsync(Guid skillId)
{
CloseSkillModals();
await SkillCreated.InvokeAsync(skillId);
}
private async Task OnSkillUpdatedAsync(Guid skillId)
{
CloseSkillModals();
await SkillUpdated.InvokeAsync(skillId);
}
private async Task OnRollVisibilityChangedAsync(ChangeEventArgs args)
{
var selectedVisibility = args.Value?.ToString() ?? "public";
await RollVisibilityChanged.InvokeAsync(selectedVisibility);
}
private async Task OnRollSubmitAsync()
{
await RollRequested.InvokeAsync();
}
private static string InitialsFor(string value)
{
var words = value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (words.Length == 0)
return "?";
if (words.Length == 1)
return words[0].Length >= 2 ? words[0][..2].ToUpperInvariant() : words[0].ToUpperInvariant();
return string.Concat(words[0][0], words[1][0]).ToUpperInvariant();
}
private bool ShowCreateSkillModal { get; set; } private bool ShowCreateSkillModal { get; set; }
private bool ShowEditSkillModal { get; set; } private bool ShowEditSkillModal { get; set; }
private Guid? EditingSkillId { get; set; } private Guid? EditingSkillId { get; set; }
@@ -84,84 +157,4 @@ public partial class CharacterPanel
[Parameter] [Parameter]
public EventCallback RollRequested { get; set; } public EventCallback RollRequested { get; set; }
private void OpenCreateSkillModal()
{
CreateSkillInitialModel = new SkillFormModel
{
Name = string.Empty,
DiceRollDefinition = string.Empty,
WildDice = IsD6 ? 1 : 0,
AllowFumble = IsD6
};
CreateSkillFormVersion++;
ShowCreateSkillModal = true;
}
private void OpenEditSkillModal()
{
if (SelectedSkill is null)
{
return;
}
EditingSkillId = SelectedSkill.Id;
EditSkillInitialModel = new SkillFormModel
{
Name = SelectedSkill.Name,
DiceRollDefinition = SelectedSkill.DiceRollDefinition,
WildDice = SelectedSkill.WildDice,
AllowFumble = SelectedSkill.AllowFumble
};
EditSkillFormVersion++;
ShowEditSkillModal = true;
}
private void CloseSkillModals()
{
ShowCreateSkillModal = false;
ShowEditSkillModal = false;
EditingSkillId = null;
}
private async Task OnSkillCreatedAsync(Guid skillId)
{
CloseSkillModals();
await SkillCreated.InvokeAsync(skillId);
}
private async Task OnSkillUpdatedAsync(Guid skillId)
{
CloseSkillModals();
await SkillUpdated.InvokeAsync(skillId);
}
private async Task OnRollVisibilityChangedAsync(ChangeEventArgs args)
{
var selectedVisibility = args.Value?.ToString() ?? "public";
await RollVisibilityChanged.InvokeAsync(selectedVisibility);
}
private async Task OnRollSubmitAsync()
{
await RollRequested.InvokeAsync();
}
private static string InitialsFor(string value)
{
var words = value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (words.Length == 0)
{
return "?";
}
if (words.Length == 1)
{
return words[0].Length >= 2 ? words[0][..2].ToUpperInvariant() : words[0].ToUpperInvariant();
}
return string.Concat(words[0][0], words[1][0]).ToUpperInvariant();
}
} }

View File

@@ -1,6 +1,3 @@
@using System.Diagnostics.CodeAnalysis
@using RpgRoller.Contracts
@if (Dice.Count > 0) @if (Dice.Count > 0)
{ {
<div class="roll-dice-strip" aria-label="@AriaLabel"> <div class="roll-dice-strip" aria-label="@AriaLabel">

View File

@@ -1,18 +1,12 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using RpgRoller.Contracts; using RpgRoller.Contracts;
using System.Diagnostics.CodeAnalysis;
namespace RpgRoller.Components.Pages.HomeControls; namespace RpgRoller.Components.Pages.HomeControls;
[ExcludeFromCodeCoverage] [ExcludeFromCodeCoverage]
public partial class RollDiceStrip public partial class RollDiceStrip
{ {
[Parameter]
public IReadOnlyList<RollDieResult> Dice { get; set; } = [];
[Parameter]
public string AriaLabel { get; set; } = "Rolled dice";
private static string RollDieGlyph(int roll) private static string RollDieGlyph(int roll)
{ {
return roll switch return roll switch
@@ -31,29 +25,19 @@ public partial class RollDiceStrip
{ {
var classes = new List<string> { "die-chip" }; var classes = new List<string> { "die-chip" };
if (die.Wild) if (die.Wild)
{
classes.Add("wild"); classes.Add("wild");
}
if (die.Crit) if (die.Crit)
{
classes.Add("crit"); classes.Add("crit");
}
if (die.Fumble) if (die.Fumble)
{
classes.Add("fumble"); classes.Add("fumble");
}
if (die.Removed) if (die.Removed)
{
classes.Add("removed"); classes.Add("removed");
}
if (die.Added) if (die.Added)
{
classes.Add("added"); classes.Add("added");
}
return string.Join(" ", classes); return string.Join(" ", classes);
} }
@@ -62,30 +46,26 @@ public partial class RollDiceStrip
{ {
var labels = new List<string> { $"Roll {die.Roll}" }; var labels = new List<string> { $"Roll {die.Roll}" };
if (die.Wild) if (die.Wild)
{
labels.Add("wild"); labels.Add("wild");
}
if (die.Crit) if (die.Crit)
{
labels.Add("critical"); labels.Add("critical");
}
if (die.Fumble) if (die.Fumble)
{
labels.Add("fumble"); labels.Add("fumble");
}
if (die.Removed) if (die.Removed)
{
labels.Add("removed"); labels.Add("removed");
}
if (die.Added) if (die.Added)
{
labels.Add("added"); labels.Add("added");
}
return string.Join(", ", labels); return string.Join(", ", labels);
} }
[Parameter]
public IReadOnlyList<RollDieResult> Dice { get; set; } = [];
[Parameter]
public string AriaLabel { get; set; } = "Rolled dice";
} }

View File

@@ -1,8 +1,3 @@
@using System.Diagnostics.CodeAnalysis
@using RpgRoller.Components
@using RpgRoller.Components.Pages
@using RpgRoller.Contracts
@if (Visible) @if (Visible)
{ {
<div class="modal-overlay" role="presentation"> <div class="modal-overlay" role="presentation">
@@ -33,6 +28,7 @@
{ {
<p class="field-error">@wildDiceError</p> <p class="field-error">@wildDiceError</p>
} }
<label for="@AllowFumbleInputId">Allow fumble</label> <label for="@AllowFumbleInputId">Allow fumble</label>
<input id="@AllowFumbleInputId" type="checkbox" @bind="FormState.Model.AllowFumble"/> <input id="@AllowFumbleInputId" type="checkbox" @bind="FormState.Model.AllowFumble"/>
} }

View File

@@ -1,16 +1,77 @@
using Microsoft.AspNetCore.Components;
using RpgRoller.Components.Pages;
using RpgRoller.Components;
using RpgRoller.Contracts;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Components;
using RpgRoller.Contracts;
namespace RpgRoller.Components.Pages.HomeControls; namespace RpgRoller.Components.Pages.HomeControls;
[ExcludeFromCodeCoverage] [ExcludeFromCodeCoverage]
public partial class SkillFormModal public partial class SkillFormModal
{ {
protected override void OnParametersSet()
{
if (!Visible || FormVersion == AppliedFormVersion)
return;
FormState.Model.Name = InitialModel.Name;
FormState.Model.DiceRollDefinition = InitialModel.DiceRollDefinition;
FormState.Model.WildDice = InitialModel.WildDice;
FormState.Model.AllowFumble = InitialModel.AllowFumble;
FormState.ResetValidation();
AppliedFormVersion = FormVersion;
}
private async Task SubmitAsync()
{
FormState.ResetValidation();
if (string.IsNullOrWhiteSpace(FormState.Model.Name))
FormState.Errors["name"] = "Skill name is required.";
if (string.IsNullOrWhiteSpace(FormState.Model.DiceRollDefinition))
FormState.Errors["diceRollDefinition"] = "Expression is required.";
if (IsD6 && FormState.Model.WildDice < 1)
FormState.Errors["wildDice"] = "D6 skills require at least one wild die.";
if (FormState.Errors.Count > 0)
{
FormState.ErrorMessage = "Resolve validation issues before submitting.";
return;
}
IsSubmitting = true;
try
{
SkillSummary skill;
if (EditingSkillId.HasValue)
{
skill = await ApiClient.RequestAsync<SkillSummary>("PUT", $"/api/skills/{EditingSkillId.Value}", new UpdateSkillRequest(FormState.Model.Name.Trim(), FormState.Model.DiceRollDefinition.Trim(), FormState.Model.WildDice, FormState.Model.AllowFumble));
}
else
{
if (!SelectedCharacterId.HasValue)
{
FormState.ErrorMessage = "Select a character first.";
return;
}
skill = await ApiClient.RequestAsync<SkillSummary>("POST", $"/api/characters/{SelectedCharacterId.Value}/skills", new CreateSkillRequest(FormState.Model.Name.Trim(), FormState.Model.DiceRollDefinition.Trim(), FormState.Model.WildDice, FormState.Model.AllowFumble));
}
await SkillSaved.InvokeAsync(skill.Id);
}
catch (ApiRequestException ex)
{
FormState.ErrorMessage = ex.Message;
}
finally
{
IsSubmitting = false;
}
}
[Inject] [Inject]
private RpgRollerApiClient ApiClient { get; set; } = default!; private RpgRollerApiClient ApiClient { get; set; } = null!;
private FormState<SkillFormModel> FormState { get; } = new(); private FormState<SkillFormModel> FormState { get; } = new();
private int AppliedFormVersion { get; set; } = -1; private int AppliedFormVersion { get; set; } = -1;
@@ -60,89 +121,4 @@ public partial class SkillFormModal
[Parameter] [Parameter]
public EventCallback CancelRequested { get; set; } public EventCallback CancelRequested { get; set; }
protected override void OnParametersSet()
{
if (!Visible || FormVersion == AppliedFormVersion)
{
return;
}
FormState.Model.Name = InitialModel.Name;
FormState.Model.DiceRollDefinition = InitialModel.DiceRollDefinition;
FormState.Model.WildDice = InitialModel.WildDice;
FormState.Model.AllowFumble = InitialModel.AllowFumble;
FormState.ResetValidation();
AppliedFormVersion = FormVersion;
}
private async Task SubmitAsync()
{
FormState.ResetValidation();
if (string.IsNullOrWhiteSpace(FormState.Model.Name))
{
FormState.Errors["name"] = "Skill name is required.";
}
if (string.IsNullOrWhiteSpace(FormState.Model.DiceRollDefinition))
{
FormState.Errors["diceRollDefinition"] = "Expression is required.";
}
if (IsD6 && FormState.Model.WildDice < 1)
{
FormState.Errors["wildDice"] = "D6 skills require at least one wild die.";
}
if (FormState.Errors.Count > 0)
{
FormState.ErrorMessage = "Resolve validation issues before submitting.";
return;
}
IsSubmitting = true;
try
{
SkillSummary skill;
if (EditingSkillId.HasValue)
{
skill = await ApiClient.RequestAsync<SkillSummary>(
"PUT",
$"/api/skills/{EditingSkillId.Value}",
new UpdateSkillRequest(
FormState.Model.Name.Trim(),
FormState.Model.DiceRollDefinition.Trim(),
FormState.Model.WildDice,
FormState.Model.AllowFumble));
}
else
{
if (!SelectedCharacterId.HasValue)
{
FormState.ErrorMessage = "Select a character first.";
return;
}
skill = await ApiClient.RequestAsync<SkillSummary>(
"POST",
$"/api/characters/{SelectedCharacterId.Value}/skills",
new CreateSkillRequest(
FormState.Model.Name.Trim(),
FormState.Model.DiceRollDefinition.Trim(),
FormState.Model.WildDice,
FormState.Model.AllowFumble));
}
await SkillSaved.InvokeAsync(skill.Id);
}
catch (ApiRequestException ex)
{
FormState.ErrorMessage = ex.Message;
}
finally
{
IsSubmitting = false;
}
}
} }

View File

@@ -1,9 +1,4 @@
@using System.Diagnostics.CodeAnalysis
@using Microsoft.JSInterop
@using RpgRoller.Components
@using RpgRoller.Components.Pages.HomeControls @using RpgRoller.Components.Pages.HomeControls
@using RpgRoller.Contracts
<div class="@AppCssClass"> <div class="@AppCssClass">
<p class="sr-only" aria-live="polite">@LiveAnnouncement</p> <p class="sr-only" aria-live="polite">@LiveAnnouncement</p>
@@ -39,8 +34,12 @@
<div class="header-group controls"> <div class="header-group controls">
<p class="connection @ConnectionStateCssClass">@ConnectionStateLabel</p> <p class="connection @ConnectionStateCssClass">@ConnectionStateLabel</p>
<div class="switch-group" role="tablist" aria-label="Screen selector"> <div class="switch-group" role="tablist" aria-label="Screen selector">
<button type="button" class="switch @(CurrentScreen == "play" ? "active" : string.Empty)" @onclick="SwitchToPlayAsync">Play</button> <button type="button" class="switch @(CurrentScreen == "play" ? "active" : string.Empty)"
<button type="button" class="switch @(CurrentScreen == "management" ? "active" : string.Empty)" @onclick="SwitchToManagementAsync">Campaign Management</button> @onclick="SwitchToPlayAsync">Play
</button>
<button type="button" class="switch @(CurrentScreen == "management" ? "active" : string.Empty)"
@onclick="SwitchToManagementAsync">Campaign Management
</button>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<button type="button" @onclick="ManualRefreshAsync" disabled="@IsMutating">Refresh</button> <button type="button" @onclick="ManualRefreshAsync" disabled="@IsMutating">Refresh</button>
@@ -93,8 +92,12 @@
VisibilityBadgeCssClass="VisibilityBadgeCssClass"/> VisibilityBadgeCssClass="VisibilityBadgeCssClass"/>
</main> </main>
<nav class="mobile-bottom-nav" aria-label="Play panel selector"> <nav class="mobile-bottom-nav" aria-label="Play panel selector">
<button type="button" class="switch @(MobilePanel == "character" ? "active" : string.Empty)" @onclick="SetMobilePanelCharacterAsync">Character</button> <button type="button" class="switch @(MobilePanel == "character" ? "active" : string.Empty)"
<button type="button" class="switch @(MobilePanel == "log" ? "active" : string.Empty)" @onclick="SetMobilePanelLogAsync">Log</button> @onclick="SetMobilePanelCharacterAsync">Character
</button>
<button type="button" class="switch @(MobilePanel == "log" ? "active" : string.Empty)"
@onclick="SetMobilePanelLogAsync">Log
</button>
</nav> </nav>
} }

View File

@@ -1,113 +1,18 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop; using Microsoft.JSInterop;
using RpgRoller.Components.Pages.HomeControls;
using RpgRoller.Components;
using RpgRoller.Contracts; using RpgRoller.Contracts;
using System.Diagnostics.CodeAnalysis;
namespace RpgRoller.Components.Pages; namespace RpgRoller.Components.Pages;
[ExcludeFromCodeCoverage] [ExcludeFromCodeCoverage]
public partial class Workspace : IAsyncDisposable public partial class Workspace : IAsyncDisposable
{ {
[Inject]
private IJSRuntime JS { get; set; } = default!;
[Inject]
private RpgRollerApiClient ApiClient { get; set; } = default!;
private const string ScreenSessionKey = "screen";
private const string CampaignSessionKey = "campaign";
private const string MobilePanelSessionKey = "play-panel";
private UserSummary? User { get; set; }
private Guid? ActiveCharacterId { get; set; }
private Guid? SelectedCampaignId { get; set; }
private CampaignDetails? SelectedCampaign { get; set; }
private List<CampaignSummary> Campaigns { get; set; } = [];
private List<CampaignLogEntry> CampaignLog { get; set; } = [];
private List<RulesetDefinition> Rulesets { get; set; } = [];
private Guid? SelectedCharacterId { get; set; }
private Guid? SelectedSkillId { get; set; }
private RollResult? LastRoll { get; set; }
private string RollVisibility { get; set; } = "public";
private bool IsMutating { get; set; }
private bool IsCampaignDataLoading { get; set; }
private bool HasHealthIssue { get; set; }
private string HealthIssueMessage { get; set; } = "Retry to restore the API connection.";
private string? StatusMessage { get; set; }
private bool StatusIsError { get; set; }
private string CurrentScreen { get; set; } = "play";
private string MobilePanel { get; set; } = "character";
private string ConnectionState { get; set; } = "offline";
private string LiveAnnouncement { get; set; } = string.Empty;
private bool ShowCreateCharacterModal { get; set; }
private bool ShowEditCharacterModal { get; set; }
private Guid? EditingCharacterId { get; set; }
private CharacterFormModel CreateCharacterInitialModel { get; set; } = new();
private CharacterFormModel EditCharacterInitialModel { get; set; } = new();
private int CreateCharacterFormVersion { get; set; }
private int EditCharacterFormVersion { get; set; }
private bool StateRefreshInProgress { get; set; }
private bool HasInteractiveRenderStarted { get; set; }
private DotNetObjectReference<Workspace>? DotNetRef { get; set; }
[Parameter]
public EventCallback<string?> LoggedOut { get; set; }
private string? SelectedCampaignName => SelectedCampaign?.Name;
private CharacterSummary? SelectedCharacter =>
SelectedCampaign?.Characters.FirstOrDefault(c => c.Id == SelectedCharacterId);
private SkillSummary? SelectedSkill =>
SelectedCampaign?.Skills.FirstOrDefault(s => s.Id == SelectedSkillId);
private string? ActiveCharacterName =>
SelectedCampaign?.Characters.FirstOrDefault(c => c.Id == SelectedCharacterId)?.Name;
private bool IsCurrentUserGm =>
SelectedCampaign is not null && User is not null && SelectedCampaign.Gm.Id == User.Id;
private bool IsSelectedCampaignD6 =>
string.Equals(SelectedCampaign?.RulesetId, "d6", StringComparison.OrdinalIgnoreCase);
private List<SkillSummary> SelectedCharacterSkills =>
SelectedCampaign is null || !SelectedCharacterId.HasValue
? []
: SelectedCampaign.Skills
.Where(skill => skill.CharacterId == SelectedCharacterId.Value)
.OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
private bool IsPlayScreen => string.Equals(CurrentScreen, "play", StringComparison.OrdinalIgnoreCase);
private bool IsManagementScreen => !IsPlayScreen;
private string ConnectionStateLabel => ConnectionState switch
{
"connected" => "Connected",
"reconnecting" => "Reconnecting",
_ => "Offline fallback"
};
private string ConnectionStateCssClass => ConnectionState switch
{
"connected" => "ok",
"reconnecting" => "warn",
_ => "offline"
};
private string AppCssClass => IsPlayScreen ? "rr-app app-play" : "rr-app";
protected override async Task OnAfterRenderAsync(bool firstRender) protected override async Task OnAfterRenderAsync(bool firstRender)
{ {
HasInteractiveRenderStarted = true; HasInteractiveRenderStarted = true;
if (!firstRender) if (!firstRender)
{
return; return;
}
await InitializeAsync(); await InitializeAsync();
await InvokeAsync(StateHasChanged); await InvokeAsync(StateHasChanged);
@@ -117,32 +22,24 @@ public partial class Workspace : IAsyncDisposable
{ {
var storedScreen = await JS.InvokeAsync<string?>("rpgRollerApi.getSessionValue", ScreenSessionKey); var storedScreen = await JS.InvokeAsync<string?>("rpgRollerApi.getSessionValue", ScreenSessionKey);
if (string.Equals(storedScreen, "management", StringComparison.OrdinalIgnoreCase)) if (string.Equals(storedScreen, "management", StringComparison.OrdinalIgnoreCase))
{
CurrentScreen = "management"; CurrentScreen = "management";
}
var storedPanel = await JS.InvokeAsync<string?>("rpgRollerApi.getSessionValue", MobilePanelSessionKey); var storedPanel = await JS.InvokeAsync<string?>("rpgRollerApi.getSessionValue", MobilePanelSessionKey);
if (string.Equals(storedPanel, "log", StringComparison.OrdinalIgnoreCase)) if (string.Equals(storedPanel, "log", StringComparison.OrdinalIgnoreCase))
{
MobilePanel = "log"; MobilePanel = "log";
}
Guid? preferredCampaignId = null; Guid? preferredCampaignId = null;
var storedCampaignId = await JS.InvokeAsync<string?>("rpgRollerApi.getSessionValue", CampaignSessionKey); var storedCampaignId = await JS.InvokeAsync<string?>("rpgRollerApi.getSessionValue", CampaignSessionKey);
if (Guid.TryParse(storedCampaignId, out var parsedCampaignId)) if (Guid.TryParse(storedCampaignId, out var parsedCampaignId))
{
preferredCampaignId = parsedCampaignId; preferredCampaignId = parsedCampaignId;
}
await CheckHealthAsync(); await CheckHealthAsync();
await LoadRulesetsAsync(); await LoadRulesetsAsync();
var reloaded = await ReloadAuthenticatedSessionAsync(preferredCampaignId); var reloaded = await ReloadAuthenticatedSessionAsync(preferredCampaignId);
if (!reloaded) if (!reloaded)
{
await LoggedOut.InvokeAsync("Session expired. Please log in again."); await LoggedOut.InvokeAsync("Session expired. Please log in again.");
} }
}
private async Task RetryAfterHealthIssueAsync() private async Task RetryAfterHealthIssueAsync()
{ {
@@ -151,11 +48,9 @@ public partial class Workspace : IAsyncDisposable
{ {
var reloaded = await ReloadAuthenticatedSessionAsync(SelectedCampaignId); var reloaded = await ReloadAuthenticatedSessionAsync(SelectedCampaignId);
if (!reloaded) if (!reloaded)
{
await LoggedOut.InvokeAsync("Session expired. Please log in again."); await LoggedOut.InvokeAsync("Session expired. Please log in again.");
} }
} }
}
private async Task CheckHealthAsync() private async Task CheckHealthAsync()
{ {
@@ -236,13 +131,9 @@ public partial class Workspace : IAsyncDisposable
var campaignIds = Campaigns.Select(c => c.Id).ToHashSet(); var campaignIds = Campaigns.Select(c => c.Id).ToHashSet();
if (preferredCampaignId.HasValue && campaignIds.Contains(preferredCampaignId.Value)) if (preferredCampaignId.HasValue && campaignIds.Contains(preferredCampaignId.Value))
{
SelectedCampaignId = preferredCampaignId.Value; SelectedCampaignId = preferredCampaignId.Value;
}
else if (!SelectedCampaignId.HasValue || !campaignIds.Contains(SelectedCampaignId.Value)) else if (!SelectedCampaignId.HasValue || !campaignIds.Contains(SelectedCampaignId.Value))
{
SelectedCampaignId = Campaigns[0].Id; SelectedCampaignId = Campaigns[0].Id;
}
await JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", CampaignSessionKey, SelectedCampaignId?.ToString()); await JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", CampaignSessionKey, SelectedCampaignId?.ToString());
} }
@@ -288,9 +179,7 @@ public partial class Workspace : IAsyncDisposable
private async Task ManualRefreshAsync() private async Task ManualRefreshAsync()
{ {
if (IsMutating) if (IsMutating)
{
return; return;
}
IsMutating = true; IsMutating = true;
try try
@@ -314,9 +203,7 @@ public partial class Workspace : IAsyncDisposable
private async Task LogoutAsync() private async Task LogoutAsync()
{ {
if (IsMutating) if (IsMutating)
{
return; return;
}
IsMutating = true; IsMutating = true;
try try
@@ -342,8 +229,15 @@ public partial class Workspace : IAsyncDisposable
await JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", ScreenSessionKey, CurrentScreen); await JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", ScreenSessionKey, CurrentScreen);
} }
private Task SwitchToPlayAsync() => SwitchScreenAsync("play"); private Task SwitchToPlayAsync()
private Task SwitchToManagementAsync() => SwitchScreenAsync("management"); {
return SwitchScreenAsync("play");
}
private Task SwitchToManagementAsync()
{
return SwitchScreenAsync("management");
}
private async Task SetMobilePanelAsync(string panel) private async Task SetMobilePanelAsync(string panel)
{ {
@@ -351,15 +245,20 @@ public partial class Workspace : IAsyncDisposable
await JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", MobilePanelSessionKey, MobilePanel); await JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", MobilePanelSessionKey, MobilePanel);
} }
private Task SetMobilePanelCharacterAsync() => SetMobilePanelAsync("character"); private Task SetMobilePanelCharacterAsync()
private Task SetMobilePanelLogAsync() => SetMobilePanelAsync("log"); {
return SetMobilePanelAsync("character");
}
private Task SetMobilePanelLogAsync()
{
return SetMobilePanelAsync("log");
}
private async Task OnCampaignSelectionChangedAsync(ChangeEventArgs args) private async Task OnCampaignSelectionChangedAsync(ChangeEventArgs args)
{ {
if (!Guid.TryParse(args.Value?.ToString(), out var campaignId)) if (!Guid.TryParse(args.Value?.ToString(), out var campaignId))
{
return; return;
}
SelectedCampaignId = campaignId; SelectedCampaignId = campaignId;
await JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", CampaignSessionKey, campaignId.ToString()); await JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", CampaignSessionKey, campaignId.ToString());
@@ -377,7 +276,7 @@ public partial class Workspace : IAsyncDisposable
private void OpenCreateCharacterModal() private void OpenCreateCharacterModal()
{ {
CreateCharacterInitialModel = new CharacterFormModel CreateCharacterInitialModel = new()
{ {
Name = string.Empty, Name = string.Empty,
CampaignId = SelectedCampaignId?.ToString() ?? string.Empty CampaignId = SelectedCampaignId?.ToString() ?? string.Empty
@@ -390,7 +289,7 @@ public partial class Workspace : IAsyncDisposable
private void OpenEditCharacterModal(CharacterSummary character) private void OpenEditCharacterModal(CharacterSummary character)
{ {
EditingCharacterId = character.Id; EditingCharacterId = character.Id;
EditCharacterInitialModel = new CharacterFormModel EditCharacterInitialModel = new()
{ {
Name = character.Name, Name = character.Name,
CampaignId = character.CampaignId.ToString() CampaignId = character.CampaignId.ToString()
@@ -445,15 +344,11 @@ public partial class Workspace : IAsyncDisposable
private async Task EnsureSelectedCharacterActiveAsync() private async Task EnsureSelectedCharacterActiveAsync()
{ {
if (!SelectedCharacterId.HasValue || SelectedCampaign is null) if (!SelectedCharacterId.HasValue || SelectedCampaign is null)
{
return; return;
}
var character = SelectedCampaign.Characters.FirstOrDefault(c => c.Id == SelectedCharacterId.Value); var character = SelectedCampaign.Characters.FirstOrDefault(c => c.Id == SelectedCharacterId.Value);
if (character is null || !CanActivateCharacter(character, User) || ActiveCharacterId == character.Id) if (character is null || !CanActivateCharacter(character, User) || ActiveCharacterId == character.Id)
{
return; return;
}
try try
{ {
@@ -490,10 +385,7 @@ public partial class Workspace : IAsyncDisposable
IsMutating = true; IsMutating = true;
try try
{ {
LastRoll = await ApiClient.RequestAsync<RollResult>( LastRoll = await ApiClient.RequestAsync<RollResult>("POST", $"/api/skills/{SelectedSkill.Id}/roll", new RollSkillRequest(RollVisibility));
"POST",
$"/api/skills/{SelectedSkill.Id}/roll",
new RollSkillRequest(RollVisibility));
await RefreshCampaignScopeAsync(); await RefreshCampaignScopeAsync();
SetStatus("Roll recorded.", false); SetStatus("Roll recorded.", false);
@@ -523,9 +415,7 @@ public partial class Workspace : IAsyncDisposable
private bool CanEditSkill(SkillSummary skill) private bool CanEditSkill(SkillSummary skill)
{ {
if (SelectedCampaign is null) if (SelectedCampaign is null)
{
return false; return false;
}
var character = SelectedCampaign.Characters.FirstOrDefault(c => c.Id == skill.CharacterId); var character = SelectedCampaign.Characters.FirstOrDefault(c => c.Id == skill.CharacterId);
return character is not null && CanEditCharacter(character); return character is not null && CanEditCharacter(character);
@@ -540,9 +430,7 @@ public partial class Workspace : IAsyncDisposable
public async Task OnStateEventReceived(long _) public async Task OnStateEventReceived(long _)
{ {
if (StateRefreshInProgress) if (StateRefreshInProgress)
{
return; return;
}
StateRefreshInProgress = true; StateRefreshInProgress = true;
try try
@@ -567,14 +455,10 @@ public partial class Workspace : IAsyncDisposable
}; };
if (ConnectionState == "reconnecting") if (ConnectionState == "reconnecting")
{
Announce("Reconnecting to live updates."); Announce("Reconnecting to live updates.");
}
if (ConnectionState == "offline") if (ConnectionState == "offline")
{
Announce("Live updates offline. Use manual refresh."); Announce("Live updates offline. Use manual refresh.");
}
return InvokeAsync(StateHasChanged); return InvokeAsync(StateHasChanged);
} }
@@ -596,9 +480,7 @@ public partial class Workspace : IAsyncDisposable
private async Task StopStateEventsAsync() private async Task StopStateEventsAsync()
{ {
if (!HasInteractiveRenderStarted) if (!HasInteractiveRenderStarted)
{
return; return;
}
try try
{ {
@@ -633,9 +515,7 @@ public partial class Workspace : IAsyncDisposable
var candidateIds = SelectedCampaign.Characters.Select(c => c.Id).ToHashSet(); var candidateIds = SelectedCampaign.Characters.Select(c => c.Id).ToHashSet();
if (SelectedCharacterId.HasValue && candidateIds.Contains(SelectedCharacterId.Value)) if (SelectedCharacterId.HasValue && candidateIds.Contains(SelectedCharacterId.Value))
{
return; return;
}
if (ActiveCharacterId.HasValue && candidateIds.Contains(ActiveCharacterId.Value)) if (ActiveCharacterId.HasValue && candidateIds.Contains(ActiveCharacterId.Value))
{ {
@@ -656,9 +536,7 @@ public partial class Workspace : IAsyncDisposable
} }
if (SelectedSkillId.HasValue && skills.Any(skill => skill.Id == SelectedSkillId.Value)) if (SelectedSkillId.HasValue && skills.Any(skill => skill.Id == SelectedSkillId.Value))
{
return; return;
}
SelectedSkillId = skills[0].Id; SelectedSkillId = skills[0].Id;
} }
@@ -666,14 +544,10 @@ public partial class Workspace : IAsyncDisposable
private string OwnerLabel(Guid ownerUserId) private string OwnerLabel(Guid ownerUserId)
{ {
if (User is not null && ownerUserId == User.Id) if (User is not null && ownerUserId == User.Id)
{
return "You"; return "You";
}
if (SelectedCampaign is not null && ownerUserId == SelectedCampaign.Gm.Id) if (SelectedCampaign is not null && ownerUserId == SelectedCampaign.Gm.Id)
{
return $"{SelectedCampaign.Gm.DisplayName} (GM)"; return $"{SelectedCampaign.Gm.DisplayName} (GM)";
}
return ownerUserId.ToString("N")[..8]; return ownerUserId.ToString("N")[..8];
} }
@@ -691,9 +565,7 @@ public partial class Workspace : IAsyncDisposable
private string SkillDefinitionLabel(SkillSummary skill) private string SkillDefinitionLabel(SkillSummary skill)
{ {
if (!string.Equals(SelectedCampaign?.RulesetId, "d6", StringComparison.OrdinalIgnoreCase)) if (!string.Equals(SelectedCampaign?.RulesetId, "d6", StringComparison.OrdinalIgnoreCase))
{
return skill.DiceRollDefinition; return skill.DiceRollDefinition;
}
var fumbleLabel = skill.AllowFumble ? "fumble on" : "fumble off"; var fumbleLabel = skill.AllowFumble ? "fumble on" : "fumble off";
return $"{skill.DiceRollDefinition} | wild {skill.WildDice}, {fumbleLabel}"; return $"{skill.DiceRollDefinition} | wild {skill.WildDice}, {fumbleLabel}";
@@ -702,14 +574,10 @@ public partial class Workspace : IAsyncDisposable
private string RollerLabel(CampaignLogEntry entry) private string RollerLabel(CampaignLogEntry entry)
{ {
if (User is not null && entry.RollerUserId == User.Id) if (User is not null && entry.RollerUserId == User.Id)
{
return "You"; return "You";
}
if (SelectedCampaign is not null && entry.RollerUserId == SelectedCampaign.Gm.Id) if (SelectedCampaign is not null && entry.RollerUserId == SelectedCampaign.Gm.Id)
{
return "GM"; return "GM";
}
return "Participant"; return "Participant";
} }
@@ -717,14 +585,10 @@ public partial class Workspace : IAsyncDisposable
private string VisibilityLabel(CampaignLogEntry entry) private string VisibilityLabel(CampaignLogEntry entry)
{ {
if (!string.Equals(entry.Visibility, "private", StringComparison.OrdinalIgnoreCase)) if (!string.Equals(entry.Visibility, "private", StringComparison.OrdinalIgnoreCase))
{
return "Public"; return "Public";
}
if (User is not null && entry.RollerUserId == User.Id) if (User is not null && entry.RollerUserId == User.Id)
{
return "Private (you)"; return "Private (you)";
}
return IsCurrentUserGm ? "Private (GM view)" : "Private"; return IsCurrentUserGm ? "Private (GM view)" : "Private";
} }
@@ -732,14 +596,10 @@ public partial class Workspace : IAsyncDisposable
private string VisibilityBadgeCssClass(CampaignLogEntry entry) private string VisibilityBadgeCssClass(CampaignLogEntry entry)
{ {
if (!string.Equals(entry.Visibility, "private", StringComparison.OrdinalIgnoreCase)) if (!string.Equals(entry.Visibility, "private", StringComparison.OrdinalIgnoreCase))
{
return "public"; return "public";
}
if (User is not null && entry.RollerUserId == User.Id) if (User is not null && entry.RollerUserId == User.Id)
{
return "private-self"; return "private-self";
}
return IsCurrentUserGm ? "private-gm" : "private-generic"; return IsCurrentUserGm ? "private-gm" : "private-generic";
} }
@@ -747,14 +607,10 @@ public partial class Workspace : IAsyncDisposable
private string LogEntryCssClass(CampaignLogEntry entry) private string LogEntryCssClass(CampaignLogEntry entry)
{ {
if (!string.Equals(entry.Visibility, "private", StringComparison.OrdinalIgnoreCase)) if (!string.Equals(entry.Visibility, "private", StringComparison.OrdinalIgnoreCase))
{
return "public"; return "public";
}
if (User is not null && entry.RollerUserId == User.Id) if (User is not null && entry.RollerUserId == User.Id)
{
return "private-self"; return "private-self";
}
return IsCurrentUserGm ? "private-gm" : "private-generic"; return IsCurrentUserGm ? "private-gm" : "private-generic";
} }
@@ -789,4 +645,90 @@ public partial class Workspace : IAsyncDisposable
{ {
LiveAnnouncement = message; LiveAnnouncement = message;
} }
[Inject]
private IJSRuntime JS { get; set; } = null!;
[Inject]
private RpgRollerApiClient ApiClient { get; set; } = null!;
private UserSummary? User { get; set; }
private Guid? ActiveCharacterId { get; set; }
private Guid? SelectedCampaignId { get; set; }
private CampaignDetails? SelectedCampaign { get; set; }
private List<CampaignSummary> Campaigns { get; set; } = [];
private List<CampaignLogEntry> CampaignLog { get; set; } = [];
private List<RulesetDefinition> Rulesets { get; set; } = [];
private Guid? SelectedCharacterId { get; set; }
private Guid? SelectedSkillId { get; set; }
private RollResult? LastRoll { get; set; }
private string RollVisibility { get; set; } = "public";
private bool IsMutating { get; set; }
private bool IsCampaignDataLoading { get; set; }
private bool HasHealthIssue { get; set; }
private string HealthIssueMessage { get; set; } = "Retry to restore the API connection.";
private string? StatusMessage { get; set; }
private bool StatusIsError { get; set; }
private string CurrentScreen { get; set; } = "play";
private string MobilePanel { get; set; } = "character";
private string ConnectionState { get; set; } = "offline";
private string LiveAnnouncement { get; set; } = string.Empty;
private bool ShowCreateCharacterModal { get; set; }
private bool ShowEditCharacterModal { get; set; }
private Guid? EditingCharacterId { get; set; }
private CharacterFormModel CreateCharacterInitialModel { get; set; } = new();
private CharacterFormModel EditCharacterInitialModel { get; set; } = new();
private int CreateCharacterFormVersion { get; set; }
private int EditCharacterFormVersion { get; set; }
private bool StateRefreshInProgress { get; set; }
private bool HasInteractiveRenderStarted { get; set; }
private DotNetObjectReference<Workspace>? DotNetRef { get; set; }
[Parameter]
public EventCallback<string?> LoggedOut { get; set; }
private string? SelectedCampaignName => SelectedCampaign?.Name;
private CharacterSummary? SelectedCharacter =>
SelectedCampaign?.Characters.FirstOrDefault(c => c.Id == SelectedCharacterId);
private SkillSummary? SelectedSkill =>
SelectedCampaign?.Skills.FirstOrDefault(s => s.Id == SelectedSkillId);
private string? ActiveCharacterName =>
SelectedCampaign?.Characters.FirstOrDefault(c => c.Id == SelectedCharacterId)?.Name;
private bool IsCurrentUserGm =>
SelectedCampaign is not null && User is not null && SelectedCampaign.Gm.Id == User.Id;
private bool IsSelectedCampaignD6 =>
string.Equals(SelectedCampaign?.RulesetId, "d6", StringComparison.OrdinalIgnoreCase);
private List<SkillSummary> SelectedCharacterSkills =>
SelectedCampaign is null || !SelectedCharacterId.HasValue ? [] : SelectedCampaign.Skills.Where(skill => skill.CharacterId == SelectedCharacterId.Value).OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase).ToList();
private bool IsPlayScreen => string.Equals(CurrentScreen, "play", StringComparison.OrdinalIgnoreCase);
private bool IsManagementScreen => !IsPlayScreen;
private string ConnectionStateLabel => ConnectionState switch
{
"connected" => "Connected",
"reconnecting" => "Reconnecting",
_ => "Offline fallback"
};
private string ConnectionStateCssClass => ConnectionState switch
{
"connected" => "ok",
"reconnecting" => "warn",
_ => "offline"
};
private string AppCssClass => IsPlayScreen ? "rr-app app-play" : "rr-app";
private const string ScreenSessionKey = "screen";
private const string CampaignSessionKey = "campaign";
private const string MobilePanelSessionKey = "play-panel";
} }

View File

@@ -1,8 +1,9 @@
@using RpgRoller.Components.Layout
@attribute [ExcludeFromCodeCoverage] @attribute [ExcludeFromCodeCoverage]
<Router AppAssembly="@typeof(Program).Assembly"> <Router AppAssembly="@typeof(Program).Assembly">
<Found Context="routeData"> <Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(Layout.MainLayout)" /> <RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)"/>
<FocusOnNavigate RouteData="@routeData" Selector="h1"/> <FocusOnNavigate RouteData="@routeData" Selector="h1"/>
</Found> </Found>
</Router> </Router>

View File

@@ -5,8 +5,13 @@ namespace RpgRoller.Components;
public sealed class RpgRollerApiClient public sealed class RpgRollerApiClient
{ {
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); private sealed class JsApiResponse
private readonly IJSRuntime m_Js; {
public bool Ok { get; set; }
public int Status { get; set; }
public string? Error { get; set; }
public JsonElement Data { get; set; }
}
public RpgRollerApiClient(IJSRuntime js) public RpgRollerApiClient(IJSRuntime js)
{ {
@@ -17,14 +22,10 @@ public sealed class RpgRollerApiClient
{ {
var response = await m_Js.InvokeAsync<JsApiResponse>("rpgRollerApi.request", method, path, payload); var response = await m_Js.InvokeAsync<JsApiResponse>("rpgRollerApi.request", method, path, payload);
if (!response.Ok) if (!response.Ok)
{
throw new ApiRequestException(response.Status, response.Error ?? "Request failed."); throw new ApiRequestException(response.Status, response.Error ?? "Request failed.");
}
if (response.Data.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null) if (response.Data.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null)
{
return default!; return default!;
}
return response.Data.Deserialize<T>(JsonOptions)!; return response.Data.Deserialize<T>(JsonOptions)!;
} }
@@ -33,24 +34,16 @@ public sealed class RpgRollerApiClient
{ {
var response = await m_Js.InvokeAsync<JsApiResponse>("rpgRollerApi.request", method, path, null); var response = await m_Js.InvokeAsync<JsApiResponse>("rpgRollerApi.request", method, path, null);
if (!response.Ok) if (!response.Ok)
{
throw new ApiRequestException(response.Status, response.Error ?? "Request failed."); throw new ApiRequestException(response.Status, response.Error ?? "Request failed.");
} }
}
private sealed class JsApiResponse private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
{ private readonly IJSRuntime m_Js;
public bool Ok { get; set; }
public int Status { get; set; }
public string? Error { get; set; }
public JsonElement Data { get; set; }
}
} }
public sealed class ApiRequestException : Exception public sealed class ApiRequestException : Exception
{ {
public ApiRequestException(int statusCode, string message) public ApiRequestException(int statusCode, string message) : base(message)
: base(message)
{ {
StatusCode = statusCode; StatusCode = statusCode;
} }

View File

@@ -1,56 +1,41 @@
namespace RpgRoller.Contracts; namespace RpgRoller.Contracts;
public sealed record HealthResponse(string Status); public sealed record HealthResponse(string Status);
public sealed record ApiError(string Error); public sealed record ApiError(string Error);
public sealed record RegisterRequest(string Username, string Password, string DisplayName); public sealed record RegisterRequest(string Username, string Password, string DisplayName);
public sealed record LoginRequest(string Username, string Password); public sealed record LoginRequest(string Username, string Password);
public sealed record UserSummary(Guid Id, string Username, string DisplayName); public sealed record UserSummary(Guid Id, string Username, string DisplayName);
public sealed record MeResponse(UserSummary User, Guid? ActiveCharacterId, Guid? CurrentCampaignId); public sealed record MeResponse(UserSummary User, Guid? ActiveCharacterId, Guid? CurrentCampaignId);
public sealed record RulesetDefinition(string Id, string Name, string DiceSyntax); public sealed record RulesetDefinition(string Id, string Name, string DiceSyntax);
public sealed record CreateCampaignRequest(string Name, string RulesetId); public sealed record CreateCampaignRequest(string Name, string RulesetId);
public sealed record CampaignSummary(Guid Id, string Name, string RulesetId, Guid GmUserId); public sealed record CampaignSummary(Guid Id, string Name, string RulesetId, Guid GmUserId);
public sealed record CampaignDetails(
Guid Id, public sealed record CampaignDetails(Guid Id, string Name, string RulesetId, UserSummary Gm, IReadOnlyList<CharacterSummary> Characters, IReadOnlyList<SkillSummary> Skills);
string Name,
string RulesetId,
UserSummary Gm,
IReadOnlyList<CharacterSummary> Characters,
IReadOnlyList<SkillSummary> Skills);
public sealed record CreateCharacterRequest(string Name, Guid CampaignId); public sealed record CreateCharacterRequest(string Name, Guid CampaignId);
public sealed record UpdateCharacterRequest(string Name, Guid CampaignId); public sealed record UpdateCharacterRequest(string Name, Guid CampaignId);
public sealed record CharacterSummary(Guid Id, string Name, Guid OwnerUserId, Guid CampaignId); public sealed record CharacterSummary(Guid Id, string Name, Guid OwnerUserId, Guid CampaignId);
public sealed record CreateSkillRequest(string Name, string DiceRollDefinition, int WildDice, bool AllowFumble); public sealed record CreateSkillRequest(string Name, string DiceRollDefinition, int WildDice, bool AllowFumble);
public sealed record UpdateSkillRequest(string Name, string DiceRollDefinition, int WildDice, bool AllowFumble); public sealed record UpdateSkillRequest(string Name, string DiceRollDefinition, int WildDice, bool AllowFumble);
public sealed record SkillSummary(Guid Id, Guid CharacterId, string Name, string DiceRollDefinition, int WildDice, bool AllowFumble); public sealed record SkillSummary(Guid Id, Guid CharacterId, string Name, string DiceRollDefinition, int WildDice, bool AllowFumble);
public sealed record RollSkillRequest(string Visibility); public sealed record RollSkillRequest(string Visibility);
public sealed record RollDieResult(int Roll, bool Crit, bool Fumble, bool Wild, bool Removed, bool Added);
public sealed record RollResult(
Guid RollId,
Guid CampaignId,
Guid CharacterId,
Guid SkillId,
Guid RollerUserId,
string Visibility,
int Result,
string Breakdown,
IReadOnlyList<RollDieResult> Dice,
DateTimeOffset TimestampUtc);
public sealed record CampaignLogEntry( public sealed record RollDieResult(int Roll, bool Crit, bool Fumble, bool Wild, bool Removed, bool Added);
Guid RollId,
Guid CampaignId, public sealed record RollResult(Guid RollId, Guid CampaignId, Guid CharacterId, Guid SkillId, Guid RollerUserId, string Visibility, int Result, string Breakdown, IReadOnlyList<RollDieResult> Dice, DateTimeOffset TimestampUtc);
Guid CharacterId,
Guid SkillId, public sealed record CampaignLogEntry(Guid RollId, Guid CampaignId, Guid CharacterId, Guid SkillId, Guid RollerUserId, string Visibility, int Result, string Breakdown, IReadOnlyList<RollDieResult> Dice, DateTimeOffset TimestampUtc);
Guid RollerUserId,
string Visibility,
int Result,
string Breakdown,
IReadOnlyList<RollDieResult> Dice,
DateTimeOffset TimestampUtc);

View File

@@ -5,18 +5,10 @@ namespace RpgRoller.Data;
public sealed class RpgRollerDbContext : DbContext public sealed class RpgRollerDbContext : DbContext
{ {
public RpgRollerDbContext(DbContextOptions<RpgRollerDbContext> options) public RpgRollerDbContext(DbContextOptions<RpgRollerDbContext> options) : base(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) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
modelBuilder.Entity<UserAccount>(entity => modelBuilder.Entity<UserAccount>(entity =>
@@ -77,4 +69,11 @@ public sealed class RpgRollerDbContext : DbContext
entity.HasIndex(x => x.CharacterId); entity.HasIndex(x => x.CharacterId);
}); });
} }
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>();
} }

View File

@@ -9,17 +9,13 @@ namespace RpgRoller.Hosting;
public static class ServiceCollectionExtensions public static class ServiceCollectionExtensions
{ {
public static IServiceCollection AddRpgRollerCore( public static IServiceCollection AddRpgRollerCore(this IServiceCollection services, IConfiguration configuration, IWebHostEnvironment environment)
this IServiceCollection services,
IConfiguration configuration,
IWebHostEnvironment environment)
{ {
var sqliteConnectionString = configuration.GetConnectionString("RpgRoller") ?? "Data Source=App_Data/rpgroller.db"; var sqliteConnectionString = configuration.GetConnectionString("RpgRoller") ?? "Data Source=App_Data/rpgroller.db";
EnsureSqliteDataDirectory(sqliteConnectionString, environment.ContentRootPath); EnsureSqliteDataDirectory(sqliteConnectionString, environment.ContentRootPath);
services.AddSingleton<IPasswordHasher<UserAccount>, PasswordHasher<UserAccount>>(); services.AddSingleton<IPasswordHasher<UserAccount>, PasswordHasher<UserAccount>>();
services.AddDbContextFactory<RpgRollerDbContext>(options => services.AddDbContextFactory<RpgRollerDbContext>(options => options.UseSqlite(sqliteConnectionString));
options.UseSqlite(sqliteConnectionString));
services.AddSingleton<IDiceRoller, RandomDiceRoller>(); services.AddSingleton<IDiceRoller, RandomDiceRoller>();
services.AddSingleton<IGameService, GameService>(); services.AddSingleton<IGameService, GameService>();
@@ -30,18 +26,12 @@ public static class ServiceCollectionExtensions
{ {
var builder = new SqliteConnectionStringBuilder(connectionString); var builder = new SqliteConnectionStringBuilder(connectionString);
if (string.IsNullOrWhiteSpace(builder.DataSource) || builder.DataSource == ":memory:") if (string.IsNullOrWhiteSpace(builder.DataSource) || builder.DataSource == ":memory:")
{
return; return;
}
var fullPath = Path.IsPathRooted(builder.DataSource) var fullPath = Path.IsPathRooted(builder.DataSource) ? builder.DataSource : Path.Combine(contentRootPath, builder.DataSource);
? builder.DataSource
: Path.Combine(contentRootPath, builder.DataSource);
var directory = Path.GetDirectoryName(fullPath); var directory = Path.GetDirectoryName(fullPath);
if (!string.IsNullOrWhiteSpace(directory)) if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory); Directory.CreateDirectory(directory);
} }
} }
}

View File

@@ -5,15 +5,10 @@ namespace RpgRoller.Hosting;
public static class SqliteSchemaUpgrader public static class SqliteSchemaUpgrader
{ {
private const string InitialMigrationId = "20260226084000_InitialSchema";
private const string ProductVersion = "10.0.2";
public static void ApplyPendingChanges(RpgRollerDbContext db) public static void ApplyPendingChanges(RpgRollerDbContext db)
{ {
if (db.Database.IsSqlite()) if (db.Database.IsSqlite())
{
EnsureLegacySchemaHistory(db); EnsureLegacySchemaHistory(db);
}
db.Database.Migrate(); db.Database.Migrate();
} }
@@ -24,23 +19,16 @@ public static class SqliteSchemaUpgrader
try try
{ {
if (TableExists(db, "__EFMigrationsHistory")) if (TableExists(db, "__EFMigrationsHistory"))
{
return; return;
}
if (!TableExists(db, "Skills")) if (!TableExists(db, "Skills"))
{
return; return;
}
if (!ColumnExists(db, "Skills", "WildDice") || !ColumnExists(db, "Skills", "AllowFumble")) if (!ColumnExists(db, "Skills", "WildDice") || !ColumnExists(db, "Skills", "AllowFumble"))
{
return; return;
}
using var createHistoryCommand = db.Database.GetDbConnection().CreateCommand(); using var createHistoryCommand = db.Database.GetDbConnection().CreateCommand();
createHistoryCommand.CommandText = createHistoryCommand.CommandText = """
"""
CREATE TABLE IF NOT EXISTS "__EFMigrationsHistory" ( CREATE TABLE IF NOT EXISTS "__EFMigrationsHistory" (
"MigrationId" TEXT NOT NULL CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY, "MigrationId" TEXT NOT NULL CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY,
"ProductVersion" TEXT NOT NULL "ProductVersion" TEXT NOT NULL
@@ -49,8 +37,7 @@ public static class SqliteSchemaUpgrader
_ = createHistoryCommand.ExecuteNonQuery(); _ = createHistoryCommand.ExecuteNonQuery();
using var insertHistoryCommand = db.Database.GetDbConnection().CreateCommand(); using var insertHistoryCommand = db.Database.GetDbConnection().CreateCommand();
insertHistoryCommand.CommandText = insertHistoryCommand.CommandText = """
"""
INSERT OR IGNORE INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") INSERT OR IGNORE INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ($migrationId, $productVersion); VALUES ($migrationId, $productVersion);
"""; """;
@@ -94,11 +81,12 @@ public static class SqliteSchemaUpgrader
{ {
var currentColumnName = reader.GetString(1); var currentColumnName = reader.GetString(1);
if (string.Equals(currentColumnName, columnName, StringComparison.OrdinalIgnoreCase)) if (string.Equals(currentColumnName, columnName, StringComparison.OrdinalIgnoreCase))
{
return true; return true;
} }
}
return false; return false;
} }
private const string InitialMigrationId = "20260226084000_InitialSchema";
private const string ProductVersion = "10.0.2";
} }

View File

@@ -1,11 +1,11 @@
using RpgRoller.Api; using RpgRoller.Api;
using RpgRoller.Components;
using RpgRoller.Hosting; using RpgRoller.Hosting;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRpgRollerCore(builder.Configuration, builder.Environment); builder.Services.AddRpgRollerCore(builder.Configuration, builder.Environment);
builder.Services.AddRazorComponents() builder.Services.AddRazorComponents().AddInteractiveServerComponents();
.AddInteractiveServerComponents(); builder.Services.AddScoped<RpgRollerApiClient>();
builder.Services.AddScoped<RpgRoller.Components.RpgRollerApiClient>();
var app = builder.Build(); var app = builder.Build();
app.InitializeRpgRollerState(); app.InitializeRpgRollerState();
@@ -14,8 +14,7 @@ app.UseStaticFiles();
app.UseAntiforgery(); app.UseAntiforgery();
app.MapRpgRollerApi(); app.MapRpgRollerApi();
app.MapRazorComponents<RpgRoller.Components.App>() app.MapRazorComponents<App>().AddInteractiveServerRenderMode();
.AddInteractiveServerRenderMode();
app.Run(); app.Run();
public partial class Program; public partial class Program;

View File

@@ -5,27 +5,13 @@ namespace RpgRoller.Services;
public static partial class DiceRules public static partial class DiceRules
{ {
private const int MaxDiceCount = 50;
private const int MaxSides = 1000;
private const int MaxModifier = 1000;
public static readonly IReadOnlyList<(RulesetKind Kind, string Id, string Name, string DiceSyntax)> SupportedRulesets =
[
(RulesetKind.D6, "d6", "D6 System", "countD(+modifier), e.g. 5D+4"),
(RulesetKind.Dnd5e, "dnd5e", "D&D 5e", "countdSides(+modifier), e.g. 2d12+2")
];
public static RulesetKind? TryParseRulesetId(string rulesetId) public static RulesetKind? TryParseRulesetId(string rulesetId)
{ {
if (string.Equals(rulesetId, "d6", StringComparison.OrdinalIgnoreCase)) if (string.Equals(rulesetId, "d6", StringComparison.OrdinalIgnoreCase))
{
return RulesetKind.D6; return RulesetKind.D6;
}
if (string.Equals(rulesetId, "dnd5e", StringComparison.OrdinalIgnoreCase)) if (string.Equals(rulesetId, "dnd5e", StringComparison.OrdinalIgnoreCase))
{
return RulesetKind.Dnd5e; return RulesetKind.Dnd5e;
}
return null; return null;
} }
@@ -43,9 +29,7 @@ public static partial class DiceRules
public static ServiceResult<DiceExpression> ParseExpression(RulesetKind ruleset, string expression) public static ServiceResult<DiceExpression> ParseExpression(RulesetKind ruleset, string expression)
{ {
if (string.IsNullOrWhiteSpace(expression)) if (string.IsNullOrWhiteSpace(expression))
{
return ServiceResult<DiceExpression>.Failure("invalid_expression", "Dice expression is required."); return ServiceResult<DiceExpression>.Failure("invalid_expression", "Dice expression is required.");
}
var trimmed = expression.Trim(); var trimmed = expression.Trim();
return ruleset switch return ruleset switch
@@ -60,57 +44,43 @@ public static partial class DiceRules
{ {
var match = D6Regex().Match(expression); var match = D6Regex().Match(expression);
if (!match.Success) if (!match.Success)
{
return ServiceResult<DiceExpression>.Failure("invalid_expression", "Expected d6 format like 5D+4."); return ServiceResult<DiceExpression>.Failure("invalid_expression", "Expected d6 format like 5D+4.");
}
var diceCount = int.Parse(match.Groups["count"].Value); var diceCount = int.Parse(match.Groups["count"].Value);
var modifier = ParseModifier(match.Groups["modifier"].Value); var modifier = ParseModifier(match.Groups["modifier"].Value);
var validation = ValidateDiceParts(diceCount, 6, modifier); var validation = ValidateDiceParts(diceCount, 6, modifier);
if (!validation.Succeeded) if (!validation.Succeeded)
{
return ServiceResult<DiceExpression>.Failure(validation.Error!.Code, validation.Error.Message); return ServiceResult<DiceExpression>.Failure(validation.Error!.Code, validation.Error.Message);
}
return ServiceResult<DiceExpression>.Success(new DiceExpression(diceCount, 6, modifier, $"{diceCount}D{FormatModifier(modifier)}")); return ServiceResult<DiceExpression>.Success(new(diceCount, 6, modifier, $"{diceCount}D{FormatModifier(modifier)}"));
} }
private static ServiceResult<DiceExpression> ParseDnd5e(string expression) private static ServiceResult<DiceExpression> ParseDnd5e(string expression)
{ {
var match = Dnd5eRegex().Match(expression); var match = Dnd5eRegex().Match(expression);
if (!match.Success) if (!match.Success)
{
return ServiceResult<DiceExpression>.Failure("invalid_expression", "Expected dnd5e format like 2d12+2."); return ServiceResult<DiceExpression>.Failure("invalid_expression", "Expected dnd5e format like 2d12+2.");
}
var diceCount = int.Parse(match.Groups["count"].Value); var diceCount = int.Parse(match.Groups["count"].Value);
var sides = int.Parse(match.Groups["sides"].Value); var sides = int.Parse(match.Groups["sides"].Value);
var modifier = ParseModifier(match.Groups["modifier"].Value); var modifier = ParseModifier(match.Groups["modifier"].Value);
var validation = ValidateDiceParts(diceCount, sides, modifier); var validation = ValidateDiceParts(diceCount, sides, modifier);
if (!validation.Succeeded) if (!validation.Succeeded)
{
return ServiceResult<DiceExpression>.Failure(validation.Error!.Code, validation.Error.Message); return ServiceResult<DiceExpression>.Failure(validation.Error!.Code, validation.Error.Message);
}
return ServiceResult<DiceExpression>.Success(new DiceExpression(diceCount, sides, modifier, $"{diceCount}d{sides}{FormatModifier(modifier)}")); return ServiceResult<DiceExpression>.Success(new(diceCount, sides, modifier, $"{diceCount}d{sides}{FormatModifier(modifier)}"));
} }
private static ServiceResult<bool> ValidateDiceParts(int diceCount, int sides, int modifier) private static ServiceResult<bool> ValidateDiceParts(int diceCount, int sides, int modifier)
{ {
if (diceCount < 1 || diceCount > MaxDiceCount) if (diceCount < 1 || diceCount > MaxDiceCount)
{
return ServiceResult<bool>.Failure("invalid_expression", $"Dice count must be between 1 and {MaxDiceCount}."); return ServiceResult<bool>.Failure("invalid_expression", $"Dice count must be between 1 and {MaxDiceCount}.");
}
if (sides < 2 || sides > MaxSides) if (sides < 2 || sides > MaxSides)
{
return ServiceResult<bool>.Failure("invalid_expression", $"Dice sides must be between 2 and {MaxSides}."); return ServiceResult<bool>.Failure("invalid_expression", $"Dice sides must be between 2 and {MaxSides}.");
}
if (modifier < 0 || modifier > MaxModifier) if (modifier < 0 || modifier > MaxModifier)
{
return ServiceResult<bool>.Failure("invalid_expression", $"Modifier must be between 0 and {MaxModifier}."); return ServiceResult<bool>.Failure("invalid_expression", $"Modifier must be between 0 and {MaxModifier}.");
}
return ServiceResult<bool>.Success(true); return ServiceResult<bool>.Success(true);
} }
@@ -130,4 +100,14 @@ public static partial class DiceRules
[GeneratedRegex("^(?<count>\\d+)d(?<sides>\\d+)(?:\\+(?<modifier>\\d+))?$", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase)] [GeneratedRegex("^(?<count>\\d+)d(?<sides>\\d+)(?:\\+(?<modifier>\\d+))?$", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase)]
private static partial Regex Dnd5eRegex(); private static partial Regex Dnd5eRegex();
private const int MaxDiceCount = 50;
private const int MaxSides = 1000;
private const int MaxModifier = 1000;
public static readonly IReadOnlyList<(RulesetKind Kind, string Id, string Name, string DiceSyntax)> SupportedRulesets =
[
(RulesetKind.D6, "d6", "D6 System", "countD(+modifier), e.g. 5D+4"),
(RulesetKind.Dnd5e, "dnd5e", "D&D 5e", "countdSides(+modifier), e.g. 2d12+2")
];
} }

View File

@@ -1,6 +1,6 @@
using System.Text.Json;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using System.Text.Json;
using RpgRoller.Contracts; using RpgRoller.Contracts;
using RpgRoller.Data; using RpgRoller.Data;
using RpgRoller.Domain; using RpgRoller.Domain;
@@ -9,19 +9,6 @@ namespace RpgRoller.Services;
public sealed class GameService : IGameService public sealed class GameService : IGameService
{ {
private static readonly JsonSerializerOptions DiceJsonOptions = new(JsonSerializerDefaults.Web);
private readonly object m_Gate = new();
private readonly IDbContextFactory<RpgRollerDbContext> m_DbContextFactory;
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.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(IDbContextFactory<RpgRollerDbContext> dbContextFactory, IPasswordHasher<UserAccount> passwordHasher, IDiceRoller diceRoller) public GameService(IDbContextFactory<RpgRollerDbContext> dbContextFactory, IPasswordHasher<UserAccount> passwordHasher, IDiceRoller diceRoller)
{ {
m_DbContextFactory = dbContextFactory; m_DbContextFactory = dbContextFactory;
@@ -32,36 +19,26 @@ public sealed class GameService : IGameService
public IReadOnlyList<RulesetDefinition> GetRulesets() public IReadOnlyList<RulesetDefinition> GetRulesets()
{ {
return DiceRules.SupportedRulesets return DiceRules.SupportedRulesets.Select(r => new RulesetDefinition(r.Id, r.Name, r.DiceSyntax)).ToArray();
.Select(r => new RulesetDefinition(r.Id, r.Name, r.DiceSyntax))
.ToArray();
} }
public ServiceResult<UserSummary> Register(string username, string password, string displayName) public ServiceResult<UserSummary> Register(string username, string password, string displayName)
{ {
if (string.IsNullOrWhiteSpace(username)) if (string.IsNullOrWhiteSpace(username))
{
return ServiceResult<UserSummary>.Failure("invalid_username", "Username is required."); return ServiceResult<UserSummary>.Failure("invalid_username", "Username is required.");
}
if (string.IsNullOrWhiteSpace(displayName)) if (string.IsNullOrWhiteSpace(displayName))
{
return ServiceResult<UserSummary>.Failure("invalid_display_name", "Display name is required."); return ServiceResult<UserSummary>.Failure("invalid_display_name", "Display name is required.");
}
if (string.IsNullOrWhiteSpace(password) || password.Length < 8) if (string.IsNullOrWhiteSpace(password) || password.Length < 8)
{
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) lock (m_Gate)
{ {
var trimmedUsername = username.Trim(); var trimmedUsername = username.Trim();
var normalizedUsername = NormalizeUsername(trimmedUsername); var normalizedUsername = NormalizeUsername(trimmedUsername);
if (m_UserIdsByUsername.ContainsKey(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.");
}
var user = new UserAccount var user = new UserAccount
{ {
@@ -86,29 +63,21 @@ public sealed class GameService : IGameService
public ServiceResult<(UserSummary User, string SessionToken)> Login(string username, string password) public ServiceResult<(UserSummary User, string SessionToken)> Login(string username, string password)
{ {
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password)) if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
{
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) lock (m_Gate)
{ {
var normalizedUsername = NormalizeUsername(username.Trim()); var normalizedUsername = NormalizeUsername(username.Trim());
if (!m_UserIdsByUsername.TryGetValue(normalizedUsername, out var userId)) if (!m_UserIdsByUsername.TryGetValue(normalizedUsername, out var userId))
{
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 user = m_UsersById[userId];
var verification = m_PasswordHasher.VerifyHashedPassword(user, user.PasswordHash, password); var verification = m_PasswordHasher.VerifyHashedPassword(user, user.PasswordHash, password);
if (verification == PasswordVerificationResult.Failed) if (verification == PasswordVerificationResult.Failed)
{
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.");
}
if (verification == PasswordVerificationResult.SuccessRehashNeeded) if (verification == PasswordVerificationResult.SuccessRehashNeeded)
{
user.PasswordHash = m_PasswordHasher.HashPassword(user, password); user.PasswordHash = m_PasswordHasher.HashPassword(user, password);
}
var session = CreateSession(userId); var session = CreateSession(userId);
PersistStateLocked(); PersistStateLocked();
@@ -121,11 +90,9 @@ public sealed class GameService : IGameService
lock (m_Gate) lock (m_Gate)
{ {
if (m_SessionsByToken.Remove(sessionToken)) if (m_SessionsByToken.Remove(sessionToken))
{
PersistStateLocked(); PersistStateLocked();
} }
} }
}
public UserSummary? GetUserBySession(string sessionToken) public UserSummary? GetUserBySession(string sessionToken)
{ {
@@ -142,9 +109,7 @@ public sealed class GameService : IGameService
{ {
var user = ResolveUserLocked(sessionToken); 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.");
}
Guid? campaignId = null; Guid? campaignId = null;
if (user.ActiveCharacterId is Guid activeCharacterId) if (user.ActiveCharacterId is Guid activeCharacterId)
@@ -155,35 +120,27 @@ public sealed class GameService : IGameService
PersistStateLocked(); PersistStateLocked();
} }
else else
{
campaignId = activeCharacter.CampaignId; campaignId = activeCharacter.CampaignId;
} }
}
return ServiceResult<MeResponse>.Success(new MeResponse(ToUserSummary(user), user.ActiveCharacterId, campaignId)); return ServiceResult<MeResponse>.Success(new(ToUserSummary(user), user.ActiveCharacterId, campaignId));
} }
} }
public ServiceResult<CampaignSummary> CreateCampaign(string sessionToken, string name, string rulesetId) public ServiceResult<CampaignSummary> CreateCampaign(string sessionToken, string name, string rulesetId)
{ {
if (string.IsNullOrWhiteSpace(name)) if (string.IsNullOrWhiteSpace(name))
{
return ServiceResult<CampaignSummary>.Failure("invalid_campaign_name", "Campaign name is required."); return ServiceResult<CampaignSummary>.Failure("invalid_campaign_name", "Campaign name is required.");
}
var ruleset = DiceRules.TryParseRulesetId(rulesetId); var ruleset = DiceRules.TryParseRulesetId(rulesetId);
if (ruleset is null) if (ruleset is null)
{
return ServiceResult<CampaignSummary>.Failure("invalid_ruleset", "Unknown ruleset."); return ServiceResult<CampaignSummary>.Failure("invalid_ruleset", "Unknown ruleset.");
}
lock (m_Gate) lock (m_Gate)
{ {
var user = ResolveUserLocked(sessionToken); 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.");
}
var campaign = new Campaign var campaign = new Campaign
{ {
@@ -206,21 +163,13 @@ public sealed class GameService : IGameService
{ {
var user = ResolveUserLocked(sessionToken); 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 = new HashSet<Guid>(m_CampaignsById.Values.Where(c => c.GmUserId == user.Id).Select(c => c.Id)); 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)) foreach (var character in m_CharactersById.Values.Where(c => c.OwnerUserId == user.Id))
{
campaignIds.Add(character.CampaignId); campaignIds.Add(character.CampaignId);
}
var results = campaignIds var results = campaignIds.Select(id => m_CampaignsById[id]).OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase).Select(ToCampaignSummary).ToArray();
.Select(id => m_CampaignsById[id])
.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase)
.Select(ToCampaignSummary)
.ToArray();
return ServiceResult<IReadOnlyList<CampaignSummary>>.Success(results); return ServiceResult<IReadOnlyList<CampaignSummary>>.Success(results);
} }
@@ -232,58 +181,35 @@ public sealed class GameService : IGameService
{ {
var context = ResolveContextLocked(sessionToken, campaignId); 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_UsersById[campaign.GmUserId]; var gm = m_UsersById[campaign.GmUserId];
var isGm = campaign.GmUserId == user.Id; var isGm = campaign.GmUserId == user.Id;
var characters = m_CharactersById.Values 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();
.Where(c => c.CampaignId == campaign.Id)
.Where(c => isGm || c.OwnerUserId == user.Id)
.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase)
.Select(ToCharacterSummary)
.ToArray();
var visibleCharacterIds = characters.Select(c => c.Id).ToHashSet(); var visibleCharacterIds = characters.Select(c => c.Id).ToHashSet();
var skills = m_SkillsById.Values var skills = m_SkillsById.Values.Where(s => visibleCharacterIds.Contains(s.CharacterId)).OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase).Select(ToSkillSummary).ToArray();
.Where(s => visibleCharacterIds.Contains(s.CharacterId))
.OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase)
.Select(ToSkillSummary)
.ToArray();
return ServiceResult<CampaignDetails>.Success(new CampaignDetails( return ServiceResult<CampaignDetails>.Success(new(campaign.Id, campaign.Name, DiceRules.ToRulesetId(campaign.Ruleset), ToUserSummary(gm), characters, skills));
campaign.Id,
campaign.Name,
DiceRules.ToRulesetId(campaign.Ruleset),
ToUserSummary(gm),
characters,
skills));
} }
} }
public ServiceResult<CharacterSummary> CreateCharacter(string sessionToken, string name, Guid campaignId) public ServiceResult<CharacterSummary> CreateCharacter(string sessionToken, string name, Guid campaignId)
{ {
if (string.IsNullOrWhiteSpace(name)) if (string.IsNullOrWhiteSpace(name))
{
return ServiceResult<CharacterSummary>.Failure("invalid_character_name", "Character name is required."); return ServiceResult<CharacterSummary>.Failure("invalid_character_name", "Character name is required.");
}
lock (m_Gate) lock (m_Gate)
{ {
var user = ResolveUserLocked(sessionToken); 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.");
}
if (!m_CampaignsById.ContainsKey(campaignId)) if (!m_CampaignsById.ContainsKey(campaignId))
{
return ServiceResult<CharacterSummary>.Failure("campaign_not_found", "Campaign was not found."); return ServiceResult<CharacterSummary>.Failure("campaign_not_found", "Campaign was not found.");
}
var character = new Character var character = new Character
{ {
@@ -304,36 +230,26 @@ public sealed class GameService : IGameService
public ServiceResult<CharacterSummary> UpdateCharacter(string sessionToken, Guid characterId, string name, Guid campaignId) public ServiceResult<CharacterSummary> UpdateCharacter(string sessionToken, Guid characterId, string name, Guid campaignId)
{ {
if (string.IsNullOrWhiteSpace(name)) if (string.IsNullOrWhiteSpace(name))
{
return ServiceResult<CharacterSummary>.Failure("invalid_character_name", "Character name is required."); return ServiceResult<CharacterSummary>.Failure("invalid_character_name", "Character name is required.");
}
lock (m_Gate) lock (m_Gate)
{ {
var user = ResolveUserLocked(sessionToken); 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.");
}
if (!m_CharactersById.TryGetValue(characterId, out var character)) if (!m_CharactersById.TryGetValue(characterId, out var character))
{
return ServiceResult<CharacterSummary>.Failure("character_not_found", "Character was not found."); return ServiceResult<CharacterSummary>.Failure("character_not_found", "Character was not found.");
}
if (!m_CampaignsById.TryGetValue(campaignId, out var targetCampaign)) if (!m_CampaignsById.TryGetValue(campaignId, out var targetCampaign))
{
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 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;
if (!isOwner && !isSourceGm && !isTargetGm) if (!isOwner && !isSourceGm && !isTargetGm)
{
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.");
}
var sourceCampaignId = character.CampaignId; var sourceCampaignId = character.CampaignId;
character.Name = name.Trim(); character.Name = name.Trim();
@@ -341,9 +257,7 @@ public sealed class GameService : IGameService
TouchCampaignLocked(sourceCampaignId); TouchCampaignLocked(sourceCampaignId);
if (sourceCampaignId != character.CampaignId) if (sourceCampaignId != character.CampaignId)
{
TouchCampaignLocked(character.CampaignId); TouchCampaignLocked(character.CampaignId);
}
PersistStateLocked(); PersistStateLocked();
return ServiceResult<CharacterSummary>.Success(ToCharacterSummary(character)); return ServiceResult<CharacterSummary>.Success(ToCharacterSummary(character));
@@ -356,19 +270,13 @@ public sealed class GameService : IGameService
{ {
var user = ResolveUserLocked(sessionToken); 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.");
}
if (!m_CharactersById.TryGetValue(characterId, out var character)) if (!m_CharactersById.TryGetValue(characterId, out var character))
{
return ServiceResult<bool>.Failure("character_not_found", "Character was not found."); return ServiceResult<bool>.Failure("character_not_found", "Character was not found.");
}
if (character.OwnerUserId != user.Id) if (character.OwnerUserId != user.Id)
{
return ServiceResult<bool>.Failure("forbidden", "You can activate only your own character."); return ServiceResult<bool>.Failure("forbidden", "You can activate only your own character.");
}
user.ActiveCharacterId = character.Id; user.ActiveCharacterId = character.Id;
PersistStateLocked(); PersistStateLocked();
@@ -382,20 +290,12 @@ public sealed class GameService : IGameService
{ {
var user = ResolveUserLocked(sessionToken); 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 (!TryGetCurrentCampaignIdLocked(user, out var campaignId)) 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 characters = m_CharactersById.Values var characters = m_CharactersById.Values.Where(c => c.CampaignId == campaignId && c.OwnerUserId == user.Id).OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase).Select(ToCharacterSummary).ToArray();
.Where(c => c.CampaignId == campaignId && c.OwnerUserId == user.Id)
.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase)
.Select(ToCharacterSummary)
.ToArray();
return ServiceResult<IReadOnlyList<CharacterSummary>>.Success(characters); return ServiceResult<IReadOnlyList<CharacterSummary>>.Success(characters);
} }
@@ -404,40 +304,28 @@ public sealed class GameService : IGameService
public ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble) public ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble)
{ {
if (string.IsNullOrWhiteSpace(name)) if (string.IsNullOrWhiteSpace(name))
{
return ServiceResult<SkillSummary>.Failure("invalid_skill_name", "Skill name is required."); return ServiceResult<SkillSummary>.Failure("invalid_skill_name", "Skill name is required.");
}
lock (m_Gate) lock (m_Gate)
{ {
var user = ResolveUserLocked(sessionToken); 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.");
}
if (!m_CharactersById.TryGetValue(characterId, out var character)) if (!m_CharactersById.TryGetValue(characterId, out var character))
{
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_CampaignsById[character.CampaignId]; var campaign = m_CampaignsById[character.CampaignId];
if (!CanEditCharacterLocked(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.");
}
var expressionValidation = DiceRules.ParseExpression(campaign.Ruleset, diceRollDefinition); var expressionValidation = DiceRules.ParseExpression(campaign.Ruleset, diceRollDefinition);
if (!expressionValidation.Succeeded) if (!expressionValidation.Succeeded)
{
return ServiceResult<SkillSummary>.Failure(expressionValidation.Error!.Code, expressionValidation.Error.Message); return ServiceResult<SkillSummary>.Failure(expressionValidation.Error!.Code, expressionValidation.Error.Message);
}
var optionsValidation = ValidateSkillOptions(campaign.Ruleset, wildDice, allowFumble); var optionsValidation = ValidateSkillOptions(campaign.Ruleset, wildDice, allowFumble);
if (!optionsValidation.Succeeded) if (!optionsValidation.Succeeded)
{
return ServiceResult<SkillSummary>.Failure(optionsValidation.Error!.Code, optionsValidation.Error.Message); return ServiceResult<SkillSummary>.Failure(optionsValidation.Error!.Code, optionsValidation.Error.Message);
}
var skill = new Skill var skill = new Skill
{ {
@@ -460,41 +348,29 @@ public sealed class GameService : IGameService
public ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble) public ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble)
{ {
if (string.IsNullOrWhiteSpace(name)) if (string.IsNullOrWhiteSpace(name))
{
return ServiceResult<SkillSummary>.Failure("invalid_skill_name", "Skill name is required."); return ServiceResult<SkillSummary>.Failure("invalid_skill_name", "Skill name is required.");
}
lock (m_Gate) lock (m_Gate)
{ {
var user = ResolveUserLocked(sessionToken); 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.");
}
if (!m_SkillsById.TryGetValue(skillId, out var skill)) if (!m_SkillsById.TryGetValue(skillId, out var skill))
{
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_CharactersById[skill.CharacterId]; var character = m_CharactersById[skill.CharacterId];
var campaign = m_CampaignsById[character.CampaignId]; var campaign = m_CampaignsById[character.CampaignId];
if (!CanEditCharacterLocked(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.");
}
var expressionValidation = DiceRules.ParseExpression(campaign.Ruleset, diceRollDefinition); var expressionValidation = DiceRules.ParseExpression(campaign.Ruleset, diceRollDefinition);
if (!expressionValidation.Succeeded) if (!expressionValidation.Succeeded)
{
return ServiceResult<SkillSummary>.Failure(expressionValidation.Error!.Code, expressionValidation.Error.Message); return ServiceResult<SkillSummary>.Failure(expressionValidation.Error!.Code, expressionValidation.Error.Message);
}
var optionsValidation = ValidateSkillOptions(campaign.Ruleset, wildDice, allowFumble); var optionsValidation = ValidateSkillOptions(campaign.Ruleset, wildDice, allowFumble);
if (!optionsValidation.Succeeded) if (!optionsValidation.Succeeded)
{
return ServiceResult<SkillSummary>.Failure(optionsValidation.Error!.Code, optionsValidation.Error.Message); return ServiceResult<SkillSummary>.Failure(optionsValidation.Error!.Code, optionsValidation.Error.Message);
}
skill.Name = name.Trim(); skill.Name = name.Trim();
skill.DiceRollDefinition = expressionValidation.Value!.Canonical; skill.DiceRollDefinition = expressionValidation.Value!.Canonical;
@@ -513,33 +389,23 @@ public sealed class GameService : IGameService
{ {
var user = ResolveUserLocked(sessionToken); 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.");
}
if (!m_SkillsById.TryGetValue(skillId, out var skill)) if (!m_SkillsById.TryGetValue(skillId, out var skill))
{
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_CharactersById[skill.CharacterId]; var character = m_CharactersById[skill.CharacterId];
var campaign = m_CampaignsById[character.CampaignId]; var campaign = m_CampaignsById[character.CampaignId];
if (!CanEditCharacterLocked(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.");
}
var parsedExpression = DiceRules.ParseExpression(campaign.Ruleset, skill.DiceRollDefinition); var parsedExpression = DiceRules.ParseExpression(campaign.Ruleset, skill.DiceRollDefinition);
if (!parsedExpression.Succeeded) if (!parsedExpression.Succeeded)
{
return ServiceResult<RollResult>.Failure(parsedExpression.Error!.Code, parsedExpression.Error.Message); return ServiceResult<RollResult>.Failure(parsedExpression.Error!.Code, parsedExpression.Error.Message);
}
var parsedVisibility = ParseVisibility(visibility); var parsedVisibility = ParseVisibility(visibility);
if (!parsedVisibility.Succeeded) if (!parsedVisibility.Succeeded)
{
return ServiceResult<RollResult>.Failure(parsedVisibility.Error!.Code, parsedVisibility.Error.Message); return ServiceResult<RollResult>.Failure(parsedVisibility.Error!.Code, parsedVisibility.Error.Message);
}
var roll = ComputeRoll(campaign.Ruleset, parsedExpression.Value!, skill); var roll = ComputeRoll(campaign.Ruleset, parsedExpression.Value!, skill);
var entry = new RollLogEntry var entry = new RollLogEntry
@@ -570,18 +436,10 @@ public sealed class GameService : IGameService
{ {
var context = ResolveContextLocked(sessionToken, campaignId); 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 entries = m_RollLog var entries = m_RollLog.Where(r => r.CampaignId == campaign.Id).Where(r => r.Visibility == RollVisibility.Public || r.RollerUserId == user.Id || campaign.GmUserId == user.Id).OrderBy(r => r.TimestampUtc).ThenBy(r => r.Id).Select(ToLogEntry).ToArray();
.Where(r => r.CampaignId == campaign.Id)
.Where(r => r.Visibility == RollVisibility.Public || r.RollerUserId == user.Id || campaign.GmUserId == user.Id)
.OrderBy(r => r.TimestampUtc)
.ThenBy(r => r.Id)
.Select(ToLogEntry)
.ToArray();
return ServiceResult<IReadOnlyList<CampaignLogEntry>>.Success(entries); return ServiceResult<IReadOnlyList<CampaignLogEntry>>.Success(entries);
} }
@@ -593,9 +451,7 @@ public sealed class GameService : IGameService
{ {
var context = ResolveContextLocked(sessionToken, campaignId); 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);
}
return ServiceResult<long>.Success(context.Value!.Campaign.Version); return ServiceResult<long>.Success(context.Value!.Campaign.Version);
} }
@@ -604,16 +460,12 @@ public sealed class GameService : IGameService
private static ServiceResult<(int WildDice, bool AllowFumble)> ValidateSkillOptions(RulesetKind ruleset, int wildDice, bool allowFumble) private static ServiceResult<(int WildDice, bool AllowFumble)> ValidateSkillOptions(RulesetKind ruleset, int wildDice, bool allowFumble)
{ {
if (wildDice < 0 || wildDice > 50) if (wildDice < 0 || wildDice > 50)
{
return ServiceResult<(int WildDice, bool AllowFumble)>.Failure("invalid_wild_dice", "Wild dice must be between 0 and 50."); return ServiceResult<(int WildDice, bool AllowFumble)>.Failure("invalid_wild_dice", "Wild dice must be between 0 and 50.");
}
if (ruleset == RulesetKind.D6) if (ruleset == RulesetKind.D6)
{ {
if (wildDice < 1) if (wildDice < 1)
{
return ServiceResult<(int WildDice, bool AllowFumble)>.Failure("invalid_wild_dice", "D6 skills require at least one wild die."); return ServiceResult<(int WildDice, bool AllowFumble)>.Failure("invalid_wild_dice", "D6 skills require at least one wild die.");
}
return ServiceResult<(int WildDice, bool AllowFumble)>.Success((wildDice, allowFumble)); return ServiceResult<(int WildDice, bool AllowFumble)>.Success((wildDice, allowFumble));
} }
@@ -623,9 +475,7 @@ public sealed class GameService : IGameService
private (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) ComputeRoll(RulesetKind ruleset, DiceExpression expression, Skill skill) private (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) ComputeRoll(RulesetKind ruleset, DiceExpression expression, Skill skill)
{ {
return ruleset == RulesetKind.D6 return ruleset == RulesetKind.D6 ? ComputeD6Roll(expression, skill.WildDice, skill.AllowFumble) : ComputeStandardRoll(expression);
? ComputeD6Roll(expression, skill.WildDice, skill.AllowFumble)
: ComputeStandardRoll(expression);
} }
private (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) ComputeStandardRoll(DiceExpression expression) private (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) ComputeStandardRoll(DiceExpression expression)
@@ -637,7 +487,7 @@ public sealed class GameService : IGameService
{ {
var value = m_DiceRoller.Roll(expression.Sides); var value = m_DiceRoller.Roll(expression.Sides);
diceValues[i] = value; diceValues[i] = value;
dice[i] = new RollDieResult(value, false, false, false, false, false); dice[i] = new(value, false, false, false, false, false);
total += value; total += value;
} }
@@ -686,17 +536,14 @@ public sealed class GameService : IGameService
} }
} }
dieResults.Add(new RollDieResult(roll, isCrit, isFumble, isWild, false, isAdded)); dieResults.Add(new(roll, isCrit, isFumble, isWild, false, isAdded));
} }
for (var roll = expression.Sides; roll >= 1 && pendingFumbles > 0; roll -= 1) for (var roll = expression.Sides; roll >= 1 && pendingFumbles > 0; roll -= 1)
{
for (var i = currentDice - 1; i >= 0 && pendingFumbles > 0; i -= 1) for (var i = currentDice - 1; i >= 0 && pendingFumbles > 0; i -= 1)
{ {
if (dieResults[i].Roll != roll) if (dieResults[i].Roll != roll)
{
continue; continue;
}
dieResults[i] = dieResults[i] with dieResults[i] = dieResults[i] with
{ {
@@ -707,7 +554,6 @@ public sealed class GameService : IGameService
}; };
pendingFumbles -= 1; pendingFumbles -= 1;
} }
}
var total = expression.Modifier; var total = expression.Modifier;
var includedDice = new List<int>(dieResults.Count); var includedDice = new List<int>(dieResults.Count);
@@ -732,9 +578,7 @@ public sealed class GameService : IGameService
{ {
var dicePart = string.Join("+", diceValues); var dicePart = string.Join("+", diceValues);
if (string.IsNullOrWhiteSpace(dicePart)) if (string.IsNullOrWhiteSpace(dicePart))
{
dicePart = "0"; dicePart = "0";
}
var modifierPart = modifier > 0 ? $"+{modifier}" : string.Empty; var modifierPart = modifier > 0 ? $"+{modifier}" : string.Empty;
return $"{dicePart}{modifierPart}={total}"; return $"{dicePart}{modifierPart}={total}";
@@ -743,14 +587,10 @@ public sealed class GameService : IGameService
private ServiceResult<RollVisibility> ParseVisibility(string visibility) private ServiceResult<RollVisibility> ParseVisibility(string visibility)
{ {
if (string.Equals(visibility, "public", StringComparison.OrdinalIgnoreCase)) if (string.Equals(visibility, "public", StringComparison.OrdinalIgnoreCase))
{
return ServiceResult<RollVisibility>.Success(RollVisibility.Public); return ServiceResult<RollVisibility>.Success(RollVisibility.Public);
}
if (string.Equals(visibility, "private", StringComparison.OrdinalIgnoreCase)) if (string.Equals(visibility, "private", StringComparison.OrdinalIgnoreCase))
{
return ServiceResult<RollVisibility>.Success(RollVisibility.Private); return ServiceResult<RollVisibility>.Success(RollVisibility.Private);
}
return ServiceResult<RollVisibility>.Failure("invalid_visibility", "Visibility must be 'public' or 'private'."); return ServiceResult<RollVisibility>.Failure("invalid_visibility", "Visibility must be 'public' or 'private'.");
} }
@@ -759,73 +599,47 @@ public sealed class GameService : IGameService
{ {
var user = ResolveUserLocked(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.");
}
if (!m_CampaignsById.TryGetValue(campaignId, out var campaign)) if (!m_CampaignsById.TryGetValue(campaignId, out var campaign))
{
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 (!CanViewCampaignLocked(user.Id, campaign.Id)) 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.");
}
return ServiceResult<(UserAccount User, Campaign Campaign)>.Success((user, campaign)); return ServiceResult<(UserAccount User, Campaign Campaign)>.Success((user, campaign));
} }
private static UserSummary ToUserSummary(UserAccount user) private static UserSummary ToUserSummary(UserAccount user)
{ {
return new UserSummary(user.Id, user.Username, user.DisplayName); return new(user.Id, user.Username, user.DisplayName);
} }
private static CampaignSummary ToCampaignSummary(Campaign campaign) private static CampaignSummary ToCampaignSummary(Campaign campaign)
{ {
return new CampaignSummary(campaign.Id, campaign.Name, DiceRules.ToRulesetId(campaign.Ruleset), campaign.GmUserId); return new(campaign.Id, campaign.Name, DiceRules.ToRulesetId(campaign.Ruleset), campaign.GmUserId);
} }
private static CharacterSummary ToCharacterSummary(Character character) private static CharacterSummary ToCharacterSummary(Character character)
{ {
return new CharacterSummary(character.Id, character.Name, character.OwnerUserId, character.CampaignId); return new(character.Id, character.Name, character.OwnerUserId, character.CampaignId);
} }
private static SkillSummary ToSkillSummary(Skill skill) private static SkillSummary ToSkillSummary(Skill skill)
{ {
return new SkillSummary(skill.Id, skill.CharacterId, skill.Name, skill.DiceRollDefinition, skill.WildDice, skill.AllowFumble); return new(skill.Id, skill.CharacterId, skill.Name, skill.DiceRollDefinition, skill.WildDice, skill.AllowFumble);
} }
private static RollResult ToRollResult(RollLogEntry entry, IReadOnlyList<RollDieResult> dice) private static RollResult ToRollResult(RollLogEntry entry, IReadOnlyList<RollDieResult> dice)
{ {
return new RollResult( return new(entry.Id, entry.CampaignId, entry.CharacterId, entry.SkillId, entry.RollerUserId, entry.Visibility == RollVisibility.Public ? "public" : "private", entry.Result, entry.Breakdown, dice, entry.TimestampUtc);
entry.Id,
entry.CampaignId,
entry.CharacterId,
entry.SkillId,
entry.RollerUserId,
entry.Visibility == RollVisibility.Public ? "public" : "private",
entry.Result,
entry.Breakdown,
dice,
entry.TimestampUtc);
} }
private static CampaignLogEntry ToLogEntry(RollLogEntry entry) private static CampaignLogEntry ToLogEntry(RollLogEntry entry)
{ {
var dice = DeserializeDice(entry.Dice); var dice = DeserializeDice(entry.Dice);
return new CampaignLogEntry( return new(entry.Id, entry.CampaignId, entry.CharacterId, entry.SkillId, entry.RollerUserId, entry.Visibility == RollVisibility.Public ? "public" : "private", entry.Result, entry.Breakdown, dice, entry.TimestampUtc);
entry.Id,
entry.CampaignId,
entry.CharacterId,
entry.SkillId,
entry.RollerUserId,
entry.Visibility == RollVisibility.Public ? "public" : "private",
entry.Result,
entry.Breakdown,
dice,
entry.TimestampUtc);
} }
private static string SerializeDice(IReadOnlyList<RollDieResult> dice) private static string SerializeDice(IReadOnlyList<RollDieResult> dice)
@@ -836,9 +650,7 @@ public sealed class GameService : IGameService
private static IReadOnlyList<RollDieResult> DeserializeDice(string serializedDice) private static IReadOnlyList<RollDieResult> DeserializeDice(string serializedDice)
{ {
if (string.IsNullOrWhiteSpace(serializedDice)) if (string.IsNullOrWhiteSpace(serializedDice))
{
return []; return [];
}
try try
{ {
@@ -859,9 +671,7 @@ public sealed class GameService : IGameService
{ {
var campaign = m_CampaignsById[campaignId]; var campaign = m_CampaignsById[campaignId];
if (campaign.GmUserId == userId) if (campaign.GmUserId == userId)
{
return true; return true;
}
return m_CharactersById.Values.Any(c => c.CampaignId == campaignId && c.OwnerUserId == userId); return m_CharactersById.Values.Any(c => c.CampaignId == campaignId && c.OwnerUserId == userId);
} }
@@ -870,9 +680,7 @@ public sealed class GameService : IGameService
{ {
campaignId = Guid.Empty; campaignId = Guid.Empty;
if (user.ActiveCharacterId is not Guid activeCharacterId) if (user.ActiveCharacterId is not Guid activeCharacterId)
{
return false; return false;
}
if (!m_CharactersById.TryGetValue(activeCharacterId, out var character)) if (!m_CharactersById.TryGetValue(activeCharacterId, out var character))
{ {
@@ -902,14 +710,10 @@ public sealed class GameService : IGameService
private UserAccount? ResolveUserLocked(string sessionToken) private UserAccount? ResolveUserLocked(string sessionToken)
{ {
if (string.IsNullOrWhiteSpace(sessionToken)) if (string.IsNullOrWhiteSpace(sessionToken))
{
return null; return null;
}
if (!m_SessionsByToken.TryGetValue(sessionToken, out var session)) if (!m_SessionsByToken.TryGetValue(sessionToken, out var session))
{
return null; return null;
}
return m_UsersById.GetValueOrDefault(session.UserId); return m_UsersById.GetValueOrDefault(session.UserId);
} }
@@ -917,10 +721,8 @@ public sealed class GameService : IGameService
private void TouchCampaignLocked(Guid campaignId) private void TouchCampaignLocked(Guid campaignId)
{ {
if (m_CampaignsById.TryGetValue(campaignId, out var campaign)) if (m_CampaignsById.TryGetValue(campaignId, out var campaign))
{
campaign.Version += 1; campaign.Version += 1;
} }
}
private void LoadStateFromDatabase() private void LoadStateFromDatabase()
{ {
@@ -930,10 +732,7 @@ public sealed class GameService : IGameService
var campaigns = db.Campaigns.AsNoTracking().ToList(); var campaigns = db.Campaigns.AsNoTracking().ToList();
var characters = db.Characters.AsNoTracking().ToList(); var characters = db.Characters.AsNoTracking().ToList();
var skills = db.Skills.AsNoTracking().ToList(); var skills = db.Skills.AsNoTracking().ToList();
var logEntries = db.RollLogEntries.AsNoTracking().ToList() var logEntries = db.RollLogEntries.AsNoTracking().ToList().OrderBy(x => x.TimestampUtc).ThenBy(x => x.Id).ToList();
.OrderBy(x => x.TimestampUtc)
.ThenBy(x => x.Id)
.ToList();
lock (m_Gate) lock (m_Gate)
{ {
@@ -947,9 +746,7 @@ public sealed class GameService : IGameService
foreach (var user in users) foreach (var user in users)
{ {
var normalizedUsername = string.IsNullOrWhiteSpace(user.UsernameNormalized) var normalizedUsername = string.IsNullOrWhiteSpace(user.UsernameNormalized) ? NormalizeUsername(user.Username) : user.UsernameNormalized;
? NormalizeUsername(user.Username)
: user.UsernameNormalized;
var storedUser = new UserAccount var storedUser = new UserAccount
{ {
@@ -968,25 +765,17 @@ public sealed class GameService : IGameService
foreach (var session in sessions) foreach (var session in sessions)
{ {
if (m_UsersById.ContainsKey(session.UserId)) if (m_UsersById.ContainsKey(session.UserId))
{
m_SessionsByToken[session.Token] = CloneSession(session); m_SessionsByToken[session.Token] = CloneSession(session);
} }
}
foreach (var campaign in campaigns) foreach (var campaign in campaigns)
{
m_CampaignsById[campaign.Id] = CloneCampaign(campaign); m_CampaignsById[campaign.Id] = CloneCampaign(campaign);
}
foreach (var character in characters) foreach (var character in characters)
{
m_CharactersById[character.Id] = CloneCharacter(character); m_CharactersById[character.Id] = CloneCharacter(character);
}
foreach (var skill in skills) foreach (var skill in skills)
{
m_SkillsById[skill.Id] = CloneSkill(skill); m_SkillsById[skill.Id] = CloneSkill(skill);
}
m_RollLog.AddRange(logEntries.Select(CloneRollLogEntry)); m_RollLog.AddRange(logEntries.Select(CloneRollLogEntry));
} }
@@ -1022,7 +811,7 @@ public sealed class GameService : IGameService
private static UserAccount CloneUser(UserAccount user) private static UserAccount CloneUser(UserAccount user)
{ {
return new UserAccount return new()
{ {
Id = user.Id, Id = user.Id,
Username = user.Username, Username = user.Username,
@@ -1035,7 +824,7 @@ public sealed class GameService : IGameService
private static UserSession CloneSession(UserSession session) private static UserSession CloneSession(UserSession session)
{ {
return new UserSession return new()
{ {
Token = session.Token, Token = session.Token,
UserId = session.UserId, UserId = session.UserId,
@@ -1045,7 +834,7 @@ public sealed class GameService : IGameService
private static Campaign CloneCampaign(Campaign campaign) private static Campaign CloneCampaign(Campaign campaign)
{ {
return new Campaign return new()
{ {
Id = campaign.Id, Id = campaign.Id,
GmUserId = campaign.GmUserId, GmUserId = campaign.GmUserId,
@@ -1057,7 +846,7 @@ public sealed class GameService : IGameService
private static Character CloneCharacter(Character character) private static Character CloneCharacter(Character character)
{ {
return new Character return new()
{ {
Id = character.Id, Id = character.Id,
OwnerUserId = character.OwnerUserId, OwnerUserId = character.OwnerUserId,
@@ -1068,7 +857,7 @@ public sealed class GameService : IGameService
private static Skill CloneSkill(Skill skill) private static Skill CloneSkill(Skill skill)
{ {
return new Skill return new()
{ {
Id = skill.Id, Id = skill.Id,
CharacterId = skill.CharacterId, CharacterId = skill.CharacterId,
@@ -1081,7 +870,7 @@ public sealed class GameService : IGameService
private static RollLogEntry CloneRollLogEntry(RollLogEntry entry) private static RollLogEntry CloneRollLogEntry(RollLogEntry entry)
{ {
return new RollLogEntry return new()
{ {
Id = entry.Id, Id = entry.Id,
CampaignId = entry.CampaignId, CampaignId = entry.CampaignId,
@@ -1095,4 +884,17 @@ public sealed class GameService : IGameService
TimestampUtc = entry.TimestampUtc TimestampUtc = entry.TimestampUtc
}; };
} }
private static readonly JsonSerializerOptions DiceJsonOptions = new(JsonSerializerDefaults.Web);
private readonly Dictionary<Guid, Campaign> m_CampaignsById = [];
private readonly Dictionary<Guid, Character> m_CharactersById = [];
private readonly IDbContextFactory<RpgRollerDbContext> m_DbContextFactory;
private readonly IDiceRoller m_DiceRoller;
private readonly object m_Gate = new();
private readonly IPasswordHasher<UserAccount> m_PasswordHasher;
private readonly List<RollLogEntry> m_RollLog = [];
private readonly Dictionary<string, UserSession> m_SessionsByToken = new(StringComparer.Ordinal);
private readonly Dictionary<Guid, Skill> m_SkillsById = [];
private readonly Dictionary<string, Guid> m_UserIdsByUsername = new(StringComparer.Ordinal);
private readonly Dictionary<Guid, UserAccount> m_UsersById = [];
} }

View File

@@ -1,5 +1,4 @@
using RpgRoller.Contracts; using RpgRoller.Contracts;
using RpgRoller.Domain;
namespace RpgRoller.Services; namespace RpgRoller.Services;

View File

@@ -1,9 +1,11 @@
using System.Security.Cryptography;
namespace RpgRoller.Services; namespace RpgRoller.Services;
public sealed class RandomDiceRoller : IDiceRoller public sealed class RandomDiceRoller : IDiceRoller
{ {
public int Roll(int sides) public int Roll(int sides)
{ {
return System.Security.Cryptography.RandomNumberGenerator.GetInt32(1, sides + 1); return RandomNumberGenerator.GetInt32(1, sides + 1);
} }
} }

View File

@@ -1 +1,2 @@
// Intentionally left blank after removing service command records. // Intentionally left blank after removing service command records.

View File

@@ -14,17 +14,17 @@ public sealed class ServiceResult<T>
Error = error; Error = error;
} }
public T? Value { get; }
public ServiceError? Error { get; }
public bool Succeeded => Error is null;
public static ServiceResult<T> Success(T value) public static ServiceResult<T> Success(T value)
{ {
return new ServiceResult<T>(value); return new(value);
} }
public static ServiceResult<T> Failure(string code, string message) public static ServiceResult<T> Failure(string code, string message)
{ {
return new ServiceResult<T>(new ServiceError(code, message)); return new(new ServiceError(code, message));
} }
public T? Value { get; }
public ServiceError? Error { get; }
public bool Succeeded => Error is null;
} }

View File

@@ -63,8 +63,7 @@ window.rpgRollerApi = (() => {
const payload = JSON.parse(event.data); const payload = JSON.parse(event.data);
const version = typeof payload.version === "number" ? payload.version : 0; const version = typeof payload.version === "number" ? payload.version : 0;
invokeDotNet("OnStateEventReceived", version); invokeDotNet("OnStateEventReceived", version);
} } catch {
catch {
invokeDotNet("OnStateEventReceived", 0); invokeDotNet("OnStateEventReceived", 0);
} }
}); });
@@ -124,8 +123,7 @@ window.rpgRollerApi = (() => {
let response; let response;
try { try {
response = await fetch(url, options); response = await fetch(url, options);
} } catch (error) {
catch (error) {
return { return {
ok: false, ok: false,
status: 0, status: 0,
@@ -138,8 +136,7 @@ window.rpgRollerApi = (() => {
if (text) { if (text) {
try { try {
parsed = JSON.parse(text); parsed = JSON.parse(text);
} } catch {
catch {
parsed = null; parsed = null;
} }
} }

View File

@@ -27,8 +27,7 @@ body {
} }
body { body {
background: background: radial-gradient(circle at 15% 10%, rgba(255, 255, 255, 0.32), transparent 45%),
radial-gradient(circle at 15% 10%, rgba(255, 255, 255, 0.32), transparent 45%),
linear-gradient(165deg, var(--bg-top), var(--bg-bottom)); linear-gradient(165deg, var(--bg-top), var(--bg-bottom));
color: var(--text); color: var(--text);
font-family: "Trebuchet MS", "Lucida Sans Unicode", "Segoe UI", sans-serif; font-family: "Trebuchet MS", "Lucida Sans Unicode", "Segoe UI", sans-serif;