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;
public sealed class AuthApiTests : ApiTestBase
{
public AuthApiTests(WebApplicationFactory<Program> factory)
: base(factory)
public AuthApiTests(WebApplicationFactory<Program> factory) : base(factory)
{
}
@@ -13,7 +10,7 @@ public sealed class AuthApiTests : ApiTestBase
public async Task RegisterLoginAndMeFlow_WorksWithDuplicateUsernameGuard()
{
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");
Assert.Equal("alice", registerResult.Username);

View File

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

View File

@@ -1,11 +1,8 @@
using Microsoft.AspNetCore.Mvc.Testing;
namespace RpgRoller.Tests;
public sealed class FrontendHostTests : ApiTestBase
{
public FrontendHostTests(WebApplicationFactory<Program> factory)
: base(factory)
public FrontendHostTests(WebApplicationFactory<Program> factory) : base(factory)
{
}
@@ -13,7 +10,7 @@ public sealed class FrontendHostTests : ApiTestBase
public async Task RootPath_ServesBlazorFrontendShell()
{
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("/");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);

View File

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

View File

@@ -1,11 +1,8 @@
using Microsoft.AspNetCore.Mvc.Testing;
namespace RpgRoller.Tests;
public sealed class SystemApiTests : ApiTestBase
{
public SystemApiTests(WebApplicationFactory<Program> factory)
: base(factory)
public SystemApiTests(WebApplicationFactory<Program> factory) : base(factory)
{
}
@@ -13,7 +10,7 @@ public sealed class SystemApiTests : ApiTestBase
public async Task RulesetAndSseEndpoints_ReturnExpectedResponses()
{
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");
Assert.Equal(2, rulesets.Count);
@@ -21,10 +18,7 @@ public sealed class SystemApiTests : ApiTestBase
await RegisterAsync(client, "sse", "Password123", "Sse User");
await LoginAsync(client, "sse", "Password123");
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(
client,
"/api/campaigns",
new CreateCampaignRequest("SSE", "d6"));
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(client, "/api/campaigns", new("SSE", "d6"));
var sseResponse = await client.GetAsync($"/api/events/state?campaignId={campaign.Id}", HttpCompletionOption.ResponseHeadersRead);
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");
Assert.NotNull(extensionsType);
var method = extensionsType!.GetMethod(
"GetRequiredSessionToken",
BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic);
var method = extensionsType!.GetMethod("GetRequiredSessionToken", BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic);
Assert.NotNull(method);
var context = new DefaultHttpContext();

View File

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

View File

@@ -8,19 +8,19 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
<PackageReference Include="coverlet.collector" Version="6.0.4"/>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1"/>
<PackageReference Include="xunit" Version="2.9.3"/>
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4"/>
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
<Using Include="Xunit"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\RpgRoller\RpgRoller.csproj" />
<ProjectReference Include="..\RpgRoller\RpgRoller.csproj"/>
</ItemGroup>
</Project>

View File

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

View File

@@ -1,5 +1,3 @@
using RpgRoller.Services;
namespace RpgRoller.Tests;
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.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using RpgRoller.Contracts;
using RpgRoller.Data;
using RpgRoller.Services;
namespace RpgRoller.Tests;
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)
{
@@ -21,8 +30,7 @@ public abstract class ApiTestBase : IClassFixture<WebApplicationFactory<Program>
protected WebApplicationFactory<Program> CreateFactory(params int[] rollValues)
{
return m_BaseFactory.WithWebHostBuilder(builder =>
builder.ConfigureServices(services =>
return m_BaseFactory.WithWebHostBuilder(builder => builder.ConfigureServices(services =>
{
services.RemoveAll<IDiceRoller>();
services.AddSingleton<IDiceRoller>(new FixedDiceRoller(rollValues));
@@ -32,17 +40,13 @@ public abstract class ApiTestBase : IClassFixture<WebApplicationFactory<Program>
services.RemoveAll<RpgRollerDbContext>();
var dbPath = Path.Combine(Path.GetTempPath(), $"rpgroller-tests-{Guid.NewGuid():N}.db");
services.AddDbContextFactory<RpgRollerDbContext>(options =>
options.UseSqlite($"Data Source={dbPath}"));
services.AddDbContextFactory<RpgRollerDbContext>(options => options.UseSqlite($"Data Source={dbPath}"));
}));
}
protected static async Task<UserSummary> RegisterAsync(HttpClient client, string username, string password, string displayName)
{
return await PostAsync<RegisterRequest, UserSummary>(
client,
"/api/auth/register",
new RegisterRequest(username, password, displayName));
return await PostAsync<RegisterRequest, UserSummary>(client, "/api/auth/register", new(username, password, displayName));
}
protected static async Task LoginAsync(HttpClient client, string username, string password)
@@ -78,19 +82,5 @@ public abstract class ApiTestBase : IClassFixture<WebApplicationFactory<Program>
return result;
}
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);
}
}
private readonly WebApplicationFactory<Program> m_BaseFactory;
}

View File

@@ -1,13 +1,86 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using RpgRoller.Data;
using RpgRoller.Domain;
using RpgRoller.Services;
namespace RpgRoller.Tests;
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)
{
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)
{
var options = new DbContextOptionsBuilder<RpgRollerDbContext>()
.UseSqlite($"Data Source={dbPath}")
.Options;
var options = new DbContextOptionsBuilder<RpgRollerDbContext>().UseSqlite($"Data Source={dbPath}").Options;
using (var db = new RpgRollerDbContext(options))
{
@@ -37,7 +108,7 @@ internal static class ServiceTestSupport
var factory = new SqliteDbContextFactory(dbPath);
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)
@@ -46,84 +117,4 @@ internal static class ServiceTestSupport
Assert.NotNull(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.MapAuthEndpoints();
var authenticatedApi = api.MapGroup(string.Empty)
.AddEndpointFilter<RequireSessionTokenFilter>();
var authenticatedApi = api.MapGroup(string.Empty).AddEndpointFilter<RequireSessionTokenFilter>();
authenticatedApi.MapMeEndpoints();
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)
{
if (result.Succeeded)
{
return TypedResults.Ok(result.Value!);
}
if (result.Error!.Code == "unauthorized")
{
return TypedResults.Unauthorized();
}
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);
if (!result.Succeeded)
{
return ApiResultMapper.ToBadRequest(result.Error!);
}
return TypedResults.Ok(result.Value!);
});
@@ -23,11 +21,9 @@ internal static class AuthEndpoints
{
var result = game.Login(request.Username, request.Password);
if (!result.Succeeded)
{
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,
SameSite = SameSiteMode.Strict,
@@ -41,9 +37,7 @@ internal static class AuthEndpoints
group.MapPost("/auth/logout", (HttpContext context, IGameService game) =>
{
if (context.TryReadSessionTokenFromCookie(out var sessionToken))
{
game.Logout(sessionToken);
}
context.Response.Cookies.Delete(SessionCookie.Name);
return TypedResults.NoContent();

View File

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

View File

@@ -2,8 +2,6 @@ namespace RpgRoller.Api;
internal static class SessionTokenHttpContextExtensions
{
private const string SessionTokenItemKey = "__rpgroller.session-token";
public static bool TryReadSessionTokenFromCookie(this HttpContext context, out string sessionToken)
{
sessionToken = context.Request.Cookies[SessionCookie.Name] ?? string.Empty;
@@ -17,13 +15,11 @@ internal static class SessionTokenHttpContextExtensions
public static string GetRequiredSessionToken(this HttpContext context)
{
if (context.Items.TryGetValue(SessionTokenItemKey, out var token)
&& token is string sessionToken
&& !string.IsNullOrWhiteSpace(sessionToken))
{
if (context.Items.TryGetValue(SessionTokenItemKey, out var token) && token is string sessionToken && !string.IsNullOrWhiteSpace(sessionToken))
return sessionToken;
}
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)
{
group.MapGet("/events/state", async Task<IResult> (
Guid campaignId,
HttpContext context,
IGameService game) =>
group.MapGet("/events/state", async Task<IResult> (Guid campaignId, HttpContext context, IGameService game) =>
{
var sessionToken = context.GetRequiredSessionToken();
var versionResult = game.GetCampaignVersion(sessionToken, campaignId);
if (!versionResult.Succeeded)
{
return versionResult.Error!.Code == "unauthorized"
? TypedResults.Unauthorized()
: TypedResults.BadRequest(new ApiError(versionResult.Error.Message));
return versionResult.Error!.Code == "unauthorized" ? TypedResults.Unauthorized() : TypedResults.BadRequest(new ApiError(versionResult.Error.Message));
}
context.Response.Headers.CacheControl = "no-cache";
@@ -37,9 +32,7 @@ internal static class StateEventEndpoints
var currentVersionResult = game.GetCampaignVersion(sessionToken, campaignId);
if (!currentVersionResult.Succeeded)
{
break;
}
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");
}
else
{
await context.Response.WriteAsync(": heartbeat\n\n");
}
await context.Response.Body.FlushAsync();
}

View File

@@ -1,19 +1,18 @@
@using Microsoft.AspNetCore.Components.Web
@attribute [ExcludeFromCodeCoverage]
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/" />
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<base href="/"/>
<title>RpgRoller</title>
<link rel="stylesheet" href="/styles.css" />
<HeadOutlet @rendermode="InteractiveServer" />
<link rel="stylesheet" href="/styles.css"/>
<HeadOutlet @rendermode="InteractiveServer"/>
</head>
<body>
<Routes @rendermode="InteractiveServer" />
<script src="/js/rpgroller-api.js"></script>
<script src="_framework/blazor.web.js"></script>
<Routes @rendermode="InteractiveServer"/>
<script src="/js/rpgroller-api.js"></script>
<script src="_framework/blazor.web.js"></script>
</body>
</html>

View File

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

View File

@@ -17,11 +17,11 @@
<AuthSection
StatusMessage="StatusMessage"
StatusIsError="StatusIsError"
LoggedIn="OnLoggedInAsync" />
LoggedIn="OnLoggedInAsync"/>
</div>
break;
case HomeViewMode.Workspace:
<Workspace LoggedOut="OnLoggedOutAsync" />
<Workspace LoggedOut="OnLoggedOutAsync"/>
break;
}

View File

@@ -1,6 +1,5 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Components;
using RpgRoller.Components;
using RpgRoller.Contracts;
namespace RpgRoller.Components.Pages;
@@ -8,20 +7,10 @@ namespace RpgRoller.Components.Pages;
[ExcludeFromCodeCoverage]
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)
{
if (!firstRender || HasInitialized)
{
return;
}
HasInitialized = true;
await InitializeAsync();
@@ -78,4 +67,12 @@ public partial class Home
StatusMessage = null;
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">
<h1>RpgRoller</h1>
<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>
<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))
{
<p class="field-error">@registerUsernameError</p>
}
<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))
{
<p class="field-error">@registerDisplayNameError</p>
}
<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))
{
<p class="field-error">@registerPasswordError</p>
@@ -48,13 +46,15 @@
}
<form class="form-grid" @onsubmit="SubmitLoginAsync" @onsubmit:preventDefault>
<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))
{
<p class="field-error">@loginUsernameError</p>
}
<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))
{
<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 Microsoft.AspNetCore.Components;
using RpgRoller.Contracts;
namespace RpgRoller.Components.Pages.HomeControls;
[ExcludeFromCodeCoverage]
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()
{
RegisterState.ResetValidation();
var model = RegisterState.Model;
if (string.IsNullOrWhiteSpace(model.Username))
{
RegisterState.Errors["username"] = "Username is required.";
}
if (string.IsNullOrWhiteSpace(model.DisplayName))
{
RegisterState.Errors["displayName"] = "Display name is required.";
}
if (string.IsNullOrWhiteSpace(model.Password) || model.Password.Length < 8)
{
RegisterState.Errors["password"] = "Password must be at least 8 characters.";
}
if (RegisterState.Errors.Count > 0)
{
@@ -54,10 +30,7 @@ public partial class AuthSection
IsSubmitting = true;
try
{
_ = await ApiClient.RequestAsync<UserSummary>(
"POST",
"/api/auth/register",
new RegisterRequest(model.Username.Trim(), model.Password, model.DisplayName.Trim()));
_ = await ApiClient.RequestAsync<UserSummary>("POST", "/api/auth/register", new RegisterRequest(model.Username.Trim(), model.Password, model.DisplayName.Trim()));
model.Password = string.Empty;
RegisterState.ErrorMessage = "Registration successful. You can log in now.";
@@ -65,14 +38,10 @@ public partial class AuthSection
catch (ApiRequestException ex)
{
if (ex.Message.Contains("already taken", StringComparison.OrdinalIgnoreCase))
{
RegisterState.Errors["username"] = "Username is already taken. Choose another one.";
}
else
{
RegisterState.ErrorMessage = ex.Message;
}
}
finally
{
IsSubmitting = false;
@@ -85,14 +54,10 @@ public partial class AuthSection
var model = LoginState.Model;
if (string.IsNullOrWhiteSpace(model.Username))
{
LoginState.Errors["username"] = "Username is required.";
}
if (string.IsNullOrWhiteSpace(model.Password))
{
LoginState.Errors["password"] = "Password is required.";
}
if (LoginState.Errors.Count > 0)
{
@@ -103,10 +68,7 @@ public partial class AuthSection
IsSubmitting = true;
try
{
_ = await ApiClient.RequestAsync<UserSummary>(
"POST",
"/api/auth/login",
new LoginRequest(model.Username.Trim(), model.Password));
_ = await ApiClient.RequestAsync<UserSummary>("POST", "/api/auth/login", new LoginRequest(model.Username.Trim(), model.Password));
model.Password = string.Empty;
await LoggedIn.InvokeAsync();
@@ -120,4 +82,20 @@ public partial class AuthSection
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">
<div class="section-head"><h2>Campaign Log</h2></div>
@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)
{
@@ -17,11 +18,16 @@
@foreach (var entry in CampaignLog)
{
<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>
<RollDiceStrip Dice="entry.Dice" AriaLabel="Log roll dice" />
<RollDiceStrip Dice="entry.Dice" AriaLabel="Log roll dice"/>
<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>
}
</ul>

View File

@@ -1,6 +1,6 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Components;
using RpgRoller.Contracts;
using System.Diagnostics.CodeAnalysis;
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">
<section class="card">
<h2>Campaign Selector</h2>
@@ -16,7 +11,9 @@
<select id="campaign-select" @onchange="CampaignSelectionChanged">
@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>
}
@@ -31,7 +28,7 @@
}
<form class="form-grid" @onsubmit="SubmitCreateCampaignAsync" @onsubmit:preventDefault>
<label for="campaign-name">Campaign name</label>
<input id="campaign-name" @bind="CampaignState.Model.Name" @bind:event="oninput" />
<input id="campaign-name" @bind="CampaignState.Model.Name" @bind:event="oninput"/>
@if (CampaignState.Errors.TryGetValue("name", out var campaignNameError))
{
<p class="field-error">@campaignNameError</p>
@@ -48,7 +45,8 @@
{
<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>
</section>
@@ -62,7 +60,8 @@
{
<p>Name: <strong>@SelectedCampaign.Name</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>
}
</section>
@@ -70,7 +69,9 @@
<section class="card">
<div class="section-head">
<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>
@if (SelectedCampaign is null)
{
@@ -86,9 +87,13 @@
@foreach (var character in SelectedCampaign.Characters)
{
<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">
<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>
</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 Microsoft.AspNetCore.Components;
using RpgRoller.Contracts;
namespace RpgRoller.Components.Pages.HomeControls;
[ExcludeFromCodeCoverage]
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]
private RpgRollerApiClient ApiClient { get; set; } = default!;
private RpgRollerApiClient ApiClient { get; set; } = null!;
private FormState<CampaignFormModel> CampaignState { get; } = new();
private bool IsCreatingCampaign { get; set; }
@@ -50,53 +88,4 @@ public partial class CampaignManagementPanel
[Parameter]
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)
{
<div class="modal-overlay" role="presentation">
@@ -14,7 +9,7 @@
}
<form class="form-grid" @onsubmit="SubmitAsync" @onsubmit:preventDefault>
<label for="@NameInputId">Character name</label>
<input id="@NameInputId" @bind="FormState.Model.Name" @bind:event="oninput" />
<input id="@NameInputId" @bind="FormState.Model.Name" @bind:event="oninput"/>
@if (FormState.Errors.TryGetValue("name", out var nameError))
{
<p class="field-error">@nameError</p>

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 Microsoft.AspNetCore.Components;
using RpgRoller.Contracts;
namespace RpgRoller.Components.Pages.HomeControls;
[ExcludeFromCodeCoverage]
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]
private RpgRollerApiClient ApiClient { get; set; } = default!;
private RpgRollerApiClient ApiClient { get; set; } = null!;
private FormState<CharacterFormModel> FormState { get; } = new();
private int AppliedFormVersion { get; set; } = -1;
@@ -51,68 +101,4 @@ public partial class CharacterFormModal
[Parameter]
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">
<div class="section-head"><h2>Character Context</h2></div>
@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)
{
@@ -22,7 +22,8 @@
@foreach (var character in SelectedCampaign.Characters)
{
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-text">@character.Name</span>
</button>
@@ -36,15 +37,22 @@
<p>Campaign: @SelectedCampaign.Name</p>
<span class="badge active">Active</span>
<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>
</article>
<article class="skills-section">
<div class="section-head">
<h3>Skills</h3>
<div class="inline-actions">
<button type="button" disabled="@(IsMutating || !CanEditCharacter(SelectedCharacter))" @onclick="OpenCreateSkillModal">Create Skill</button>
<button type="button" disabled="@(IsMutating || SelectedSkill is null || !CanEditSkill(SelectedSkill))" @onclick="OpenEditSkillModal">Edit Skill</button>
<button type="button" disabled="@(IsMutating || !CanEditCharacter(SelectedCharacter))"
@onclick="OpenCreateSkillModal">Create Skill
</button>
<button type="button"
disabled="@(IsMutating || SelectedSkill is null || !CanEditSkill(SelectedSkill))"
@onclick="OpenEditSkillModal">Edit Skill
</button>
</div>
</div>
@if (SelectedCharacterSkills.Count == 0)
@@ -57,7 +65,8 @@
@foreach (var skill in SelectedCharacterSkills)
{
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>
</button>
}
@@ -69,7 +78,9 @@
<option value="public">Public</option>
<option value="private">Private</option>
</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>
</article>
}
@@ -83,9 +94,13 @@
else
{
<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><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>
</section>
@@ -105,7 +120,7 @@
EditingSkillId="null"
IsMutating="IsMutating"
SkillSaved="OnSkillCreatedAsync"
CancelRequested="CloseSkillModals" />
CancelRequested="CloseSkillModals"/>
<SkillFormModal
Visible="ShowEditSkillModal"
@@ -122,4 +137,4 @@
EditingSkillId="EditingSkillId"
IsMutating="IsMutating"
SkillSaved="OnSkillUpdatedAsync"
CancelRequested="CloseSkillModals" />
CancelRequested="CloseSkillModals"/>

View File

@@ -1,13 +1,86 @@
using Microsoft.AspNetCore.Components;
using RpgRoller.Components.Pages;
using RpgRoller.Contracts;
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Components;
using RpgRoller.Contracts;
namespace RpgRoller.Components.Pages.HomeControls;
[ExcludeFromCodeCoverage]
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 ShowEditSkillModal { get; set; }
private Guid? EditingSkillId { get; set; }
@@ -84,84 +157,4 @@ public partial class CharacterPanel
[Parameter]
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)
{
<div class="roll-dice-strip" aria-label="@AriaLabel">

View File

@@ -1,18 +1,12 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Components;
using RpgRoller.Contracts;
using System.Diagnostics.CodeAnalysis;
namespace RpgRoller.Components.Pages.HomeControls;
[ExcludeFromCodeCoverage]
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)
{
return roll switch
@@ -31,29 +25,19 @@ public partial class RollDiceStrip
{
var classes = new List<string> { "die-chip" };
if (die.Wild)
{
classes.Add("wild");
}
if (die.Crit)
{
classes.Add("crit");
}
if (die.Fumble)
{
classes.Add("fumble");
}
if (die.Removed)
{
classes.Add("removed");
}
if (die.Added)
{
classes.Add("added");
}
return string.Join(" ", classes);
}
@@ -62,30 +46,26 @@ public partial class RollDiceStrip
{
var labels = new List<string> { $"Roll {die.Roll}" };
if (die.Wild)
{
labels.Add("wild");
}
if (die.Crit)
{
labels.Add("critical");
}
if (die.Fumble)
{
labels.Add("fumble");
}
if (die.Removed)
{
labels.Add("removed");
}
if (die.Added)
{
labels.Add("added");
}
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)
{
<div class="modal-overlay" role="presentation">
@@ -14,13 +9,13 @@
}
<form class="form-grid" @onsubmit="SubmitAsync" @onsubmit:preventDefault>
<label for="@NameInputId">Skill name</label>
<input id="@NameInputId" @bind="FormState.Model.Name" @bind:event="oninput" />
<input id="@NameInputId" @bind="FormState.Model.Name" @bind:event="oninput"/>
@if (FormState.Errors.TryGetValue("name", out var skillNameError))
{
<p class="field-error">@skillNameError</p>
}
<label for="@ExpressionInputId">Expression</label>
<input id="@ExpressionInputId" @bind="FormState.Model.DiceRollDefinition" @bind:event="oninput" />
<input id="@ExpressionInputId" @bind="FormState.Model.DiceRollDefinition" @bind:event="oninput"/>
@if (FormState.Errors.TryGetValue("diceRollDefinition", out var expressionError))
{
<p class="field-error">@expressionError</p>
@@ -28,13 +23,14 @@
@if (IsD6)
{
<label for="@WildDiceInputId">Wild dice</label>
<input id="@WildDiceInputId" type="number" min="1" step="1" @bind="FormState.Model.WildDice" />
<input id="@WildDiceInputId" type="number" min="1" step="1" @bind="FormState.Model.WildDice"/>
@if (FormState.Errors.TryGetValue("wildDice", out var wildDiceError))
{
<p class="field-error">@wildDiceError</p>
}
<label for="@AllowFumbleInputId">Allow fumble</label>
<input id="@AllowFumbleInputId" type="checkbox" @bind="FormState.Model.AllowFumble" />
<input id="@AllowFumbleInputId" type="checkbox" @bind="FormState.Model.AllowFumble"/>
}
<div class="inline-actions">
<button type="submit" disabled="@(IsMutating || IsSubmitting)">@SubmitLabel</button>

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 Microsoft.AspNetCore.Components;
using RpgRoller.Contracts;
namespace RpgRoller.Components.Pages.HomeControls;
[ExcludeFromCodeCoverage]
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]
private RpgRollerApiClient ApiClient { get; set; } = default!;
private RpgRollerApiClient ApiClient { get; set; } = null!;
private FormState<SkillFormModel> FormState { get; } = new();
private int AppliedFormVersion { get; set; } = -1;
@@ -60,89 +121,4 @@ public partial class SkillFormModal
[Parameter]
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.Contracts
<div class="@AppCssClass">
<p class="sr-only" aria-live="polite">@LiveAnnouncement</p>
@@ -39,8 +34,12 @@
<div class="header-group controls">
<p class="connection @ConnectionStateCssClass">@ConnectionStateLabel</p>
<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 == "management" ? "active" : string.Empty)" @onclick="SwitchToManagementAsync">Campaign Management</button>
<button type="button" class="switch @(CurrentScreen == "play" ? "active" : string.Empty)"
@onclick="SwitchToPlayAsync">Play
</button>
<button type="button" class="switch @(CurrentScreen == "management" ? "active" : string.Empty)"
@onclick="SwitchToManagementAsync">Campaign Management
</button>
</div>
<div class="header-actions">
<button type="button" @onclick="ManualRefreshAsync" disabled="@IsMutating">Refresh</button>
@@ -80,7 +79,7 @@
EditCharacterRequested="OpenEditCharacterModal"
SkillCreated="OnSkillCreatedAsync"
SkillUpdated="OnSkillUpdatedAsync"
RollRequested="RollSelectedSkillAsync" />
RollRequested="RollSelectedSkillAsync"/>
<CampaignLogPanel
IsCampaignDataLoading="IsCampaignDataLoading"
@@ -90,11 +89,15 @@
CharacterLabel="CharacterLabel"
LogEntryCssClass="LogEntryCssClass"
VisibilityLabel="VisibilityLabel"
VisibilityBadgeCssClass="VisibilityBadgeCssClass" />
VisibilityBadgeCssClass="VisibilityBadgeCssClass"/>
</main>
<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 == "log" ? "active" : string.Empty)" @onclick="SetMobilePanelLogAsync">Log</button>
<button type="button" class="switch @(MobilePanel == "character" ? "active" : string.Empty)"
@onclick="SetMobilePanelCharacterAsync">Character
</button>
<button type="button" class="switch @(MobilePanel == "log" ? "active" : string.Empty)"
@onclick="SetMobilePanelLogAsync">Log
</button>
</nav>
}
@@ -112,7 +115,7 @@
CampaignSelectionChanged="OnCampaignSelectionChangedAsync"
CampaignCreated="OnCampaignCreatedAsync"
CreateCharacterRequested="OpenCreateCharacterModal"
EditCharacterRequested="OpenEditCharacterModal" />
EditCharacterRequested="OpenEditCharacterModal"/>
}
</div>
</div>
@@ -129,7 +132,7 @@
Campaigns="Campaigns"
IsMutating="IsMutating"
CharacterSaved="OnCharacterCreatedAsync"
CancelRequested="CloseCharacterModals" />
CancelRequested="CloseCharacterModals"/>
<CharacterFormModal
Visible="ShowEditCharacterModal"
@@ -143,4 +146,4 @@
Campaigns="Campaigns"
IsMutating="IsMutating"
CharacterSaved="OnCharacterUpdatedAsync"
CancelRequested="CloseCharacterModals" />
CancelRequested="CloseCharacterModals"/>

View File

@@ -1,113 +1,18 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using RpgRoller.Components.Pages.HomeControls;
using RpgRoller.Components;
using RpgRoller.Contracts;
using System.Diagnostics.CodeAnalysis;
namespace RpgRoller.Components.Pages;
[ExcludeFromCodeCoverage]
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)
{
HasInteractiveRenderStarted = true;
if (!firstRender)
{
return;
}
await InitializeAsync();
await InvokeAsync(StateHasChanged);
@@ -117,32 +22,24 @@ public partial class Workspace : IAsyncDisposable
{
var storedScreen = await JS.InvokeAsync<string?>("rpgRollerApi.getSessionValue", ScreenSessionKey);
if (string.Equals(storedScreen, "management", StringComparison.OrdinalIgnoreCase))
{
CurrentScreen = "management";
}
var storedPanel = await JS.InvokeAsync<string?>("rpgRollerApi.getSessionValue", MobilePanelSessionKey);
if (string.Equals(storedPanel, "log", StringComparison.OrdinalIgnoreCase))
{
MobilePanel = "log";
}
Guid? preferredCampaignId = null;
var storedCampaignId = await JS.InvokeAsync<string?>("rpgRollerApi.getSessionValue", CampaignSessionKey);
if (Guid.TryParse(storedCampaignId, out var parsedCampaignId))
{
preferredCampaignId = parsedCampaignId;
}
await CheckHealthAsync();
await LoadRulesetsAsync();
var reloaded = await ReloadAuthenticatedSessionAsync(preferredCampaignId);
if (!reloaded)
{
await LoggedOut.InvokeAsync("Session expired. Please log in again.");
}
}
private async Task RetryAfterHealthIssueAsync()
{
@@ -151,11 +48,9 @@ public partial class Workspace : IAsyncDisposable
{
var reloaded = await ReloadAuthenticatedSessionAsync(SelectedCampaignId);
if (!reloaded)
{
await LoggedOut.InvokeAsync("Session expired. Please log in again.");
}
}
}
private async Task CheckHealthAsync()
{
@@ -236,13 +131,9 @@ public partial class Workspace : IAsyncDisposable
var campaignIds = Campaigns.Select(c => c.Id).ToHashSet();
if (preferredCampaignId.HasValue && campaignIds.Contains(preferredCampaignId.Value))
{
SelectedCampaignId = preferredCampaignId.Value;
}
else if (!SelectedCampaignId.HasValue || !campaignIds.Contains(SelectedCampaignId.Value))
{
SelectedCampaignId = Campaigns[0].Id;
}
await JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", CampaignSessionKey, SelectedCampaignId?.ToString());
}
@@ -288,9 +179,7 @@ public partial class Workspace : IAsyncDisposable
private async Task ManualRefreshAsync()
{
if (IsMutating)
{
return;
}
IsMutating = true;
try
@@ -314,9 +203,7 @@ public partial class Workspace : IAsyncDisposable
private async Task LogoutAsync()
{
if (IsMutating)
{
return;
}
IsMutating = true;
try
@@ -342,8 +229,15 @@ public partial class Workspace : IAsyncDisposable
await JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", ScreenSessionKey, CurrentScreen);
}
private Task SwitchToPlayAsync() => SwitchScreenAsync("play");
private Task SwitchToManagementAsync() => SwitchScreenAsync("management");
private Task SwitchToPlayAsync()
{
return SwitchScreenAsync("play");
}
private Task SwitchToManagementAsync()
{
return SwitchScreenAsync("management");
}
private async Task SetMobilePanelAsync(string panel)
{
@@ -351,15 +245,20 @@ public partial class Workspace : IAsyncDisposable
await JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", MobilePanelSessionKey, MobilePanel);
}
private Task SetMobilePanelCharacterAsync() => SetMobilePanelAsync("character");
private Task SetMobilePanelLogAsync() => SetMobilePanelAsync("log");
private Task SetMobilePanelCharacterAsync()
{
return SetMobilePanelAsync("character");
}
private Task SetMobilePanelLogAsync()
{
return SetMobilePanelAsync("log");
}
private async Task OnCampaignSelectionChangedAsync(ChangeEventArgs args)
{
if (!Guid.TryParse(args.Value?.ToString(), out var campaignId))
{
return;
}
SelectedCampaignId = campaignId;
await JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", CampaignSessionKey, campaignId.ToString());
@@ -377,7 +276,7 @@ public partial class Workspace : IAsyncDisposable
private void OpenCreateCharacterModal()
{
CreateCharacterInitialModel = new CharacterFormModel
CreateCharacterInitialModel = new()
{
Name = string.Empty,
CampaignId = SelectedCampaignId?.ToString() ?? string.Empty
@@ -390,7 +289,7 @@ public partial class Workspace : IAsyncDisposable
private void OpenEditCharacterModal(CharacterSummary character)
{
EditingCharacterId = character.Id;
EditCharacterInitialModel = new CharacterFormModel
EditCharacterInitialModel = new()
{
Name = character.Name,
CampaignId = character.CampaignId.ToString()
@@ -445,15 +344,11 @@ public partial class Workspace : IAsyncDisposable
private async Task EnsureSelectedCharacterActiveAsync()
{
if (!SelectedCharacterId.HasValue || SelectedCampaign is null)
{
return;
}
var character = SelectedCampaign.Characters.FirstOrDefault(c => c.Id == SelectedCharacterId.Value);
if (character is null || !CanActivateCharacter(character, User) || ActiveCharacterId == character.Id)
{
return;
}
try
{
@@ -490,10 +385,7 @@ public partial class Workspace : IAsyncDisposable
IsMutating = true;
try
{
LastRoll = await ApiClient.RequestAsync<RollResult>(
"POST",
$"/api/skills/{SelectedSkill.Id}/roll",
new RollSkillRequest(RollVisibility));
LastRoll = await ApiClient.RequestAsync<RollResult>("POST", $"/api/skills/{SelectedSkill.Id}/roll", new RollSkillRequest(RollVisibility));
await RefreshCampaignScopeAsync();
SetStatus("Roll recorded.", false);
@@ -523,9 +415,7 @@ public partial class Workspace : IAsyncDisposable
private bool CanEditSkill(SkillSummary skill)
{
if (SelectedCampaign is null)
{
return false;
}
var character = SelectedCampaign.Characters.FirstOrDefault(c => c.Id == skill.CharacterId);
return character is not null && CanEditCharacter(character);
@@ -540,9 +430,7 @@ public partial class Workspace : IAsyncDisposable
public async Task OnStateEventReceived(long _)
{
if (StateRefreshInProgress)
{
return;
}
StateRefreshInProgress = true;
try
@@ -567,14 +455,10 @@ public partial class Workspace : IAsyncDisposable
};
if (ConnectionState == "reconnecting")
{
Announce("Reconnecting to live updates.");
}
if (ConnectionState == "offline")
{
Announce("Live updates offline. Use manual refresh.");
}
return InvokeAsync(StateHasChanged);
}
@@ -596,9 +480,7 @@ public partial class Workspace : IAsyncDisposable
private async Task StopStateEventsAsync()
{
if (!HasInteractiveRenderStarted)
{
return;
}
try
{
@@ -633,9 +515,7 @@ public partial class Workspace : IAsyncDisposable
var candidateIds = SelectedCampaign.Characters.Select(c => c.Id).ToHashSet();
if (SelectedCharacterId.HasValue && candidateIds.Contains(SelectedCharacterId.Value))
{
return;
}
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))
{
return;
}
SelectedSkillId = skills[0].Id;
}
@@ -666,14 +544,10 @@ public partial class Workspace : IAsyncDisposable
private string OwnerLabel(Guid ownerUserId)
{
if (User is not null && ownerUserId == User.Id)
{
return "You";
}
if (SelectedCampaign is not null && ownerUserId == SelectedCampaign.Gm.Id)
{
return $"{SelectedCampaign.Gm.DisplayName} (GM)";
}
return ownerUserId.ToString("N")[..8];
}
@@ -691,9 +565,7 @@ public partial class Workspace : IAsyncDisposable
private string SkillDefinitionLabel(SkillSummary skill)
{
if (!string.Equals(SelectedCampaign?.RulesetId, "d6", StringComparison.OrdinalIgnoreCase))
{
return skill.DiceRollDefinition;
}
var fumbleLabel = skill.AllowFumble ? "fumble on" : "fumble off";
return $"{skill.DiceRollDefinition} | wild {skill.WildDice}, {fumbleLabel}";
@@ -702,14 +574,10 @@ public partial class Workspace : IAsyncDisposable
private string RollerLabel(CampaignLogEntry entry)
{
if (User is not null && entry.RollerUserId == User.Id)
{
return "You";
}
if (SelectedCampaign is not null && entry.RollerUserId == SelectedCampaign.Gm.Id)
{
return "GM";
}
return "Participant";
}
@@ -717,14 +585,10 @@ public partial class Workspace : IAsyncDisposable
private string VisibilityLabel(CampaignLogEntry entry)
{
if (!string.Equals(entry.Visibility, "private", StringComparison.OrdinalIgnoreCase))
{
return "Public";
}
if (User is not null && entry.RollerUserId == User.Id)
{
return "Private (you)";
}
return IsCurrentUserGm ? "Private (GM view)" : "Private";
}
@@ -732,14 +596,10 @@ public partial class Workspace : IAsyncDisposable
private string VisibilityBadgeCssClass(CampaignLogEntry entry)
{
if (!string.Equals(entry.Visibility, "private", StringComparison.OrdinalIgnoreCase))
{
return "public";
}
if (User is not null && entry.RollerUserId == User.Id)
{
return "private-self";
}
return IsCurrentUserGm ? "private-gm" : "private-generic";
}
@@ -747,14 +607,10 @@ public partial class Workspace : IAsyncDisposable
private string LogEntryCssClass(CampaignLogEntry entry)
{
if (!string.Equals(entry.Visibility, "private", StringComparison.OrdinalIgnoreCase))
{
return "public";
}
if (User is not null && entry.RollerUserId == User.Id)
{
return "private-self";
}
return IsCurrentUserGm ? "private-gm" : "private-generic";
}
@@ -789,4 +645,90 @@ public partial class Workspace : IAsyncDisposable
{
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]
<Router AppAssembly="@typeof(Program).Assembly">
<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(Layout.MainLayout)" />
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)"/>
<FocusOnNavigate RouteData="@routeData" Selector="h1"/>
</Found>
</Router>

View File

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

View File

@@ -1,56 +1,41 @@
namespace RpgRoller.Contracts;
public sealed record HealthResponse(string Status);
public sealed record ApiError(string Error);
public sealed record RegisterRequest(string Username, string Password, string DisplayName);
public sealed record LoginRequest(string Username, string Password);
public sealed record UserSummary(Guid Id, string Username, string DisplayName);
public sealed record MeResponse(UserSummary User, Guid? ActiveCharacterId, Guid? CurrentCampaignId);
public sealed record RulesetDefinition(string Id, string Name, string DiceSyntax);
public sealed record CreateCampaignRequest(string Name, string RulesetId);
public sealed record CampaignSummary(Guid Id, string Name, string RulesetId, Guid GmUserId);
public sealed record CampaignDetails(
Guid Id,
string Name,
string RulesetId,
UserSummary Gm,
IReadOnlyList<CharacterSummary> Characters,
IReadOnlyList<SkillSummary> Skills);
public sealed record CampaignDetails(Guid Id, string Name, string RulesetId, UserSummary Gm, IReadOnlyList<CharacterSummary> Characters, IReadOnlyList<SkillSummary> Skills);
public sealed record CreateCharacterRequest(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 CreateSkillRequest(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 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(
Guid RollId,
Guid CampaignId,
Guid CharacterId,
Guid SkillId,
Guid RollerUserId,
string Visibility,
int Result,
string Breakdown,
IReadOnlyList<RollDieResult> Dice,
DateTimeOffset TimestampUtc);
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(Guid RollId, Guid CampaignId, Guid CharacterId, Guid SkillId, 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 RpgRollerDbContext(DbContextOptions<RpgRollerDbContext> options)
: base(options)
public RpgRollerDbContext(DbContextOptions<RpgRollerDbContext> options) : base(options)
{
}
public DbSet<UserAccount> Users => Set<UserAccount>();
public DbSet<UserSession> Sessions => Set<UserSession>();
public DbSet<Campaign> Campaigns => Set<Campaign>();
public DbSet<Character> Characters => Set<Character>();
public DbSet<Skill> Skills => Set<Skill>();
public DbSet<RollLogEntry> RollLogEntries => Set<RollLogEntry>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<UserAccount>(entity =>
@@ -77,4 +69,11 @@ public sealed class RpgRollerDbContext : DbContext
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 IServiceCollection AddRpgRollerCore(
this IServiceCollection services,
IConfiguration configuration,
IWebHostEnvironment environment)
public static IServiceCollection AddRpgRollerCore(this IServiceCollection services, IConfiguration configuration, IWebHostEnvironment environment)
{
var sqliteConnectionString = configuration.GetConnectionString("RpgRoller") ?? "Data Source=App_Data/rpgroller.db";
EnsureSqliteDataDirectory(sqliteConnectionString, environment.ContentRootPath);
services.AddSingleton<IPasswordHasher<UserAccount>, PasswordHasher<UserAccount>>();
services.AddDbContextFactory<RpgRollerDbContext>(options =>
options.UseSqlite(sqliteConnectionString));
services.AddDbContextFactory<RpgRollerDbContext>(options => options.UseSqlite(sqliteConnectionString));
services.AddSingleton<IDiceRoller, RandomDiceRoller>();
services.AddSingleton<IGameService, GameService>();
@@ -30,18 +26,12 @@ public static class ServiceCollectionExtensions
{
var builder = new SqliteConnectionStringBuilder(connectionString);
if (string.IsNullOrWhiteSpace(builder.DataSource) || builder.DataSource == ":memory:")
{
return;
}
var fullPath = Path.IsPathRooted(builder.DataSource)
? builder.DataSource
: Path.Combine(contentRootPath, builder.DataSource);
var fullPath = Path.IsPathRooted(builder.DataSource) ? builder.DataSource : Path.Combine(contentRootPath, builder.DataSource);
var directory = Path.GetDirectoryName(fullPath);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
}
}

View File

@@ -5,15 +5,10 @@ namespace RpgRoller.Hosting;
public static class SqliteSchemaUpgrader
{
private const string InitialMigrationId = "20260226084000_InitialSchema";
private const string ProductVersion = "10.0.2";
public static void ApplyPendingChanges(RpgRollerDbContext db)
{
if (db.Database.IsSqlite())
{
EnsureLegacySchemaHistory(db);
}
db.Database.Migrate();
}
@@ -24,23 +19,16 @@ public static class SqliteSchemaUpgrader
try
{
if (TableExists(db, "__EFMigrationsHistory"))
{
return;
}
if (!TableExists(db, "Skills"))
{
return;
}
if (!ColumnExists(db, "Skills", "WildDice") || !ColumnExists(db, "Skills", "AllowFumble"))
{
return;
}
using var createHistoryCommand = db.Database.GetDbConnection().CreateCommand();
createHistoryCommand.CommandText =
"""
createHistoryCommand.CommandText = """
CREATE TABLE IF NOT EXISTS "__EFMigrationsHistory" (
"MigrationId" TEXT NOT NULL CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY,
"ProductVersion" TEXT NOT NULL
@@ -49,8 +37,7 @@ public static class SqliteSchemaUpgrader
_ = createHistoryCommand.ExecuteNonQuery();
using var insertHistoryCommand = db.Database.GetDbConnection().CreateCommand();
insertHistoryCommand.CommandText =
"""
insertHistoryCommand.CommandText = """
INSERT OR IGNORE INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ($migrationId, $productVersion);
""";
@@ -94,11 +81,12 @@ public static class SqliteSchemaUpgrader
{
var currentColumnName = reader.GetString(1);
if (string.Equals(currentColumnName, columnName, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
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.Components;
using RpgRoller.Hosting;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRpgRollerCore(builder.Configuration, builder.Environment);
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
builder.Services.AddScoped<RpgRoller.Components.RpgRollerApiClient>();
builder.Services.AddRazorComponents().AddInteractiveServerComponents();
builder.Services.AddScoped<RpgRollerApiClient>();
var app = builder.Build();
app.InitializeRpgRollerState();
@@ -14,8 +14,7 @@ app.UseStaticFiles();
app.UseAntiforgery();
app.MapRpgRollerApi();
app.MapRazorComponents<RpgRoller.Components.App>()
.AddInteractiveServerRenderMode();
app.MapRazorComponents<App>().AddInteractiveServerRenderMode();
app.Run();
public partial class Program;

View File

@@ -11,7 +11,7 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.2"/>
</ItemGroup>
</Project>

View File

@@ -5,27 +5,13 @@ namespace RpgRoller.Services;
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)
{
if (string.Equals(rulesetId, "d6", StringComparison.OrdinalIgnoreCase))
{
return RulesetKind.D6;
}
if (string.Equals(rulesetId, "dnd5e", StringComparison.OrdinalIgnoreCase))
{
return RulesetKind.Dnd5e;
}
return null;
}
@@ -43,9 +29,7 @@ public static partial class DiceRules
public static ServiceResult<DiceExpression> ParseExpression(RulesetKind ruleset, string expression)
{
if (string.IsNullOrWhiteSpace(expression))
{
return ServiceResult<DiceExpression>.Failure("invalid_expression", "Dice expression is required.");
}
var trimmed = expression.Trim();
return ruleset switch
@@ -60,57 +44,43 @@ public static partial class DiceRules
{
var match = D6Regex().Match(expression);
if (!match.Success)
{
return ServiceResult<DiceExpression>.Failure("invalid_expression", "Expected d6 format like 5D+4.");
}
var diceCount = int.Parse(match.Groups["count"].Value);
var modifier = ParseModifier(match.Groups["modifier"].Value);
var validation = ValidateDiceParts(diceCount, 6, modifier);
if (!validation.Succeeded)
{
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)
{
var match = Dnd5eRegex().Match(expression);
if (!match.Success)
{
return ServiceResult<DiceExpression>.Failure("invalid_expression", "Expected dnd5e format like 2d12+2.");
}
var diceCount = int.Parse(match.Groups["count"].Value);
var sides = int.Parse(match.Groups["sides"].Value);
var modifier = ParseModifier(match.Groups["modifier"].Value);
var validation = ValidateDiceParts(diceCount, sides, modifier);
if (!validation.Succeeded)
{
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)
{
if (diceCount < 1 || diceCount > MaxDiceCount)
{
return ServiceResult<bool>.Failure("invalid_expression", $"Dice count must be between 1 and {MaxDiceCount}.");
}
if (sides < 2 || sides > MaxSides)
{
return ServiceResult<bool>.Failure("invalid_expression", $"Dice sides must be between 2 and {MaxSides}.");
}
if (modifier < 0 || modifier > MaxModifier)
{
return ServiceResult<bool>.Failure("invalid_expression", $"Modifier must be between 0 and {MaxModifier}.");
}
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)]
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.EntityFrameworkCore;
using System.Text.Json;
using RpgRoller.Contracts;
using RpgRoller.Data;
using RpgRoller.Domain;
@@ -9,19 +9,6 @@ namespace RpgRoller.Services;
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)
{
m_DbContextFactory = dbContextFactory;
@@ -32,36 +19,26 @@ public sealed class GameService : IGameService
public IReadOnlyList<RulesetDefinition> GetRulesets()
{
return DiceRules.SupportedRulesets
.Select(r => new RulesetDefinition(r.Id, r.Name, r.DiceSyntax))
.ToArray();
return DiceRules.SupportedRulesets.Select(r => new RulesetDefinition(r.Id, r.Name, r.DiceSyntax)).ToArray();
}
public ServiceResult<UserSummary> Register(string username, string password, string displayName)
{
if (string.IsNullOrWhiteSpace(username))
{
return ServiceResult<UserSummary>.Failure("invalid_username", "Username is required.");
}
if (string.IsNullOrWhiteSpace(displayName))
{
return ServiceResult<UserSummary>.Failure("invalid_display_name", "Display name is required.");
}
if (string.IsNullOrWhiteSpace(password) || password.Length < 8)
{
return ServiceResult<UserSummary>.Failure("invalid_password", "Password must be at least 8 characters.");
}
lock (m_Gate)
{
var trimmedUsername = username.Trim();
var normalizedUsername = NormalizeUsername(trimmedUsername);
if (m_UserIdsByUsername.ContainsKey(normalizedUsername))
{
return ServiceResult<UserSummary>.Failure("duplicate_username", "Username is already taken.");
}
var user = new UserAccount
{
@@ -86,29 +63,21 @@ public sealed class GameService : IGameService
public ServiceResult<(UserSummary User, string SessionToken)> Login(string username, string password)
{
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
{
return ServiceResult<(UserSummary User, string SessionToken)>.Failure("invalid_credentials", "Invalid username or password.");
}
lock (m_Gate)
{
var normalizedUsername = NormalizeUsername(username.Trim());
if (!m_UserIdsByUsername.TryGetValue(normalizedUsername, out var userId))
{
return ServiceResult<(UserSummary User, string SessionToken)>.Failure("invalid_credentials", "Invalid username or password.");
}
var user = m_UsersById[userId];
var verification = m_PasswordHasher.VerifyHashedPassword(user, user.PasswordHash, password);
if (verification == PasswordVerificationResult.Failed)
{
return ServiceResult<(UserSummary User, string SessionToken)>.Failure("invalid_credentials", "Invalid username or password.");
}
if (verification == PasswordVerificationResult.SuccessRehashNeeded)
{
user.PasswordHash = m_PasswordHasher.HashPassword(user, password);
}
var session = CreateSession(userId);
PersistStateLocked();
@@ -121,11 +90,9 @@ public sealed class GameService : IGameService
lock (m_Gate)
{
if (m_SessionsByToken.Remove(sessionToken))
{
PersistStateLocked();
}
}
}
public UserSummary? GetUserBySession(string sessionToken)
{
@@ -142,9 +109,7 @@ public sealed class GameService : IGameService
{
var user = ResolveUserLocked(sessionToken);
if (user is null)
{
return ServiceResult<MeResponse>.Failure("unauthorized", "You must be logged in.");
}
Guid? campaignId = null;
if (user.ActiveCharacterId is Guid activeCharacterId)
@@ -155,35 +120,27 @@ public sealed class GameService : IGameService
PersistStateLocked();
}
else
{
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)
{
if (string.IsNullOrWhiteSpace(name))
{
return ServiceResult<CampaignSummary>.Failure("invalid_campaign_name", "Campaign name is required.");
}
var ruleset = DiceRules.TryParseRulesetId(rulesetId);
if (ruleset is null)
{
return ServiceResult<CampaignSummary>.Failure("invalid_ruleset", "Unknown ruleset.");
}
lock (m_Gate)
{
var user = ResolveUserLocked(sessionToken);
if (user is null)
{
return ServiceResult<CampaignSummary>.Failure("unauthorized", "You must be logged in.");
}
var campaign = new Campaign
{
@@ -206,21 +163,13 @@ public sealed class GameService : IGameService
{
var user = ResolveUserLocked(sessionToken);
if (user is null)
{
return ServiceResult<IReadOnlyList<CampaignSummary>>.Failure("unauthorized", "You must be logged in.");
}
var campaignIds = new HashSet<Guid>(m_CampaignsById.Values.Where(c => c.GmUserId == user.Id).Select(c => c.Id));
foreach (var character in m_CharactersById.Values.Where(c => c.OwnerUserId == user.Id))
{
campaignIds.Add(character.CampaignId);
}
var results = campaignIds
.Select(id => m_CampaignsById[id])
.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase)
.Select(ToCampaignSummary)
.ToArray();
var results = campaignIds.Select(id => m_CampaignsById[id]).OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase).Select(ToCampaignSummary).ToArray();
return ServiceResult<IReadOnlyList<CampaignSummary>>.Success(results);
}
@@ -232,58 +181,35 @@ public sealed class GameService : IGameService
{
var context = ResolveContextLocked(sessionToken, campaignId);
if (!context.Succeeded)
{
return ServiceResult<CampaignDetails>.Failure(context.Error!.Code, context.Error.Message);
}
var (user, campaign) = context.Value!;
var gm = m_UsersById[campaign.GmUserId];
var isGm = campaign.GmUserId == user.Id;
var characters = m_CharactersById.Values
.Where(c => c.CampaignId == campaign.Id)
.Where(c => isGm || c.OwnerUserId == user.Id)
.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase)
.Select(ToCharacterSummary)
.ToArray();
var characters = m_CharactersById.Values.Where(c => c.CampaignId == campaign.Id).Where(c => isGm || c.OwnerUserId == user.Id).OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase).Select(ToCharacterSummary).ToArray();
var visibleCharacterIds = characters.Select(c => c.Id).ToHashSet();
var skills = m_SkillsById.Values
.Where(s => visibleCharacterIds.Contains(s.CharacterId))
.OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase)
.Select(ToSkillSummary)
.ToArray();
var skills = m_SkillsById.Values.Where(s => visibleCharacterIds.Contains(s.CharacterId)).OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase).Select(ToSkillSummary).ToArray();
return ServiceResult<CampaignDetails>.Success(new CampaignDetails(
campaign.Id,
campaign.Name,
DiceRules.ToRulesetId(campaign.Ruleset),
ToUserSummary(gm),
characters,
skills));
return ServiceResult<CampaignDetails>.Success(new(campaign.Id, campaign.Name, DiceRules.ToRulesetId(campaign.Ruleset), ToUserSummary(gm), characters, skills));
}
}
public ServiceResult<CharacterSummary> CreateCharacter(string sessionToken, string name, Guid campaignId)
{
if (string.IsNullOrWhiteSpace(name))
{
return ServiceResult<CharacterSummary>.Failure("invalid_character_name", "Character name is required.");
}
lock (m_Gate)
{
var user = ResolveUserLocked(sessionToken);
if (user is null)
{
return ServiceResult<CharacterSummary>.Failure("unauthorized", "You must be logged in.");
}
if (!m_CampaignsById.ContainsKey(campaignId))
{
return ServiceResult<CharacterSummary>.Failure("campaign_not_found", "Campaign was not found.");
}
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)
{
if (string.IsNullOrWhiteSpace(name))
{
return ServiceResult<CharacterSummary>.Failure("invalid_character_name", "Character name is required.");
}
lock (m_Gate)
{
var user = ResolveUserLocked(sessionToken);
if (user is null)
{
return ServiceResult<CharacterSummary>.Failure("unauthorized", "You must be logged in.");
}
if (!m_CharactersById.TryGetValue(characterId, out var character))
{
return ServiceResult<CharacterSummary>.Failure("character_not_found", "Character was not found.");
}
if (!m_CampaignsById.TryGetValue(campaignId, out var targetCampaign))
{
return ServiceResult<CharacterSummary>.Failure("campaign_not_found", "Campaign was not found.");
}
var sourceCampaign = m_CampaignsById[character.CampaignId];
var isOwner = character.OwnerUserId == user.Id;
var isSourceGm = sourceCampaign.GmUserId == user.Id;
var isTargetGm = targetCampaign.GmUserId == user.Id;
if (!isOwner && !isSourceGm && !isTargetGm)
{
return ServiceResult<CharacterSummary>.Failure("forbidden", "Only the owner or GM can edit this character.");
}
var sourceCampaignId = character.CampaignId;
character.Name = name.Trim();
@@ -341,9 +257,7 @@ public sealed class GameService : IGameService
TouchCampaignLocked(sourceCampaignId);
if (sourceCampaignId != character.CampaignId)
{
TouchCampaignLocked(character.CampaignId);
}
PersistStateLocked();
return ServiceResult<CharacterSummary>.Success(ToCharacterSummary(character));
@@ -356,19 +270,13 @@ public sealed class GameService : IGameService
{
var user = ResolveUserLocked(sessionToken);
if (user is null)
{
return ServiceResult<bool>.Failure("unauthorized", "You must be logged in.");
}
if (!m_CharactersById.TryGetValue(characterId, out var character))
{
return ServiceResult<bool>.Failure("character_not_found", "Character was not found.");
}
if (character.OwnerUserId != user.Id)
{
return ServiceResult<bool>.Failure("forbidden", "You can activate only your own character.");
}
user.ActiveCharacterId = character.Id;
PersistStateLocked();
@@ -382,20 +290,12 @@ public sealed class GameService : IGameService
{
var user = ResolveUserLocked(sessionToken);
if (user is null)
{
return ServiceResult<IReadOnlyList<CharacterSummary>>.Failure("unauthorized", "You must be logged in.");
}
if (!TryGetCurrentCampaignIdLocked(user, out var campaignId))
{
return ServiceResult<IReadOnlyList<CharacterSummary>>.Failure("no_active_character", "No active character is selected.");
}
var characters = m_CharactersById.Values
.Where(c => c.CampaignId == campaignId && c.OwnerUserId == user.Id)
.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase)
.Select(ToCharacterSummary)
.ToArray();
var characters = m_CharactersById.Values.Where(c => c.CampaignId == campaignId && c.OwnerUserId == user.Id).OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase).Select(ToCharacterSummary).ToArray();
return ServiceResult<IReadOnlyList<CharacterSummary>>.Success(characters);
}
@@ -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)
{
if (string.IsNullOrWhiteSpace(name))
{
return ServiceResult<SkillSummary>.Failure("invalid_skill_name", "Skill name is required.");
}
lock (m_Gate)
{
var user = ResolveUserLocked(sessionToken);
if (user is null)
{
return ServiceResult<SkillSummary>.Failure("unauthorized", "You must be logged in.");
}
if (!m_CharactersById.TryGetValue(characterId, out var character))
{
return ServiceResult<SkillSummary>.Failure("character_not_found", "Character was not found.");
}
var campaign = m_CampaignsById[character.CampaignId];
if (!CanEditCharacterLocked(user.Id, character, campaign))
{
return ServiceResult<SkillSummary>.Failure("forbidden", "Only the owner or GM can edit skills.");
}
var expressionValidation = DiceRules.ParseExpression(campaign.Ruleset, diceRollDefinition);
if (!expressionValidation.Succeeded)
{
return ServiceResult<SkillSummary>.Failure(expressionValidation.Error!.Code, expressionValidation.Error.Message);
}
var optionsValidation = ValidateSkillOptions(campaign.Ruleset, wildDice, allowFumble);
if (!optionsValidation.Succeeded)
{
return ServiceResult<SkillSummary>.Failure(optionsValidation.Error!.Code, optionsValidation.Error.Message);
}
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)
{
if (string.IsNullOrWhiteSpace(name))
{
return ServiceResult<SkillSummary>.Failure("invalid_skill_name", "Skill name is required.");
}
lock (m_Gate)
{
var user = ResolveUserLocked(sessionToken);
if (user is null)
{
return ServiceResult<SkillSummary>.Failure("unauthorized", "You must be logged in.");
}
if (!m_SkillsById.TryGetValue(skillId, out var skill))
{
return ServiceResult<SkillSummary>.Failure("skill_not_found", "Skill was not found.");
}
var character = m_CharactersById[skill.CharacterId];
var campaign = m_CampaignsById[character.CampaignId];
if (!CanEditCharacterLocked(user.Id, character, campaign))
{
return ServiceResult<SkillSummary>.Failure("forbidden", "Only the owner or GM can edit skills.");
}
var expressionValidation = DiceRules.ParseExpression(campaign.Ruleset, diceRollDefinition);
if (!expressionValidation.Succeeded)
{
return ServiceResult<SkillSummary>.Failure(expressionValidation.Error!.Code, expressionValidation.Error.Message);
}
var optionsValidation = ValidateSkillOptions(campaign.Ruleset, wildDice, allowFumble);
if (!optionsValidation.Succeeded)
{
return ServiceResult<SkillSummary>.Failure(optionsValidation.Error!.Code, optionsValidation.Error.Message);
}
skill.Name = name.Trim();
skill.DiceRollDefinition = expressionValidation.Value!.Canonical;
@@ -513,33 +389,23 @@ public sealed class GameService : IGameService
{
var user = ResolveUserLocked(sessionToken);
if (user is null)
{
return ServiceResult<RollResult>.Failure("unauthorized", "You must be logged in.");
}
if (!m_SkillsById.TryGetValue(skillId, out var skill))
{
return ServiceResult<RollResult>.Failure("skill_not_found", "Skill was not found.");
}
var character = m_CharactersById[skill.CharacterId];
var campaign = m_CampaignsById[character.CampaignId];
if (!CanEditCharacterLocked(user.Id, character, campaign))
{
return ServiceResult<RollResult>.Failure("forbidden", "Only the owner or GM can roll this skill.");
}
var parsedExpression = DiceRules.ParseExpression(campaign.Ruleset, skill.DiceRollDefinition);
if (!parsedExpression.Succeeded)
{
return ServiceResult<RollResult>.Failure(parsedExpression.Error!.Code, parsedExpression.Error.Message);
}
var parsedVisibility = ParseVisibility(visibility);
if (!parsedVisibility.Succeeded)
{
return ServiceResult<RollResult>.Failure(parsedVisibility.Error!.Code, parsedVisibility.Error.Message);
}
var roll = ComputeRoll(campaign.Ruleset, parsedExpression.Value!, skill);
var entry = new RollLogEntry
@@ -570,18 +436,10 @@ public sealed class GameService : IGameService
{
var context = ResolveContextLocked(sessionToken, campaignId);
if (!context.Succeeded)
{
return ServiceResult<IReadOnlyList<CampaignLogEntry>>.Failure(context.Error!.Code, context.Error.Message);
}
var (user, campaign) = context.Value!;
var entries = m_RollLog
.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();
var entries = m_RollLog.Where(r => r.CampaignId == campaign.Id).Where(r => r.Visibility == RollVisibility.Public || r.RollerUserId == user.Id || campaign.GmUserId == user.Id).OrderBy(r => r.TimestampUtc).ThenBy(r => r.Id).Select(ToLogEntry).ToArray();
return ServiceResult<IReadOnlyList<CampaignLogEntry>>.Success(entries);
}
@@ -593,9 +451,7 @@ public sealed class GameService : IGameService
{
var context = ResolveContextLocked(sessionToken, campaignId);
if (!context.Succeeded)
{
return ServiceResult<long>.Failure(context.Error!.Code, context.Error.Message);
}
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)
{
if (wildDice < 0 || wildDice > 50)
{
return ServiceResult<(int WildDice, bool AllowFumble)>.Failure("invalid_wild_dice", "Wild dice must be between 0 and 50.");
}
if (ruleset == RulesetKind.D6)
{
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)>.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)
{
return ruleset == RulesetKind.D6
? ComputeD6Roll(expression, skill.WildDice, skill.AllowFumble)
: ComputeStandardRoll(expression);
return ruleset == RulesetKind.D6 ? ComputeD6Roll(expression, skill.WildDice, skill.AllowFumble) : ComputeStandardRoll(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);
diceValues[i] = value;
dice[i] = new RollDieResult(value, false, false, false, false, false);
dice[i] = new(value, false, false, false, false, false);
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 i = currentDice - 1; i >= 0 && pendingFumbles > 0; i -= 1)
{
if (dieResults[i].Roll != roll)
{
continue;
}
dieResults[i] = dieResults[i] with
{
@@ -707,7 +554,6 @@ public sealed class GameService : IGameService
};
pendingFumbles -= 1;
}
}
var total = expression.Modifier;
var includedDice = new List<int>(dieResults.Count);
@@ -732,9 +578,7 @@ public sealed class GameService : IGameService
{
var dicePart = string.Join("+", diceValues);
if (string.IsNullOrWhiteSpace(dicePart))
{
dicePart = "0";
}
var modifierPart = modifier > 0 ? $"+{modifier}" : string.Empty;
return $"{dicePart}{modifierPart}={total}";
@@ -743,14 +587,10 @@ public sealed class GameService : IGameService
private ServiceResult<RollVisibility> ParseVisibility(string visibility)
{
if (string.Equals(visibility, "public", StringComparison.OrdinalIgnoreCase))
{
return ServiceResult<RollVisibility>.Success(RollVisibility.Public);
}
if (string.Equals(visibility, "private", StringComparison.OrdinalIgnoreCase))
{
return ServiceResult<RollVisibility>.Success(RollVisibility.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);
if (user is null)
{
return ServiceResult<(UserAccount User, Campaign Campaign)>.Failure("unauthorized", "You must be logged in.");
}
if (!m_CampaignsById.TryGetValue(campaignId, out var campaign))
{
return ServiceResult<(UserAccount User, Campaign Campaign)>.Failure("campaign_not_found", "Campaign was not found.");
}
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)>.Success((user, campaign));
}
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)
{
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)
{
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)
{
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)
{
return new RollResult(
entry.Id,
entry.CampaignId,
entry.CharacterId,
entry.SkillId,
entry.RollerUserId,
entry.Visibility == RollVisibility.Public ? "public" : "private",
entry.Result,
entry.Breakdown,
dice,
entry.TimestampUtc);
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);
}
private static CampaignLogEntry ToLogEntry(RollLogEntry entry)
{
var dice = DeserializeDice(entry.Dice);
return new CampaignLogEntry(
entry.Id,
entry.CampaignId,
entry.CharacterId,
entry.SkillId,
entry.RollerUserId,
entry.Visibility == RollVisibility.Public ? "public" : "private",
entry.Result,
entry.Breakdown,
dice,
entry.TimestampUtc);
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);
}
private static string SerializeDice(IReadOnlyList<RollDieResult> dice)
@@ -836,9 +650,7 @@ public sealed class GameService : IGameService
private static IReadOnlyList<RollDieResult> DeserializeDice(string serializedDice)
{
if (string.IsNullOrWhiteSpace(serializedDice))
{
return [];
}
try
{
@@ -859,9 +671,7 @@ public sealed class GameService : IGameService
{
var campaign = m_CampaignsById[campaignId];
if (campaign.GmUserId == userId)
{
return true;
}
return m_CharactersById.Values.Any(c => c.CampaignId == campaignId && c.OwnerUserId == userId);
}
@@ -870,9 +680,7 @@ public sealed class GameService : IGameService
{
campaignId = Guid.Empty;
if (user.ActiveCharacterId is not Guid activeCharacterId)
{
return false;
}
if (!m_CharactersById.TryGetValue(activeCharacterId, out var character))
{
@@ -902,14 +710,10 @@ public sealed class GameService : IGameService
private UserAccount? ResolveUserLocked(string sessionToken)
{
if (string.IsNullOrWhiteSpace(sessionToken))
{
return null;
}
if (!m_SessionsByToken.TryGetValue(sessionToken, out var session))
{
return null;
}
return m_UsersById.GetValueOrDefault(session.UserId);
}
@@ -917,10 +721,8 @@ public sealed class GameService : IGameService
private void TouchCampaignLocked(Guid campaignId)
{
if (m_CampaignsById.TryGetValue(campaignId, out var campaign))
{
campaign.Version += 1;
}
}
private void LoadStateFromDatabase()
{
@@ -930,10 +732,7 @@ public sealed class GameService : IGameService
var campaigns = db.Campaigns.AsNoTracking().ToList();
var characters = db.Characters.AsNoTracking().ToList();
var skills = db.Skills.AsNoTracking().ToList();
var logEntries = db.RollLogEntries.AsNoTracking().ToList()
.OrderBy(x => x.TimestampUtc)
.ThenBy(x => x.Id)
.ToList();
var logEntries = db.RollLogEntries.AsNoTracking().ToList().OrderBy(x => x.TimestampUtc).ThenBy(x => x.Id).ToList();
lock (m_Gate)
{
@@ -947,9 +746,7 @@ public sealed class GameService : IGameService
foreach (var user in users)
{
var normalizedUsername = string.IsNullOrWhiteSpace(user.UsernameNormalized)
? NormalizeUsername(user.Username)
: user.UsernameNormalized;
var normalizedUsername = string.IsNullOrWhiteSpace(user.UsernameNormalized) ? NormalizeUsername(user.Username) : user.UsernameNormalized;
var storedUser = new UserAccount
{
@@ -968,25 +765,17 @@ public sealed class GameService : IGameService
foreach (var session in sessions)
{
if (m_UsersById.ContainsKey(session.UserId))
{
m_SessionsByToken[session.Token] = CloneSession(session);
}
}
foreach (var campaign in campaigns)
{
m_CampaignsById[campaign.Id] = CloneCampaign(campaign);
}
foreach (var character in characters)
{
m_CharactersById[character.Id] = CloneCharacter(character);
}
foreach (var skill in skills)
{
m_SkillsById[skill.Id] = CloneSkill(skill);
}
m_RollLog.AddRange(logEntries.Select(CloneRollLogEntry));
}
@@ -1022,7 +811,7 @@ public sealed class GameService : IGameService
private static UserAccount CloneUser(UserAccount user)
{
return new UserAccount
return new()
{
Id = user.Id,
Username = user.Username,
@@ -1035,7 +824,7 @@ public sealed class GameService : IGameService
private static UserSession CloneSession(UserSession session)
{
return new UserSession
return new()
{
Token = session.Token,
UserId = session.UserId,
@@ -1045,7 +834,7 @@ public sealed class GameService : IGameService
private static Campaign CloneCampaign(Campaign campaign)
{
return new Campaign
return new()
{
Id = campaign.Id,
GmUserId = campaign.GmUserId,
@@ -1057,7 +846,7 @@ public sealed class GameService : IGameService
private static Character CloneCharacter(Character character)
{
return new Character
return new()
{
Id = character.Id,
OwnerUserId = character.OwnerUserId,
@@ -1068,7 +857,7 @@ public sealed class GameService : IGameService
private static Skill CloneSkill(Skill skill)
{
return new Skill
return new()
{
Id = skill.Id,
CharacterId = skill.CharacterId,
@@ -1081,7 +870,7 @@ public sealed class GameService : IGameService
private static RollLogEntry CloneRollLogEntry(RollLogEntry entry)
{
return new RollLogEntry
return new()
{
Id = entry.Id,
CampaignId = entry.CampaignId,
@@ -1095,4 +884,17 @@ public sealed class GameService : IGameService
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.Domain;
namespace RpgRoller.Services;

View File

@@ -1,9 +1,11 @@
using System.Security.Cryptography;
namespace RpgRoller.Services;
public sealed class RandomDiceRoller : IDiceRoller
{
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.

View File

@@ -14,17 +14,17 @@ public sealed class ServiceResult<T>
Error = error;
}
public T? Value { get; }
public ServiceError? Error { get; }
public bool Succeeded => Error is null;
public static ServiceResult<T> Success(T value)
{
return new ServiceResult<T>(value);
return new(value);
}
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 version = typeof payload.version === "number" ? payload.version : 0;
invokeDotNet("OnStateEventReceived", version);
}
catch {
} catch {
invokeDotNet("OnStateEventReceived", 0);
}
});
@@ -124,8 +123,7 @@ window.rpgRollerApi = (() => {
let response;
try {
response = await fetch(url, options);
}
catch (error) {
} catch (error) {
return {
ok: false,
status: 0,
@@ -138,8 +136,7 @@ window.rpgRollerApi = (() => {
if (text) {
try {
parsed = JSON.parse(text);
}
catch {
} catch {
parsed = null;
}
}

View File

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