Code cleanup

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,13 +11,11 @@ public sealed class BackendCoverageTests
var extensionsType = typeof(Program).Assembly.GetType("RpgRoller.Api.SessionTokenHttpContextExtensions"); var extensionsType = typeof(Program).Assembly.GetType("RpgRoller.Api.SessionTokenHttpContextExtensions");
Assert.NotNull(extensionsType); Assert.NotNull(extensionsType);
var method = extensionsType!.GetMethod( var method = extensionsType!.GetMethod("GetRequiredSessionToken", BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic);
"GetRequiredSessionToken",
BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic);
Assert.NotNull(method); Assert.NotNull(method);
var context = new DefaultHttpContext(); var context = new DefaultHttpContext();
var exception = Assert.Throws<TargetInvocationException>(() => method!.Invoke(null, [context])); var exception = Assert.Throws<TargetInvocationException>(() => method!.Invoke(null, [context]));
Assert.IsType<InvalidOperationException>(exception.InnerException); Assert.IsType<InvalidOperationException>(exception.InnerException);
} }
} }

View File

@@ -1,3 +1,3 @@
namespace RpgRoller.Tests; namespace RpgRoller.Tests;
// Service-level tests were split by concern under RpgRoller.Tests/Services. // Service-level tests were split by concern under RpgRoller.Tests/Services.

View File

@@ -4,4 +4,4 @@ global using System.Net.Http.Json;
global using Microsoft.AspNetCore.Mvc.Testing; global using Microsoft.AspNetCore.Mvc.Testing;
global using RpgRoller.Contracts; global using RpgRoller.Contracts;
global using RpgRoller.Domain; global using RpgRoller.Domain;
global using RpgRoller.Services; global using RpgRoller.Services;

View File

@@ -1,9 +1,9 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Data.Sqlite; using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using RpgRoller.Hosting; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using RpgRoller.Data; using RpgRoller.Data;
using RpgRoller.Hosting;
namespace RpgRoller.Tests; namespace RpgRoller.Tests;
@@ -13,22 +13,14 @@ public sealed class HostingCoverageTests
public void AddRpgRollerCore_WithInMemoryConnectionString_RegistersCoreServices() public void AddRpgRollerCore_WithInMemoryConnectionString_RegistersCoreServices()
{ {
var services = new ServiceCollection(); var services = new ServiceCollection();
var configuration = new ConfigurationBuilder() var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?> { ["ConnectionStrings:RpgRoller"] = "Data Source=:memory:" }).Build();
.AddInMemoryCollection(new Dictionary<string, string?>
{
["ConnectionStrings:RpgRoller"] = "Data Source=:memory:"
})
.Build();
var environment = new TestWebHostEnvironment var environment = new TestWebHostEnvironment { ContentRootPath = Path.GetTempPath() };
{
ContentRootPath = Path.GetTempPath()
};
services.AddRpgRollerCore(configuration, environment); services.AddRpgRollerCore(configuration, environment);
Assert.Contains(services, d => d.ServiceType == typeof(RpgRoller.Services.IGameService)); Assert.Contains(services, d => d.ServiceType == typeof(IGameService));
Assert.Contains(services, d => d.ServiceType == typeof(RpgRoller.Services.IDiceRoller)); Assert.Contains(services, d => d.ServiceType == typeof(IDiceRoller));
} }
[Fact] [Fact]
@@ -41,63 +33,60 @@ public sealed class HostingCoverageTests
{ {
connection.Open(); connection.Open();
using var command = connection.CreateCommand(); using var command = connection.CreateCommand();
command.CommandText = command.CommandText = """
""" CREATE TABLE "Users" (
CREATE TABLE "Users" ( "Id" TEXT NOT NULL CONSTRAINT "PK_Users" PRIMARY KEY,
"Id" TEXT NOT NULL CONSTRAINT "PK_Users" PRIMARY KEY, "Username" TEXT NOT NULL,
"Username" TEXT NOT NULL, "UsernameNormalized" TEXT NOT NULL,
"UsernameNormalized" TEXT NOT NULL, "PasswordHash" TEXT NOT NULL,
"PasswordHash" TEXT NOT NULL, "DisplayName" TEXT NOT NULL,
"DisplayName" TEXT NOT NULL, "ActiveCharacterId" TEXT NULL
"ActiveCharacterId" TEXT NULL );
);
CREATE TABLE "Sessions" ( CREATE TABLE "Sessions" (
"Token" TEXT NOT NULL CONSTRAINT "PK_Sessions" PRIMARY KEY, "Token" TEXT NOT NULL CONSTRAINT "PK_Sessions" PRIMARY KEY,
"UserId" TEXT NOT NULL, "UserId" TEXT NOT NULL,
"CreatedAtUtc" TEXT NOT NULL "CreatedAtUtc" TEXT NOT NULL
); );
CREATE TABLE "Campaigns" ( CREATE TABLE "Campaigns" (
"Id" TEXT NOT NULL CONSTRAINT "PK_Campaigns" PRIMARY KEY, "Id" TEXT NOT NULL CONSTRAINT "PK_Campaigns" PRIMARY KEY,
"GmUserId" TEXT NOT NULL, "GmUserId" TEXT NOT NULL,
"Name" TEXT NOT NULL, "Name" TEXT NOT NULL,
"Ruleset" TEXT NOT NULL, "Ruleset" TEXT NOT NULL,
"Version" INTEGER NOT NULL "Version" INTEGER NOT NULL
); );
CREATE TABLE "Characters" ( CREATE TABLE "Characters" (
"Id" TEXT NOT NULL CONSTRAINT "PK_Characters" PRIMARY KEY, "Id" TEXT NOT NULL CONSTRAINT "PK_Characters" PRIMARY KEY,
"OwnerUserId" TEXT NOT NULL, "OwnerUserId" TEXT NOT NULL,
"CampaignId" TEXT NOT NULL, "CampaignId" TEXT NOT NULL,
"Name" TEXT NOT NULL "Name" TEXT NOT NULL
); );
CREATE TABLE "Skills" ( CREATE TABLE "Skills" (
"Id" TEXT NOT NULL CONSTRAINT "PK_Skills" PRIMARY KEY, "Id" TEXT NOT NULL CONSTRAINT "PK_Skills" PRIMARY KEY,
"CharacterId" TEXT NOT NULL, "CharacterId" TEXT NOT NULL,
"Name" TEXT NOT NULL, "Name" TEXT NOT NULL,
"DiceRollDefinition" TEXT NOT NULL "DiceRollDefinition" TEXT NOT NULL
); );
CREATE TABLE "RollLogEntries" ( CREATE TABLE "RollLogEntries" (
"Id" TEXT NOT NULL CONSTRAINT "PK_RollLogEntries" PRIMARY KEY, "Id" TEXT NOT NULL CONSTRAINT "PK_RollLogEntries" PRIMARY KEY,
"CampaignId" TEXT NOT NULL, "CampaignId" TEXT NOT NULL,
"CharacterId" TEXT NOT NULL, "CharacterId" TEXT NOT NULL,
"SkillId" TEXT NOT NULL, "SkillId" TEXT NOT NULL,
"RollerUserId" TEXT NOT NULL, "RollerUserId" TEXT NOT NULL,
"Visibility" TEXT NOT NULL, "Visibility" TEXT NOT NULL,
"Result" INTEGER NOT NULL, "Result" INTEGER NOT NULL,
"Breakdown" TEXT NOT NULL, "Breakdown" TEXT NOT NULL,
"TimestampUtc" TEXT NOT NULL "TimestampUtc" TEXT NOT NULL
); );
"""; """;
_ = command.ExecuteNonQuery(); _ = command.ExecuteNonQuery();
} }
var options = new DbContextOptionsBuilder<RpgRollerDbContext>() var options = new DbContextOptionsBuilder<RpgRollerDbContext>().UseSqlite(connectionString).Options;
.UseSqlite(connectionString)
.Options;
using (var db = new RpgRollerDbContext(options)) using (var db = new RpgRollerDbContext(options))
{ {
@@ -112,9 +101,7 @@ public sealed class HostingCoverageTests
using var tableInfoReader = tableInfoCommand.ExecuteReader(); using var tableInfoReader = tableInfoCommand.ExecuteReader();
var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase); var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
while (tableInfoReader.Read()) while (tableInfoReader.Read())
{
columns.Add(tableInfoReader.GetString(1)); columns.Add(tableInfoReader.GetString(1));
}
Assert.Contains("WildDice", columns); Assert.Contains("WildDice", columns);
Assert.Contains("AllowFumble", columns); Assert.Contains("AllowFumble", columns);
@@ -124,9 +111,7 @@ public sealed class HostingCoverageTests
using var rollTableInfoReader = rollTableInfoCommand.ExecuteReader(); using var rollTableInfoReader = rollTableInfoCommand.ExecuteReader();
var rollColumns = new HashSet<string>(StringComparer.OrdinalIgnoreCase); var rollColumns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
while (rollTableInfoReader.Read()) while (rollTableInfoReader.Read())
{
rollColumns.Add(rollTableInfoReader.GetString(1)); rollColumns.Add(rollTableInfoReader.GetString(1));
}
Assert.Contains("Dice", rollColumns); Assert.Contains("Dice", rollColumns);
@@ -145,4 +130,4 @@ public sealed class HostingCoverageTests
var rollDiceHistoryCount = Convert.ToInt32(rollDiceHistoryCommand.ExecuteScalar()); var rollDiceHistoryCount = Convert.ToInt32(rollDiceHistoryCommand.ExecuteScalar());
Assert.Equal(1, rollDiceHistoryCount); Assert.Equal(1, rollDiceHistoryCount);
} }
} }

View File

@@ -1,26 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<IsPackable>false</IsPackable> <IsPackable>false</IsPackable>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" /> <PackageReference Include="coverlet.collector" Version="6.0.4"/>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0" /> <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1"/>
<PackageReference Include="xunit" Version="2.9.3" /> <PackageReference Include="xunit" Version="2.9.3"/>
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" /> <PackageReference Include="xunit.runner.visualstudio" Version="3.1.4"/>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Using Include="Xunit" /> <Using Include="Xunit"/>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\RpgRoller\RpgRoller.csproj" /> <ProjectReference Include="..\RpgRoller\RpgRoller.csproj"/>
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -1,5 +1,3 @@
using RpgRoller.Services;
namespace RpgRoller.Tests; namespace RpgRoller.Tests;
public sealed class DiceRulesTests public sealed class DiceRulesTests
@@ -33,4 +31,4 @@ public sealed class DiceRulesTests
Assert.Equal("dnd5e", DiceRules.ToRulesetId(RulesetKind.Dnd5e)); Assert.Equal("dnd5e", DiceRules.ToRulesetId(RulesetKind.Dnd5e));
Assert.Throws<ArgumentOutOfRangeException>(() => DiceRules.ToRulesetId((RulesetKind)99)); Assert.Throws<ArgumentOutOfRangeException>(() => DiceRules.ToRulesetId((RulesetKind)99));
} }
} }

View File

@@ -1,5 +1,3 @@
using RpgRoller.Services;
namespace RpgRoller.Tests; namespace RpgRoller.Tests;
public sealed class RandomDiceRollerTests public sealed class RandomDiceRollerTests
@@ -14,4 +12,4 @@ public sealed class RandomDiceRollerTests
Assert.InRange(value, 1, 12); Assert.InRange(value, 1, 12);
} }
} }
} }

View File

@@ -56,4 +56,4 @@ public sealed class ServiceAuthTests
Assert.True(login.Succeeded); Assert.True(login.Succeeded);
Assert.Equal(2, hasher.HashCalls); Assert.Equal(2, hasher.HashCalls);
} }
} }

View File

@@ -97,4 +97,4 @@ public sealed class ServiceCampaignTests
Assert.Single(ownerView.Skills); Assert.Single(ownerView.Skills);
Assert.Equal(ownerSkill.Id, ownerView.Skills[0].Id); Assert.Equal(ownerSkill.Id, ownerView.Skills[0].Id);
} }
} }

View File

@@ -77,4 +77,4 @@ public sealed class ServiceD6RollTests
Assert.Equal(0, dndSkill.WildDice); Assert.Equal(0, dndSkill.WildDice);
Assert.False(dndSkill.AllowFumble); Assert.False(dndSkill.AllowFumble);
} }
} }

View File

@@ -91,4 +91,4 @@ public sealed class ServicePersistenceTests
Assert.False(invalidExpressionHarness.Service.RollSkill(ownerSession, skill.Id, "public").Succeeded); Assert.False(invalidExpressionHarness.Service.RollSkill(ownerSession, skill.Id, "public").Succeeded);
Assert.False(service.GetCampaignLog(string.Empty, campaign.Id).Succeeded); Assert.False(service.GetCampaignLog(string.Empty, campaign.Id).Succeeded);
} }
} }

View File

@@ -73,4 +73,4 @@ public sealed class ServiceSkillRollTests
Assert.True(version.Succeeded); Assert.True(version.Succeeded);
Assert.False(missingVersion.Succeeded); Assert.False(missingVersion.Succeeded);
} }
} }

View File

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

View File

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

View File

@@ -11,4 +11,4 @@ internal sealed class TestWebHostEnvironment : IWebHostEnvironment
public string EnvironmentName { get; set; } = "Development"; public string EnvironmentName { get; set; } = "Development";
public string ContentRootPath { get; set; } = string.Empty; public string ContentRootPath { get; set; } = string.Empty;
public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider(); public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider();
} }

View File

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

View File

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

View File

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

View File

@@ -33,4 +33,4 @@ internal static class CampaignEndpoints
return group; return group;
} }
} }

View File

@@ -33,4 +33,4 @@ internal static class CharacterEndpoints
return group; return group;
} }
} }

View File

@@ -16,4 +16,4 @@ internal static class MeEndpoints
return group; return group;
} }
} }

View File

@@ -2,4 +2,4 @@ namespace RpgRoller.Api;
internal static class RequestMappings internal static class RequestMappings
{ {
} }

View File

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

View File

@@ -3,4 +3,4 @@ namespace RpgRoller.Api;
internal static class SessionCookie internal static class SessionCookie
{ {
public const string Name = "rpgroller_session"; public const string Name = "rpgroller_session";
} }

View File

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

View File

@@ -27,4 +27,4 @@ internal static class SkillEndpoints
return group; return group;
} }
} }

View File

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

View File

@@ -11,4 +11,4 @@ internal static class SystemEndpoints
group.MapGet("/rulesets", (IGameService game) => TypedResults.Ok(game.GetRulesets())); group.MapGet("/rulesets", (IGameService game) => TypedResults.Ok(game.GetRulesets()));
return group; return group;
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using RpgRoller.Contracts; using RpgRoller.Contracts;
using System.Diagnostics.CodeAnalysis;
namespace RpgRoller.Components.Pages.HomeControls; namespace RpgRoller.Components.Pages.HomeControls;
@@ -9,25 +9,25 @@ public partial class CampaignLogPanel
{ {
[Parameter] [Parameter]
public bool IsCampaignDataLoading { get; set; } public bool IsCampaignDataLoading { get; set; }
[Parameter] [Parameter]
public IReadOnlyList<CampaignLogEntry> CampaignLog { get; set; } = []; public IReadOnlyList<CampaignLogEntry> CampaignLog { get; set; } = [];
[Parameter] [Parameter]
public Func<CampaignLogEntry, string> RollerLabel { get; set; } = _ => string.Empty; public Func<CampaignLogEntry, string> RollerLabel { get; set; } = _ => string.Empty;
[Parameter] [Parameter]
public Func<Guid, string> SkillLabel { get; set; } = _ => string.Empty; public Func<Guid, string> SkillLabel { get; set; } = _ => string.Empty;
[Parameter] [Parameter]
public Func<Guid, string> CharacterLabel { get; set; } = _ => string.Empty; public Func<Guid, string> CharacterLabel { get; set; } = _ => string.Empty;
[Parameter] [Parameter]
public Func<CampaignLogEntry, string> LogEntryCssClass { get; set; } = _ => string.Empty; public Func<CampaignLogEntry, string> LogEntryCssClass { get; set; } = _ => string.Empty;
[Parameter] [Parameter]
public Func<CampaignLogEntry, string> VisibilityLabel { get; set; } = _ => string.Empty; public Func<CampaignLogEntry, string> VisibilityLabel { get; set; } = _ => string.Empty;
[Parameter] [Parameter]
public Func<CampaignLogEntry, string> VisibilityBadgeCssClass { get; set; } = _ => string.Empty; public Func<CampaignLogEntry, string> VisibilityBadgeCssClass { get; set; } = _ => string.Empty;
} }

View File

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

View File

@@ -1,92 +1,39 @@
using Microsoft.AspNetCore.Components;
using RpgRoller.Components.Pages;
using RpgRoller.Components;
using RpgRoller.Contracts;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Components;
using RpgRoller.Contracts;
namespace RpgRoller.Components.Pages.HomeControls; namespace RpgRoller.Components.Pages.HomeControls;
[ExcludeFromCodeCoverage] [ExcludeFromCodeCoverage]
public partial class CampaignManagementPanel public partial class CampaignManagementPanel
{ {
[Inject]
private RpgRollerApiClient ApiClient { get; set; } = default!;
private FormState<CampaignFormModel> CampaignState { get; } = new();
private bool IsCreatingCampaign { get; set; }
[Parameter]
public IReadOnlyList<CampaignSummary> Campaigns { get; set; } = [];
[Parameter]
public Guid? SelectedCampaignId { get; set; }
[Parameter]
public string? SelectedCampaignName { get; set; }
[Parameter]
public CampaignDetails? SelectedCampaign { get; set; }
[Parameter]
public IReadOnlyList<RulesetDefinition> Rulesets { get; set; } = [];
[Parameter]
public bool IsMutating { get; set; }
[Parameter]
public Func<Guid, string> OwnerLabel { get; set; } = _ => string.Empty;
[Parameter]
public Func<CharacterSummary, bool> CanEditCharacter { get; set; } = _ => false;
[Parameter]
public EventCallback<ChangeEventArgs> CampaignSelectionChanged { get; set; }
[Parameter]
public EventCallback<Guid> CampaignCreated { get; set; }
[Parameter]
public EventCallback CreateCharacterRequested { get; set; }
[Parameter]
public EventCallback<CharacterSummary> EditCharacterRequested { get; set; }
protected override void OnParametersSet() protected override void OnParametersSet()
{ {
if (string.IsNullOrWhiteSpace(CampaignState.Model.RulesetId) && Rulesets.Count > 0) if (string.IsNullOrWhiteSpace(CampaignState.Model.RulesetId) && Rulesets.Count > 0)
{
CampaignState.Model.RulesetId = Rulesets[0].Id; CampaignState.Model.RulesetId = Rulesets[0].Id;
}
} }
private async Task SubmitCreateCampaignAsync() private async Task SubmitCreateCampaignAsync()
{ {
CampaignState.ResetValidation(); CampaignState.ResetValidation();
if (string.IsNullOrWhiteSpace(CampaignState.Model.Name)) if (string.IsNullOrWhiteSpace(CampaignState.Model.Name))
{
CampaignState.Errors["name"] = "Campaign name is required."; CampaignState.Errors["name"] = "Campaign name is required.";
}
if (string.IsNullOrWhiteSpace(CampaignState.Model.RulesetId)) if (string.IsNullOrWhiteSpace(CampaignState.Model.RulesetId))
{
CampaignState.Errors["rulesetId"] = "Ruleset is required."; CampaignState.Errors["rulesetId"] = "Ruleset is required.";
}
if (CampaignState.Errors.Count > 0) if (CampaignState.Errors.Count > 0)
{ {
CampaignState.ErrorMessage = "Resolve validation issues before submitting."; CampaignState.ErrorMessage = "Resolve validation issues before submitting.";
return; return;
} }
IsCreatingCampaign = true; IsCreatingCampaign = true;
try try
{ {
var campaign = await ApiClient.RequestAsync<CampaignSummary>( var campaign = await ApiClient.RequestAsync<CampaignSummary>("POST", "/api/campaigns", new CreateCampaignRequest(CampaignState.Model.Name.Trim(), CampaignState.Model.RulesetId));
"POST",
"/api/campaigns",
new CreateCampaignRequest(CampaignState.Model.Name.Trim(), CampaignState.Model.RulesetId));
CampaignState.Model.Name = string.Empty; CampaignState.Model.Name = string.Empty;
await CampaignCreated.InvokeAsync(campaign.Id); await CampaignCreated.InvokeAsync(campaign.Id);
} }
@@ -99,4 +46,46 @@ public partial class CampaignManagementPanel
IsCreatingCampaign = false; IsCreatingCampaign = false;
} }
} }
}
[Inject]
private RpgRollerApiClient ApiClient { get; set; } = null!;
private FormState<CampaignFormModel> CampaignState { get; } = new();
private bool IsCreatingCampaign { get; set; }
[Parameter]
public IReadOnlyList<CampaignSummary> Campaigns { get; set; } = [];
[Parameter]
public Guid? SelectedCampaignId { get; set; }
[Parameter]
public string? SelectedCampaignName { get; set; }
[Parameter]
public CampaignDetails? SelectedCampaign { get; set; }
[Parameter]
public IReadOnlyList<RulesetDefinition> Rulesets { get; set; } = [];
[Parameter]
public bool IsMutating { get; set; }
[Parameter]
public Func<Guid, string> OwnerLabel { get; set; } = _ => string.Empty;
[Parameter]
public Func<CharacterSummary, bool> CanEditCharacter { get; set; } = _ => false;
[Parameter]
public EventCallback<ChangeEventArgs> CampaignSelectionChanged { get; set; }
[Parameter]
public EventCallback<Guid> CampaignCreated { get; set; }
[Parameter]
public EventCallback CreateCharacterRequested { get; set; }
[Parameter]
public EventCallback<CharacterSummary> EditCharacterRequested { get; set; }
}

View File

@@ -1,8 +1,3 @@
@using System.Diagnostics.CodeAnalysis
@using RpgRoller.Components
@using RpgRoller.Components.Pages
@using RpgRoller.Contracts
@if (Visible) @if (Visible)
{ {
<div class="modal-overlay" role="presentation"> <div class="modal-overlay" role="presentation">
@@ -14,7 +9,7 @@
} }
<form class="form-grid" @onsubmit="SubmitAsync" @onsubmit:preventDefault> <form class="form-grid" @onsubmit="SubmitAsync" @onsubmit:preventDefault>
<label for="@NameInputId">Character name</label> <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)) @if (FormState.Errors.TryGetValue("name", out var nameError))
{ {
<p class="field-error">@nameError</p> <p class="field-error">@nameError</p>

View File

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

View File

@@ -1,12 +1,12 @@
@using System.Diagnostics.CodeAnalysis
@using RpgRoller.Components.Pages
@using RpgRoller.Contracts
<section class="card character-panel"> <section class="card character-panel">
<div class="section-head"><h2>Character Context</h2></div> <div class="section-head"><h2>Character Context</h2></div>
@if (IsCampaignDataLoading) @if (IsCampaignDataLoading)
{ {
<div class="skeleton-stack"><div class="skeleton-line"></div><div class="skeleton-line short"></div><div class="skeleton-line"></div></div> <div class="skeleton-stack">
<div class="skeleton-line"></div>
<div class="skeleton-line short"></div>
<div class="skeleton-line"></div>
</div>
} }
else if (SelectedCampaign is null) else if (SelectedCampaign is null)
{ {
@@ -22,7 +22,8 @@
@foreach (var character in SelectedCampaign.Characters) @foreach (var character in SelectedCampaign.Characters)
{ {
var isSelectedCharacter = SelectedCharacterId == character.Id; var isSelectedCharacter = SelectedCharacterId == character.Id;
<button type="button" class="icon-tab @(isSelectedCharacter ? "active" : string.Empty)" aria-label="@character.Name" @onclick="() => CharacterSelected.InvokeAsync(character.Id)"> <button type="button" class="icon-tab @(isSelectedCharacter ? "active" : string.Empty)"
aria-label="@character.Name" @onclick="() => CharacterSelected.InvokeAsync(character.Id)">
<span class="icon-tab-glyph">@InitialsFor(character.Name)</span> <span class="icon-tab-glyph">@InitialsFor(character.Name)</span>
<span class="icon-tab-text">@character.Name</span> <span class="icon-tab-text">@character.Name</span>
</button> </button>
@@ -36,15 +37,22 @@
<p>Campaign: @SelectedCampaign.Name</p> <p>Campaign: @SelectedCampaign.Name</p>
<span class="badge active">Active</span> <span class="badge active">Active</span>
<div class="inline-actions"> <div class="inline-actions">
<button type="button" disabled="@(IsMutating || !CanEditCharacter(SelectedCharacter))" @onclick="() => EditCharacterRequested.InvokeAsync(SelectedCharacter)">Edit Character</button> <button type="button" disabled="@(IsMutating || !CanEditCharacter(SelectedCharacter))"
@onclick="() => EditCharacterRequested.InvokeAsync(SelectedCharacter)">Edit Character
</button>
</div> </div>
</article> </article>
<article class="skills-section"> <article class="skills-section">
<div class="section-head"> <div class="section-head">
<h3>Skills</h3> <h3>Skills</h3>
<div class="inline-actions"> <div class="inline-actions">
<button type="button" disabled="@(IsMutating || !CanEditCharacter(SelectedCharacter))" @onclick="OpenCreateSkillModal">Create Skill</button> <button type="button" disabled="@(IsMutating || !CanEditCharacter(SelectedCharacter))"
<button type="button" disabled="@(IsMutating || SelectedSkill is null || !CanEditSkill(SelectedSkill))" @onclick="OpenEditSkillModal">Edit Skill</button> @onclick="OpenCreateSkillModal">Create Skill
</button>
<button type="button"
disabled="@(IsMutating || SelectedSkill is null || !CanEditSkill(SelectedSkill))"
@onclick="OpenEditSkillModal">Edit Skill
</button>
</div> </div>
</div> </div>
@if (SelectedCharacterSkills.Count == 0) @if (SelectedCharacterSkills.Count == 0)
@@ -57,7 +65,8 @@
@foreach (var skill in SelectedCharacterSkills) @foreach (var skill in SelectedCharacterSkills)
{ {
var isSelectedSkill = SelectedSkillId == skill.Id; var isSelectedSkill = SelectedSkillId == skill.Id;
<button type="button" class="skill-item @(isSelectedSkill ? "active" : string.Empty)" @onclick="() => SkillSelected.InvokeAsync(skill.Id)"> <button type="button" class="skill-item @(isSelectedSkill ? "active" : string.Empty)"
@onclick="() => SkillSelected.InvokeAsync(skill.Id)">
<strong>@skill.Name</strong><span>@SkillDefinitionLabel(skill)</span> <strong>@skill.Name</strong><span>@SkillDefinitionLabel(skill)</span>
</button> </button>
} }
@@ -69,7 +78,9 @@
<option value="public">Public</option> <option value="public">Public</option>
<option value="private">Private</option> <option value="private">Private</option>
</select> </select>
<button type="submit" disabled="@(IsMutating || SelectedSkill is null || !CanRollSkill(SelectedSkill))">Roll Skill</button> <button type="submit"
disabled="@(IsMutating || SelectedSkill is null || !CanRollSkill(SelectedSkill))">Roll Skill
</button>
</form> </form>
</article> </article>
} }
@@ -83,9 +94,13 @@
else else
{ {
<p class="roll-total">@LastRoll.Result</p> <p class="roll-total">@LastRoll.Result</p>
<RollDiceStrip Dice="LastRoll.Dice" AriaLabel="Last roll dice" /> <RollDiceStrip Dice="LastRoll.Dice" AriaLabel="Last roll dice"/>
<p>@LastRoll.Breakdown</p> <p>@LastRoll.Breakdown</p>
<p><span class="badge @(LastRoll.Visibility == "private" ? "private-self" : "public")">@LastRoll.Visibility</span> <time title="@LastRoll.TimestampUtc.ToString("O")">@LastRoll.TimestampUtc.ToLocalTime().ToString("g")</time></p> <p><span
class="badge @(LastRoll.Visibility == "private" ? "private-self" : "public")">@LastRoll.Visibility</span>
<time
title="@LastRoll.TimestampUtc.ToString("O")">@LastRoll.TimestampUtc.ToLocalTime().ToString("g")</time>
</p>
} }
</article> </article>
</section> </section>
@@ -105,7 +120,7 @@
EditingSkillId="null" EditingSkillId="null"
IsMutating="IsMutating" IsMutating="IsMutating"
SkillSaved="OnSkillCreatedAsync" SkillSaved="OnSkillCreatedAsync"
CancelRequested="CloseSkillModals" /> CancelRequested="CloseSkillModals"/>
<SkillFormModal <SkillFormModal
Visible="ShowEditSkillModal" Visible="ShowEditSkillModal"
@@ -122,4 +137,4 @@
EditingSkillId="EditingSkillId" EditingSkillId="EditingSkillId"
IsMutating="IsMutating" IsMutating="IsMutating"
SkillSaved="OnSkillUpdatedAsync" 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 System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Components;
using RpgRoller.Contracts;
namespace RpgRoller.Components.Pages.HomeControls; namespace RpgRoller.Components.Pages.HomeControls;
[ExcludeFromCodeCoverage] [ExcludeFromCodeCoverage]
public partial class CharacterPanel public partial class CharacterPanel
{ {
private void OpenCreateSkillModal()
{
CreateSkillInitialModel = new()
{
Name = string.Empty,
DiceRollDefinition = string.Empty,
WildDice = IsD6 ? 1 : 0,
AllowFumble = IsD6
};
CreateSkillFormVersion++;
ShowCreateSkillModal = true;
}
private void OpenEditSkillModal()
{
if (SelectedSkill is null)
return;
EditingSkillId = SelectedSkill.Id;
EditSkillInitialModel = new()
{
Name = SelectedSkill.Name,
DiceRollDefinition = SelectedSkill.DiceRollDefinition,
WildDice = SelectedSkill.WildDice,
AllowFumble = SelectedSkill.AllowFumble
};
EditSkillFormVersion++;
ShowEditSkillModal = true;
}
private void CloseSkillModals()
{
ShowCreateSkillModal = false;
ShowEditSkillModal = false;
EditingSkillId = null;
}
private async Task OnSkillCreatedAsync(Guid skillId)
{
CloseSkillModals();
await SkillCreated.InvokeAsync(skillId);
}
private async Task OnSkillUpdatedAsync(Guid skillId)
{
CloseSkillModals();
await SkillUpdated.InvokeAsync(skillId);
}
private async Task OnRollVisibilityChangedAsync(ChangeEventArgs args)
{
var selectedVisibility = args.Value?.ToString() ?? "public";
await RollVisibilityChanged.InvokeAsync(selectedVisibility);
}
private async Task OnRollSubmitAsync()
{
await RollRequested.InvokeAsync();
}
private static string InitialsFor(string value)
{
var words = value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (words.Length == 0)
return "?";
if (words.Length == 1)
return words[0].Length >= 2 ? words[0][..2].ToUpperInvariant() : words[0].ToUpperInvariant();
return string.Concat(words[0][0], words[1][0]).ToUpperInvariant();
}
private bool ShowCreateSkillModal { get; set; } private bool ShowCreateSkillModal { get; set; }
private bool ShowEditSkillModal { get; set; } private bool ShowEditSkillModal { get; set; }
private Guid? EditingSkillId { get; set; } private Guid? EditingSkillId { get; set; }
@@ -15,153 +88,73 @@ public partial class CharacterPanel
private SkillFormModel EditSkillInitialModel { get; set; } = new(); private SkillFormModel EditSkillInitialModel { get; set; } = new();
private int CreateSkillFormVersion { get; set; } private int CreateSkillFormVersion { get; set; }
private int EditSkillFormVersion { get; set; } private int EditSkillFormVersion { get; set; }
[Parameter] [Parameter]
public bool IsCampaignDataLoading { get; set; } public bool IsCampaignDataLoading { get; set; }
[Parameter] [Parameter]
public CampaignDetails? SelectedCampaign { get; set; } public CampaignDetails? SelectedCampaign { get; set; }
[Parameter] [Parameter]
public Guid? SelectedCharacterId { get; set; } public Guid? SelectedCharacterId { get; set; }
[Parameter] [Parameter]
public CharacterSummary? SelectedCharacter { get; set; } public CharacterSummary? SelectedCharacter { get; set; }
[Parameter] [Parameter]
public bool IsMutating { get; set; } public bool IsMutating { get; set; }
[Parameter] [Parameter]
public IReadOnlyList<SkillSummary> SelectedCharacterSkills { get; set; } = []; public IReadOnlyList<SkillSummary> SelectedCharacterSkills { get; set; } = [];
[Parameter] [Parameter]
public Guid? SelectedSkillId { get; set; } public Guid? SelectedSkillId { get; set; }
[Parameter] [Parameter]
public SkillSummary? SelectedSkill { get; set; } public SkillSummary? SelectedSkill { get; set; }
[Parameter] [Parameter]
public bool IsD6 { get; set; } public bool IsD6 { get; set; }
[Parameter] [Parameter]
public string RollVisibility { get; set; } = "public"; public string RollVisibility { get; set; } = "public";
[Parameter] [Parameter]
public EventCallback<string> RollVisibilityChanged { get; set; } public EventCallback<string> RollVisibilityChanged { get; set; }
[Parameter] [Parameter]
public RollResult? LastRoll { get; set; } public RollResult? LastRoll { get; set; }
[Parameter] [Parameter]
public Func<Guid, string> OwnerLabel { get; set; } = _ => string.Empty; public Func<Guid, string> OwnerLabel { get; set; } = _ => string.Empty;
[Parameter] [Parameter]
public Func<SkillSummary, string> SkillDefinitionLabel { get; set; } = _ => string.Empty; public Func<SkillSummary, string> SkillDefinitionLabel { get; set; } = _ => string.Empty;
[Parameter] [Parameter]
public Func<CharacterSummary, bool> CanEditCharacter { get; set; } = _ => false; public Func<CharacterSummary, bool> CanEditCharacter { get; set; } = _ => false;
[Parameter] [Parameter]
public Func<SkillSummary, bool> CanEditSkill { get; set; } = _ => false; public Func<SkillSummary, bool> CanEditSkill { get; set; } = _ => false;
[Parameter] [Parameter]
public Func<SkillSummary, bool> CanRollSkill { get; set; } = _ => false; public Func<SkillSummary, bool> CanRollSkill { get; set; } = _ => false;
[Parameter] [Parameter]
public EventCallback<Guid> CharacterSelected { get; set; } public EventCallback<Guid> CharacterSelected { get; set; }
[Parameter] [Parameter]
public EventCallback<Guid> SkillSelected { get; set; } public EventCallback<Guid> SkillSelected { get; set; }
[Parameter] [Parameter]
public EventCallback<CharacterSummary> EditCharacterRequested { get; set; } public EventCallback<CharacterSummary> EditCharacterRequested { get; set; }
[Parameter] [Parameter]
public EventCallback<Guid> SkillCreated { get; set; } public EventCallback<Guid> SkillCreated { get; set; }
[Parameter] [Parameter]
public EventCallback<Guid> SkillUpdated { get; set; } public EventCallback<Guid> SkillUpdated { get; set; }
[Parameter] [Parameter]
public EventCallback RollRequested { get; set; } public EventCallback RollRequested { get; set; }
}
private void OpenCreateSkillModal()
{
CreateSkillInitialModel = new SkillFormModel
{
Name = string.Empty,
DiceRollDefinition = string.Empty,
WildDice = IsD6 ? 1 : 0,
AllowFumble = IsD6
};
CreateSkillFormVersion++;
ShowCreateSkillModal = true;
}
private void OpenEditSkillModal()
{
if (SelectedSkill is null)
{
return;
}
EditingSkillId = SelectedSkill.Id;
EditSkillInitialModel = new SkillFormModel
{
Name = SelectedSkill.Name,
DiceRollDefinition = SelectedSkill.DiceRollDefinition,
WildDice = SelectedSkill.WildDice,
AllowFumble = SelectedSkill.AllowFumble
};
EditSkillFormVersion++;
ShowEditSkillModal = true;
}
private void CloseSkillModals()
{
ShowCreateSkillModal = false;
ShowEditSkillModal = false;
EditingSkillId = null;
}
private async Task OnSkillCreatedAsync(Guid skillId)
{
CloseSkillModals();
await SkillCreated.InvokeAsync(skillId);
}
private async Task OnSkillUpdatedAsync(Guid skillId)
{
CloseSkillModals();
await SkillUpdated.InvokeAsync(skillId);
}
private async Task OnRollVisibilityChangedAsync(ChangeEventArgs args)
{
var selectedVisibility = args.Value?.ToString() ?? "public";
await RollVisibilityChanged.InvokeAsync(selectedVisibility);
}
private async Task OnRollSubmitAsync()
{
await RollRequested.InvokeAsync();
}
private static string InitialsFor(string value)
{
var words = value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (words.Length == 0)
{
return "?";
}
if (words.Length == 1)
{
return words[0].Length >= 2 ? words[0][..2].ToUpperInvariant() : words[0].ToUpperInvariant();
}
return string.Concat(words[0][0], words[1][0]).ToUpperInvariant();
}
}

View File

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

View File

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

View File

@@ -1,8 +1,3 @@
@using System.Diagnostics.CodeAnalysis
@using RpgRoller.Components
@using RpgRoller.Components.Pages
@using RpgRoller.Contracts
@if (Visible) @if (Visible)
{ {
<div class="modal-overlay" role="presentation"> <div class="modal-overlay" role="presentation">
@@ -14,13 +9,13 @@
} }
<form class="form-grid" @onsubmit="SubmitAsync" @onsubmit:preventDefault> <form class="form-grid" @onsubmit="SubmitAsync" @onsubmit:preventDefault>
<label for="@NameInputId">Skill name</label> <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)) @if (FormState.Errors.TryGetValue("name", out var skillNameError))
{ {
<p class="field-error">@skillNameError</p> <p class="field-error">@skillNameError</p>
} }
<label for="@ExpressionInputId">Expression</label> <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)) @if (FormState.Errors.TryGetValue("diceRollDefinition", out var expressionError))
{ {
<p class="field-error">@expressionError</p> <p class="field-error">@expressionError</p>
@@ -28,13 +23,14 @@
@if (IsD6) @if (IsD6)
{ {
<label for="@WildDiceInputId">Wild dice</label> <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)) @if (FormState.Errors.TryGetValue("wildDice", out var wildDiceError))
{ {
<p class="field-error">@wildDiceError</p> <p class="field-error">@wildDiceError</p>
} }
<label for="@AllowFumbleInputId">Allow fumble</label> <label for="@AllowFumbleInputId">Allow fumble</label>
<input id="@AllowFumbleInputId" type="checkbox" @bind="FormState.Model.AllowFumble" /> <input id="@AllowFumbleInputId" type="checkbox" @bind="FormState.Model.AllowFumble"/>
} }
<div class="inline-actions"> <div class="inline-actions">
<button type="submit" disabled="@(IsMutating || IsSubmitting)">@SubmitLabel</button> <button type="submit" disabled="@(IsMutating || IsSubmitting)">@SubmitLabel</button>

View File

@@ -1,73 +1,17 @@
using Microsoft.AspNetCore.Components;
using RpgRoller.Components.Pages;
using RpgRoller.Components;
using RpgRoller.Contracts;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Components;
using RpgRoller.Contracts;
namespace RpgRoller.Components.Pages.HomeControls; namespace RpgRoller.Components.Pages.HomeControls;
[ExcludeFromCodeCoverage] [ExcludeFromCodeCoverage]
public partial class SkillFormModal public partial class SkillFormModal
{ {
[Inject]
private RpgRollerApiClient ApiClient { get; set; } = default!;
private FormState<SkillFormModel> FormState { get; } = new();
private int AppliedFormVersion { get; set; } = -1;
private bool IsSubmitting { get; set; }
[Parameter]
public bool Visible { get; set; }
[Parameter]
public bool IsD6 { get; set; }
[Parameter]
public string Title { get; set; } = "Skill";
[Parameter]
public string SubmitLabel { get; set; } = "Save";
[Parameter]
public string NameInputId { get; set; } = "skill-name";
[Parameter]
public string ExpressionInputId { get; set; } = "skill-expression";
[Parameter]
public string WildDiceInputId { get; set; } = "skill-wild";
[Parameter]
public string AllowFumbleInputId { get; set; } = "skill-fumble";
[Parameter]
public SkillFormModel InitialModel { get; set; } = new();
[Parameter]
public int FormVersion { get; set; }
[Parameter]
public Guid? SelectedCharacterId { get; set; }
[Parameter]
public Guid? EditingSkillId { get; set; }
[Parameter]
public bool IsMutating { get; set; }
[Parameter]
public EventCallback<Guid> SkillSaved { get; set; }
[Parameter]
public EventCallback CancelRequested { get; set; }
protected override void OnParametersSet() protected override void OnParametersSet()
{ {
if (!Visible || FormVersion == AppliedFormVersion) if (!Visible || FormVersion == AppliedFormVersion)
{
return; return;
}
FormState.Model.Name = InitialModel.Name; FormState.Model.Name = InitialModel.Name;
FormState.Model.DiceRollDefinition = InitialModel.DiceRollDefinition; FormState.Model.DiceRollDefinition = InitialModel.DiceRollDefinition;
FormState.Model.WildDice = InitialModel.WildDice; FormState.Model.WildDice = InitialModel.WildDice;
@@ -75,46 +19,33 @@ public partial class SkillFormModal
FormState.ResetValidation(); FormState.ResetValidation();
AppliedFormVersion = FormVersion; AppliedFormVersion = FormVersion;
} }
private async Task SubmitAsync() private async Task SubmitAsync()
{ {
FormState.ResetValidation(); FormState.ResetValidation();
if (string.IsNullOrWhiteSpace(FormState.Model.Name)) if (string.IsNullOrWhiteSpace(FormState.Model.Name))
{
FormState.Errors["name"] = "Skill name is required."; FormState.Errors["name"] = "Skill name is required.";
}
if (string.IsNullOrWhiteSpace(FormState.Model.DiceRollDefinition)) if (string.IsNullOrWhiteSpace(FormState.Model.DiceRollDefinition))
{
FormState.Errors["diceRollDefinition"] = "Expression is required."; FormState.Errors["diceRollDefinition"] = "Expression is required.";
}
if (IsD6 && FormState.Model.WildDice < 1) if (IsD6 && FormState.Model.WildDice < 1)
{
FormState.Errors["wildDice"] = "D6 skills require at least one wild die."; FormState.Errors["wildDice"] = "D6 skills require at least one wild die.";
}
if (FormState.Errors.Count > 0) if (FormState.Errors.Count > 0)
{ {
FormState.ErrorMessage = "Resolve validation issues before submitting."; FormState.ErrorMessage = "Resolve validation issues before submitting.";
return; return;
} }
IsSubmitting = true; IsSubmitting = true;
try try
{ {
SkillSummary skill; SkillSummary skill;
if (EditingSkillId.HasValue) if (EditingSkillId.HasValue)
{ {
skill = await ApiClient.RequestAsync<SkillSummary>( 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));
"PUT",
$"/api/skills/{EditingSkillId.Value}",
new UpdateSkillRequest(
FormState.Model.Name.Trim(),
FormState.Model.DiceRollDefinition.Trim(),
FormState.Model.WildDice,
FormState.Model.AllowFumble));
} }
else else
{ {
@@ -123,17 +54,10 @@ public partial class SkillFormModal
FormState.ErrorMessage = "Select a character first."; FormState.ErrorMessage = "Select a character first.";
return; return;
} }
skill = await ApiClient.RequestAsync<SkillSummary>( 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));
"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); await SkillSaved.InvokeAsync(skill.Id);
} }
catch (ApiRequestException ex) catch (ApiRequestException ex)
@@ -145,4 +69,56 @@ public partial class SkillFormModal
IsSubmitting = false; IsSubmitting = false;
} }
} }
}
[Inject]
private RpgRollerApiClient ApiClient { get; set; } = null!;
private FormState<SkillFormModel> FormState { get; } = new();
private int AppliedFormVersion { get; set; } = -1;
private bool IsSubmitting { get; set; }
[Parameter]
public bool Visible { get; set; }
[Parameter]
public bool IsD6 { get; set; }
[Parameter]
public string Title { get; set; } = "Skill";
[Parameter]
public string SubmitLabel { get; set; } = "Save";
[Parameter]
public string NameInputId { get; set; } = "skill-name";
[Parameter]
public string ExpressionInputId { get; set; } = "skill-expression";
[Parameter]
public string WildDiceInputId { get; set; } = "skill-wild";
[Parameter]
public string AllowFumbleInputId { get; set; } = "skill-fumble";
[Parameter]
public SkillFormModel InitialModel { get; set; } = new();
[Parameter]
public int FormVersion { get; set; }
[Parameter]
public Guid? SelectedCharacterId { get; set; }
[Parameter]
public Guid? EditingSkillId { get; set; }
[Parameter]
public bool IsMutating { get; set; }
[Parameter]
public EventCallback<Guid> SkillSaved { get; set; }
[Parameter]
public EventCallback CancelRequested { get; set; }
}

View File

@@ -1,9 +1,4 @@
@using System.Diagnostics.CodeAnalysis
@using Microsoft.JSInterop
@using RpgRoller.Components
@using RpgRoller.Components.Pages.HomeControls @using RpgRoller.Components.Pages.HomeControls
@using RpgRoller.Contracts
<div class="@AppCssClass"> <div class="@AppCssClass">
<p class="sr-only" aria-live="polite">@LiveAnnouncement</p> <p class="sr-only" aria-live="polite">@LiveAnnouncement</p>
@@ -39,8 +34,12 @@
<div class="header-group controls"> <div class="header-group controls">
<p class="connection @ConnectionStateCssClass">@ConnectionStateLabel</p> <p class="connection @ConnectionStateCssClass">@ConnectionStateLabel</p>
<div class="switch-group" role="tablist" aria-label="Screen selector"> <div class="switch-group" role="tablist" aria-label="Screen selector">
<button type="button" class="switch @(CurrentScreen == "play" ? "active" : string.Empty)" @onclick="SwitchToPlayAsync">Play</button> <button type="button" class="switch @(CurrentScreen == "play" ? "active" : string.Empty)"
<button type="button" class="switch @(CurrentScreen == "management" ? "active" : string.Empty)" @onclick="SwitchToManagementAsync">Campaign Management</button> @onclick="SwitchToPlayAsync">Play
</button>
<button type="button" class="switch @(CurrentScreen == "management" ? "active" : string.Empty)"
@onclick="SwitchToManagementAsync">Campaign Management
</button>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<button type="button" @onclick="ManualRefreshAsync" disabled="@IsMutating">Refresh</button> <button type="button" @onclick="ManualRefreshAsync" disabled="@IsMutating">Refresh</button>
@@ -80,7 +79,7 @@
EditCharacterRequested="OpenEditCharacterModal" EditCharacterRequested="OpenEditCharacterModal"
SkillCreated="OnSkillCreatedAsync" SkillCreated="OnSkillCreatedAsync"
SkillUpdated="OnSkillUpdatedAsync" SkillUpdated="OnSkillUpdatedAsync"
RollRequested="RollSelectedSkillAsync" /> RollRequested="RollSelectedSkillAsync"/>
<CampaignLogPanel <CampaignLogPanel
IsCampaignDataLoading="IsCampaignDataLoading" IsCampaignDataLoading="IsCampaignDataLoading"
@@ -90,11 +89,15 @@
CharacterLabel="CharacterLabel" CharacterLabel="CharacterLabel"
LogEntryCssClass="LogEntryCssClass" LogEntryCssClass="LogEntryCssClass"
VisibilityLabel="VisibilityLabel" VisibilityLabel="VisibilityLabel"
VisibilityBadgeCssClass="VisibilityBadgeCssClass" /> VisibilityBadgeCssClass="VisibilityBadgeCssClass"/>
</main> </main>
<nav class="mobile-bottom-nav" aria-label="Play panel selector"> <nav class="mobile-bottom-nav" aria-label="Play panel selector">
<button type="button" class="switch @(MobilePanel == "character" ? "active" : string.Empty)" @onclick="SetMobilePanelCharacterAsync">Character</button> <button type="button" class="switch @(MobilePanel == "character" ? "active" : string.Empty)"
<button type="button" class="switch @(MobilePanel == "log" ? "active" : string.Empty)" @onclick="SetMobilePanelLogAsync">Log</button> @onclick="SetMobilePanelCharacterAsync">Character
</button>
<button type="button" class="switch @(MobilePanel == "log" ? "active" : string.Empty)"
@onclick="SetMobilePanelLogAsync">Log
</button>
</nav> </nav>
} }
@@ -112,7 +115,7 @@
CampaignSelectionChanged="OnCampaignSelectionChangedAsync" CampaignSelectionChanged="OnCampaignSelectionChangedAsync"
CampaignCreated="OnCampaignCreatedAsync" CampaignCreated="OnCampaignCreatedAsync"
CreateCharacterRequested="OpenCreateCharacterModal" CreateCharacterRequested="OpenCreateCharacterModal"
EditCharacterRequested="OpenEditCharacterModal" /> EditCharacterRequested="OpenEditCharacterModal"/>
} }
</div> </div>
</div> </div>
@@ -129,7 +132,7 @@
Campaigns="Campaigns" Campaigns="Campaigns"
IsMutating="IsMutating" IsMutating="IsMutating"
CharacterSaved="OnCharacterCreatedAsync" CharacterSaved="OnCharacterCreatedAsync"
CancelRequested="CloseCharacterModals" /> CancelRequested="CloseCharacterModals"/>
<CharacterFormModal <CharacterFormModal
Visible="ShowEditCharacterModal" Visible="ShowEditCharacterModal"
@@ -143,4 +146,4 @@
Campaigns="Campaigns" Campaigns="Campaigns"
IsMutating="IsMutating" IsMutating="IsMutating"
CharacterSaved="OnCharacterUpdatedAsync" CharacterSaved="OnCharacterUpdatedAsync"
CancelRequested="CloseCharacterModals" /> CancelRequested="CloseCharacterModals"/>

View File

@@ -1,149 +1,46 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop; using Microsoft.JSInterop;
using RpgRoller.Components.Pages.HomeControls;
using RpgRoller.Components;
using RpgRoller.Contracts; using RpgRoller.Contracts;
using System.Diagnostics.CodeAnalysis;
namespace RpgRoller.Components.Pages; namespace RpgRoller.Components.Pages;
[ExcludeFromCodeCoverage] [ExcludeFromCodeCoverage]
public partial class Workspace : IAsyncDisposable public partial class Workspace : IAsyncDisposable
{ {
[Inject]
private IJSRuntime JS { get; set; } = default!;
[Inject]
private RpgRollerApiClient ApiClient { get; set; } = default!;
private const string ScreenSessionKey = "screen";
private const string CampaignSessionKey = "campaign";
private const string MobilePanelSessionKey = "play-panel";
private UserSummary? User { get; set; }
private Guid? ActiveCharacterId { get; set; }
private Guid? SelectedCampaignId { get; set; }
private CampaignDetails? SelectedCampaign { get; set; }
private List<CampaignSummary> Campaigns { get; set; } = [];
private List<CampaignLogEntry> CampaignLog { get; set; } = [];
private List<RulesetDefinition> Rulesets { get; set; } = [];
private Guid? SelectedCharacterId { get; set; }
private Guid? SelectedSkillId { get; set; }
private RollResult? LastRoll { get; set; }
private string RollVisibility { get; set; } = "public";
private bool IsMutating { get; set; }
private bool IsCampaignDataLoading { get; set; }
private bool HasHealthIssue { get; set; }
private string HealthIssueMessage { get; set; } = "Retry to restore the API connection.";
private string? StatusMessage { get; set; }
private bool StatusIsError { get; set; }
private string CurrentScreen { get; set; } = "play";
private string MobilePanel { get; set; } = "character";
private string ConnectionState { get; set; } = "offline";
private string LiveAnnouncement { get; set; } = string.Empty;
private bool ShowCreateCharacterModal { get; set; }
private bool ShowEditCharacterModal { get; set; }
private Guid? EditingCharacterId { get; set; }
private CharacterFormModel CreateCharacterInitialModel { get; set; } = new();
private CharacterFormModel EditCharacterInitialModel { get; set; } = new();
private int CreateCharacterFormVersion { get; set; }
private int EditCharacterFormVersion { get; set; }
private bool StateRefreshInProgress { get; set; }
private bool HasInteractiveRenderStarted { get; set; }
private DotNetObjectReference<Workspace>? DotNetRef { get; set; }
[Parameter]
public EventCallback<string?> LoggedOut { get; set; }
private string? SelectedCampaignName => SelectedCampaign?.Name;
private CharacterSummary? SelectedCharacter =>
SelectedCampaign?.Characters.FirstOrDefault(c => c.Id == SelectedCharacterId);
private SkillSummary? SelectedSkill =>
SelectedCampaign?.Skills.FirstOrDefault(s => s.Id == SelectedSkillId);
private string? ActiveCharacterName =>
SelectedCampaign?.Characters.FirstOrDefault(c => c.Id == SelectedCharacterId)?.Name;
private bool IsCurrentUserGm =>
SelectedCampaign is not null && User is not null && SelectedCampaign.Gm.Id == User.Id;
private bool IsSelectedCampaignD6 =>
string.Equals(SelectedCampaign?.RulesetId, "d6", StringComparison.OrdinalIgnoreCase);
private List<SkillSummary> SelectedCharacterSkills =>
SelectedCampaign is null || !SelectedCharacterId.HasValue
? []
: SelectedCampaign.Skills
.Where(skill => skill.CharacterId == SelectedCharacterId.Value)
.OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
private bool IsPlayScreen => string.Equals(CurrentScreen, "play", StringComparison.OrdinalIgnoreCase);
private bool IsManagementScreen => !IsPlayScreen;
private string ConnectionStateLabel => ConnectionState switch
{
"connected" => "Connected",
"reconnecting" => "Reconnecting",
_ => "Offline fallback"
};
private string ConnectionStateCssClass => ConnectionState switch
{
"connected" => "ok",
"reconnecting" => "warn",
_ => "offline"
};
private string AppCssClass => IsPlayScreen ? "rr-app app-play" : "rr-app";
protected override async Task OnAfterRenderAsync(bool firstRender) protected override async Task OnAfterRenderAsync(bool firstRender)
{ {
HasInteractiveRenderStarted = true; HasInteractiveRenderStarted = true;
if (!firstRender) if (!firstRender)
{
return; return;
}
await InitializeAsync(); await InitializeAsync();
await InvokeAsync(StateHasChanged); await InvokeAsync(StateHasChanged);
} }
private async Task InitializeAsync() private async Task InitializeAsync()
{ {
var storedScreen = await JS.InvokeAsync<string?>("rpgRollerApi.getSessionValue", ScreenSessionKey); var storedScreen = await JS.InvokeAsync<string?>("rpgRollerApi.getSessionValue", ScreenSessionKey);
if (string.Equals(storedScreen, "management", StringComparison.OrdinalIgnoreCase)) if (string.Equals(storedScreen, "management", StringComparison.OrdinalIgnoreCase))
{
CurrentScreen = "management"; CurrentScreen = "management";
}
var storedPanel = await JS.InvokeAsync<string?>("rpgRollerApi.getSessionValue", MobilePanelSessionKey); var storedPanel = await JS.InvokeAsync<string?>("rpgRollerApi.getSessionValue", MobilePanelSessionKey);
if (string.Equals(storedPanel, "log", StringComparison.OrdinalIgnoreCase)) if (string.Equals(storedPanel, "log", StringComparison.OrdinalIgnoreCase))
{
MobilePanel = "log"; MobilePanel = "log";
}
Guid? preferredCampaignId = null; Guid? preferredCampaignId = null;
var storedCampaignId = await JS.InvokeAsync<string?>("rpgRollerApi.getSessionValue", CampaignSessionKey); var storedCampaignId = await JS.InvokeAsync<string?>("rpgRollerApi.getSessionValue", CampaignSessionKey);
if (Guid.TryParse(storedCampaignId, out var parsedCampaignId)) if (Guid.TryParse(storedCampaignId, out var parsedCampaignId))
{
preferredCampaignId = parsedCampaignId; preferredCampaignId = parsedCampaignId;
}
await CheckHealthAsync(); await CheckHealthAsync();
await LoadRulesetsAsync(); await LoadRulesetsAsync();
var reloaded = await ReloadAuthenticatedSessionAsync(preferredCampaignId); var reloaded = await ReloadAuthenticatedSessionAsync(preferredCampaignId);
if (!reloaded) if (!reloaded)
{
await LoggedOut.InvokeAsync("Session expired. Please log in again."); await LoggedOut.InvokeAsync("Session expired. Please log in again.");
}
} }
private async Task RetryAfterHealthIssueAsync() private async Task RetryAfterHealthIssueAsync()
{ {
await CheckHealthAsync(); await CheckHealthAsync();
@@ -151,12 +48,10 @@ public partial class Workspace : IAsyncDisposable
{ {
var reloaded = await ReloadAuthenticatedSessionAsync(SelectedCampaignId); var reloaded = await ReloadAuthenticatedSessionAsync(SelectedCampaignId);
if (!reloaded) if (!reloaded)
{
await LoggedOut.InvokeAsync("Session expired. Please log in again."); await LoggedOut.InvokeAsync("Session expired. Please log in again.");
}
} }
} }
private async Task CheckHealthAsync() private async Task CheckHealthAsync()
{ {
try try
@@ -168,7 +63,7 @@ public partial class Workspace : IAsyncDisposable
HealthIssueMessage = "Health endpoint returned an unhealthy response."; HealthIssueMessage = "Health endpoint returned an unhealthy response.";
return; return;
} }
HasHealthIssue = false; HasHealthIssue = false;
HealthIssueMessage = string.Empty; HealthIssueMessage = string.Empty;
} }
@@ -178,7 +73,7 @@ public partial class Workspace : IAsyncDisposable
HealthIssueMessage = "Unable to reach API. Retry to continue."; HealthIssueMessage = "Unable to reach API. Retry to continue.";
} }
} }
private async Task LoadRulesetsAsync() private async Task LoadRulesetsAsync()
{ {
try try
@@ -190,7 +85,7 @@ public partial class Workspace : IAsyncDisposable
SetStatus(ex.Message, true); SetStatus(ex.Message, true);
} }
} }
private async Task<bool> ReloadAuthenticatedSessionAsync(Guid? preferredCampaignId) private async Task<bool> ReloadAuthenticatedSessionAsync(Guid? preferredCampaignId)
{ {
var me = await TryGetMeAsync(); var me = await TryGetMeAsync();
@@ -200,16 +95,16 @@ public partial class Workspace : IAsyncDisposable
await StopStateEventsAsync(); await StopStateEventsAsync();
return false; return false;
} }
User = me.User; User = me.User;
ActiveCharacterId = me.ActiveCharacterId; ActiveCharacterId = me.ActiveCharacterId;
await ReloadCampaignsAsync(preferredCampaignId ?? me.CurrentCampaignId); await ReloadCampaignsAsync(preferredCampaignId ?? me.CurrentCampaignId);
await RefreshCampaignScopeAsync(); await RefreshCampaignScopeAsync();
await SyncStateEventsAsync(); await SyncStateEventsAsync();
return true; return true;
} }
private async Task<MeResponse?> TryGetMeAsync() private async Task<MeResponse?> TryGetMeAsync()
{ {
try try
@@ -221,32 +116,28 @@ public partial class Workspace : IAsyncDisposable
return null; return null;
} }
} }
private async Task ReloadCampaignsAsync(Guid? preferredCampaignId) private async Task ReloadCampaignsAsync(Guid? preferredCampaignId)
{ {
var campaigns = await ApiClient.RequestAsync<IReadOnlyList<CampaignSummary>>("GET", "/api/campaigns"); var campaigns = await ApiClient.RequestAsync<IReadOnlyList<CampaignSummary>>("GET", "/api/campaigns");
Campaigns = campaigns.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase).ToList(); Campaigns = campaigns.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase).ToList();
if (Campaigns.Count == 0) if (Campaigns.Count == 0)
{ {
SelectedCampaignId = null; SelectedCampaignId = null;
await JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", CampaignSessionKey, null); await JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", CampaignSessionKey, null);
return; return;
} }
var campaignIds = Campaigns.Select(c => c.Id).ToHashSet(); var campaignIds = Campaigns.Select(c => c.Id).ToHashSet();
if (preferredCampaignId.HasValue && campaignIds.Contains(preferredCampaignId.Value)) if (preferredCampaignId.HasValue && campaignIds.Contains(preferredCampaignId.Value))
{
SelectedCampaignId = preferredCampaignId.Value; SelectedCampaignId = preferredCampaignId.Value;
}
else if (!SelectedCampaignId.HasValue || !campaignIds.Contains(SelectedCampaignId.Value)) else if (!SelectedCampaignId.HasValue || !campaignIds.Contains(SelectedCampaignId.Value))
{
SelectedCampaignId = Campaigns[0].Id; SelectedCampaignId = Campaigns[0].Id;
}
await JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", CampaignSessionKey, SelectedCampaignId?.ToString()); await JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", CampaignSessionKey, SelectedCampaignId?.ToString());
} }
private async Task RefreshCampaignScopeAsync() private async Task RefreshCampaignScopeAsync()
{ {
if (!SelectedCampaignId.HasValue) if (!SelectedCampaignId.HasValue)
@@ -258,7 +149,7 @@ public partial class Workspace : IAsyncDisposable
ConnectionState = "offline"; ConnectionState = "offline";
return; return;
} }
IsCampaignDataLoading = true; IsCampaignDataLoading = true;
try try
{ {
@@ -284,14 +175,12 @@ public partial class Workspace : IAsyncDisposable
IsCampaignDataLoading = false; IsCampaignDataLoading = false;
} }
} }
private async Task ManualRefreshAsync() private async Task ManualRefreshAsync()
{ {
if (IsMutating) if (IsMutating)
{
return; return;
}
IsMutating = true; IsMutating = true;
try try
{ {
@@ -302,7 +191,7 @@ public partial class Workspace : IAsyncDisposable
await LoggedOut.InvokeAsync("Session expired. Please log in again."); await LoggedOut.InvokeAsync("Session expired. Please log in again.");
return; return;
} }
SetStatus("Campaign data refreshed.", false); SetStatus("Campaign data refreshed.", false);
} }
finally finally
@@ -310,14 +199,12 @@ public partial class Workspace : IAsyncDisposable
IsMutating = false; IsMutating = false;
} }
} }
private async Task LogoutAsync() private async Task LogoutAsync()
{ {
if (IsMutating) if (IsMutating)
{
return; return;
}
IsMutating = true; IsMutating = true;
try try
{ {
@@ -330,43 +217,55 @@ public partial class Workspace : IAsyncDisposable
{ {
IsMutating = false; IsMutating = false;
} }
ClearAuthenticatedState(); ClearAuthenticatedState();
await StopStateEventsAsync(); await StopStateEventsAsync();
await LoggedOut.InvokeAsync("Logged out."); await LoggedOut.InvokeAsync("Logged out.");
} }
private async Task SwitchScreenAsync(string screen) private async Task SwitchScreenAsync(string screen)
{ {
CurrentScreen = string.Equals(screen, "management", StringComparison.OrdinalIgnoreCase) ? "management" : "play"; CurrentScreen = string.Equals(screen, "management", StringComparison.OrdinalIgnoreCase) ? "management" : "play";
await JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", ScreenSessionKey, CurrentScreen); await JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", ScreenSessionKey, CurrentScreen);
} }
private Task SwitchToPlayAsync() => SwitchScreenAsync("play"); private Task SwitchToPlayAsync()
private Task SwitchToManagementAsync() => SwitchScreenAsync("management"); {
return SwitchScreenAsync("play");
}
private Task SwitchToManagementAsync()
{
return SwitchScreenAsync("management");
}
private async Task SetMobilePanelAsync(string panel) private async Task SetMobilePanelAsync(string panel)
{ {
MobilePanel = string.Equals(panel, "log", StringComparison.OrdinalIgnoreCase) ? "log" : "character"; MobilePanel = string.Equals(panel, "log", StringComparison.OrdinalIgnoreCase) ? "log" : "character";
await JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", MobilePanelSessionKey, MobilePanel); await JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", MobilePanelSessionKey, MobilePanel);
} }
private Task SetMobilePanelCharacterAsync() => SetMobilePanelAsync("character"); private Task SetMobilePanelCharacterAsync()
private Task SetMobilePanelLogAsync() => SetMobilePanelAsync("log"); {
return SetMobilePanelAsync("character");
}
private Task SetMobilePanelLogAsync()
{
return SetMobilePanelAsync("log");
}
private async Task OnCampaignSelectionChangedAsync(ChangeEventArgs args) private async Task OnCampaignSelectionChangedAsync(ChangeEventArgs args)
{ {
if (!Guid.TryParse(args.Value?.ToString(), out var campaignId)) if (!Guid.TryParse(args.Value?.ToString(), out var campaignId))
{
return; return;
}
SelectedCampaignId = campaignId; SelectedCampaignId = campaignId;
await JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", CampaignSessionKey, campaignId.ToString()); await JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", CampaignSessionKey, campaignId.ToString());
await RefreshCampaignScopeAsync(); await RefreshCampaignScopeAsync();
await SyncStateEventsAsync(); await SyncStateEventsAsync();
} }
private async Task OnCampaignCreatedAsync(Guid campaignId) private async Task OnCampaignCreatedAsync(Guid campaignId)
{ {
await ReloadCampaignsAsync(campaignId); await ReloadCampaignsAsync(campaignId);
@@ -374,39 +273,39 @@ public partial class Workspace : IAsyncDisposable
await SyncStateEventsAsync(); await SyncStateEventsAsync();
SetStatus("Campaign created.", false); SetStatus("Campaign created.", false);
} }
private void OpenCreateCharacterModal() private void OpenCreateCharacterModal()
{ {
CreateCharacterInitialModel = new CharacterFormModel CreateCharacterInitialModel = new()
{ {
Name = string.Empty, Name = string.Empty,
CampaignId = SelectedCampaignId?.ToString() ?? string.Empty CampaignId = SelectedCampaignId?.ToString() ?? string.Empty
}; };
CreateCharacterFormVersion++; CreateCharacterFormVersion++;
ShowCreateCharacterModal = true; ShowCreateCharacterModal = true;
} }
private void OpenEditCharacterModal(CharacterSummary character) private void OpenEditCharacterModal(CharacterSummary character)
{ {
EditingCharacterId = character.Id; EditingCharacterId = character.Id;
EditCharacterInitialModel = new CharacterFormModel EditCharacterInitialModel = new()
{ {
Name = character.Name, Name = character.Name,
CampaignId = character.CampaignId.ToString() CampaignId = character.CampaignId.ToString()
}; };
EditCharacterFormVersion++; EditCharacterFormVersion++;
ShowEditCharacterModal = true; ShowEditCharacterModal = true;
} }
private void CloseCharacterModals() private void CloseCharacterModals()
{ {
ShowCreateCharacterModal = false; ShowCreateCharacterModal = false;
ShowEditCharacterModal = false; ShowEditCharacterModal = false;
EditingCharacterId = null; EditingCharacterId = null;
} }
private async Task OnCharacterCreatedAsync(Guid campaignId) private async Task OnCharacterCreatedAsync(Guid campaignId)
{ {
CloseCharacterModals(); CloseCharacterModals();
@@ -415,7 +314,7 @@ public partial class Workspace : IAsyncDisposable
await SyncStateEventsAsync(); await SyncStateEventsAsync();
SetStatus("Character created.", false); SetStatus("Character created.", false);
} }
private async Task OnCharacterUpdatedAsync(Guid campaignId) private async Task OnCharacterUpdatedAsync(Guid campaignId)
{ {
CloseCharacterModals(); CloseCharacterModals();
@@ -424,37 +323,33 @@ public partial class Workspace : IAsyncDisposable
await SyncStateEventsAsync(); await SyncStateEventsAsync();
SetStatus("Character updated.", false); SetStatus("Character updated.", false);
} }
private async Task SelectCharacterAsync(Guid characterId) private async Task SelectCharacterAsync(Guid characterId)
{ {
SelectedCharacterId = characterId; SelectedCharacterId = characterId;
SyncSelectedSkill(); SyncSelectedSkill();
await EnsureSelectedCharacterActiveAsync(); await EnsureSelectedCharacterActiveAsync();
} }
private bool CanEditCharacter(CharacterSummary character) private bool CanEditCharacter(CharacterSummary character)
{ {
return User is not null && (character.OwnerUserId == User.Id || IsCurrentUserGm); return User is not null && (character.OwnerUserId == User.Id || IsCurrentUserGm);
} }
private static bool CanActivateCharacter(CharacterSummary character, UserSummary? user) private static bool CanActivateCharacter(CharacterSummary character, UserSummary? user)
{ {
return user is not null && character.OwnerUserId == user.Id; return user is not null && character.OwnerUserId == user.Id;
} }
private async Task EnsureSelectedCharacterActiveAsync() private async Task EnsureSelectedCharacterActiveAsync()
{ {
if (!SelectedCharacterId.HasValue || SelectedCampaign is null) if (!SelectedCharacterId.HasValue || SelectedCampaign is null)
{
return; return;
}
var character = SelectedCampaign.Characters.FirstOrDefault(c => c.Id == SelectedCharacterId.Value); var character = SelectedCampaign.Characters.FirstOrDefault(c => c.Id == SelectedCharacterId.Value);
if (character is null || !CanActivateCharacter(character, User) || ActiveCharacterId == character.Id) if (character is null || !CanActivateCharacter(character, User) || ActiveCharacterId == character.Id)
{
return; return;
}
try try
{ {
await ApiClient.RequestWithoutPayloadAsync("POST", $"/api/characters/{character.Id}/activate"); await ApiClient.RequestWithoutPayloadAsync("POST", $"/api/characters/{character.Id}/activate");
@@ -465,20 +360,20 @@ public partial class Workspace : IAsyncDisposable
SetStatus(ex.Message, true); SetStatus(ex.Message, true);
} }
} }
private async Task OnSkillCreatedAsync(Guid _) private async Task OnSkillCreatedAsync(Guid _)
{ {
await RefreshCampaignScopeAsync(); await RefreshCampaignScopeAsync();
SetStatus("Skill created.", false); SetStatus("Skill created.", false);
} }
private async Task OnSkillUpdatedAsync(Guid skillId) private async Task OnSkillUpdatedAsync(Guid skillId)
{ {
SelectedSkillId = skillId; SelectedSkillId = skillId;
await RefreshCampaignScopeAsync(); await RefreshCampaignScopeAsync();
SetStatus("Skill updated.", false); SetStatus("Skill updated.", false);
} }
private async Task RollSelectedSkillAsync() private async Task RollSelectedSkillAsync()
{ {
if (SelectedSkill is null) if (SelectedSkill is null)
@@ -486,15 +381,12 @@ public partial class Workspace : IAsyncDisposable
SetStatus("Select a skill to roll.", true); SetStatus("Select a skill to roll.", true);
return; return;
} }
IsMutating = true; IsMutating = true;
try try
{ {
LastRoll = await ApiClient.RequestAsync<RollResult>( LastRoll = await ApiClient.RequestAsync<RollResult>("POST", $"/api/skills/{SelectedSkill.Id}/roll", new RollSkillRequest(RollVisibility));
"POST",
$"/api/skills/{SelectedSkill.Id}/roll",
new RollSkillRequest(RollVisibility));
await RefreshCampaignScopeAsync(); await RefreshCampaignScopeAsync();
SetStatus("Roll recorded.", false); SetStatus("Roll recorded.", false);
Announce("Roll result updated."); Announce("Roll result updated.");
@@ -508,42 +400,38 @@ public partial class Workspace : IAsyncDisposable
IsMutating = false; IsMutating = false;
} }
} }
private Task OnRollVisibilityChanged(string visibility) private Task OnRollVisibilityChanged(string visibility)
{ {
RollVisibility = string.Equals(visibility, "private", StringComparison.OrdinalIgnoreCase) ? "private" : "public"; RollVisibility = string.Equals(visibility, "private", StringComparison.OrdinalIgnoreCase) ? "private" : "public";
return Task.CompletedTask; return Task.CompletedTask;
} }
private void SelectSkill(Guid skillId) private void SelectSkill(Guid skillId)
{ {
SelectedSkillId = skillId; SelectedSkillId = skillId;
} }
private bool CanEditSkill(SkillSummary skill) private bool CanEditSkill(SkillSummary skill)
{ {
if (SelectedCampaign is null) if (SelectedCampaign is null)
{
return false; return false;
}
var character = SelectedCampaign.Characters.FirstOrDefault(c => c.Id == skill.CharacterId); var character = SelectedCampaign.Characters.FirstOrDefault(c => c.Id == skill.CharacterId);
return character is not null && CanEditCharacter(character); return character is not null && CanEditCharacter(character);
} }
private bool CanRollSkill(SkillSummary skill) private bool CanRollSkill(SkillSummary skill)
{ {
return CanEditSkill(skill); return CanEditSkill(skill);
} }
[JSInvokable] [JSInvokable]
public async Task OnStateEventReceived(long _) public async Task OnStateEventReceived(long _)
{ {
if (StateRefreshInProgress) if (StateRefreshInProgress)
{
return; return;
}
StateRefreshInProgress = true; StateRefreshInProgress = true;
try try
{ {
@@ -555,30 +443,26 @@ public partial class Workspace : IAsyncDisposable
await InvokeAsync(StateHasChanged); await InvokeAsync(StateHasChanged);
} }
} }
[JSInvokable] [JSInvokable]
public Task OnConnectionStateChanged(string state) public Task OnConnectionStateChanged(string state)
{ {
ConnectionState = state switch ConnectionState = state switch
{ {
"connected" => "connected", "connected" => "connected",
"reconnecting" => "reconnecting", "reconnecting" => "reconnecting",
_ => "offline" _ => "offline"
}; };
if (ConnectionState == "reconnecting") if (ConnectionState == "reconnecting")
{
Announce("Reconnecting to live updates."); Announce("Reconnecting to live updates.");
}
if (ConnectionState == "offline") if (ConnectionState == "offline")
{
Announce("Live updates offline. Use manual refresh."); Announce("Live updates offline. Use manual refresh.");
}
return InvokeAsync(StateHasChanged); return InvokeAsync(StateHasChanged);
} }
private async Task SyncStateEventsAsync() private async Task SyncStateEventsAsync()
{ {
if (User is null || !SelectedCampaignId.HasValue) if (User is null || !SelectedCampaignId.HasValue)
@@ -587,19 +471,17 @@ public partial class Workspace : IAsyncDisposable
ConnectionState = "offline"; ConnectionState = "offline";
return; return;
} }
DotNetRef ??= DotNetObjectReference.Create(this); DotNetRef ??= DotNetObjectReference.Create(this);
await JS.InvokeVoidAsync("rpgRollerApi.startStateEvents", SelectedCampaignId.Value.ToString(), DotNetRef); await JS.InvokeVoidAsync("rpgRollerApi.startStateEvents", SelectedCampaignId.Value.ToString(), DotNetRef);
ConnectionState = "reconnecting"; ConnectionState = "reconnecting";
} }
private async Task StopStateEventsAsync() private async Task StopStateEventsAsync()
{ {
if (!HasInteractiveRenderStarted) if (!HasInteractiveRenderStarted)
{
return; return;
}
try try
{ {
await JS.InvokeVoidAsync("rpgRollerApi.stopStateEvents"); await JS.InvokeVoidAsync("rpgRollerApi.stopStateEvents");
@@ -611,18 +493,18 @@ public partial class Workspace : IAsyncDisposable
{ {
} }
} }
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
{ {
await StopStateEventsAsync(); await StopStateEventsAsync();
DotNetRef?.Dispose(); DotNetRef?.Dispose();
} }
private static bool IsStaticRenderInteropException(InvalidOperationException exception) private static bool IsStaticRenderInteropException(InvalidOperationException exception)
{ {
return exception.Message.Contains("statically rendered", StringComparison.OrdinalIgnoreCase); return exception.Message.Contains("statically rendered", StringComparison.OrdinalIgnoreCase);
} }
private void SyncSelectedCharacter() private void SyncSelectedCharacter()
{ {
if (SelectedCampaign is null || SelectedCampaign.Characters.Count == 0) if (SelectedCampaign is null || SelectedCampaign.Characters.Count == 0)
@@ -630,22 +512,20 @@ public partial class Workspace : IAsyncDisposable
SelectedCharacterId = null; SelectedCharacterId = null;
return; return;
} }
var candidateIds = SelectedCampaign.Characters.Select(c => c.Id).ToHashSet(); var candidateIds = SelectedCampaign.Characters.Select(c => c.Id).ToHashSet();
if (SelectedCharacterId.HasValue && candidateIds.Contains(SelectedCharacterId.Value)) if (SelectedCharacterId.HasValue && candidateIds.Contains(SelectedCharacterId.Value))
{
return; return;
}
if (ActiveCharacterId.HasValue && candidateIds.Contains(ActiveCharacterId.Value)) if (ActiveCharacterId.HasValue && candidateIds.Contains(ActiveCharacterId.Value))
{ {
SelectedCharacterId = ActiveCharacterId; SelectedCharacterId = ActiveCharacterId;
return; return;
} }
SelectedCharacterId = SelectedCampaign.Characters[0].Id; SelectedCharacterId = SelectedCampaign.Characters[0].Id;
} }
private void SyncSelectedSkill() private void SyncSelectedSkill()
{ {
var skills = SelectedCharacterSkills; var skills = SelectedCharacterSkills;
@@ -654,111 +534,87 @@ public partial class Workspace : IAsyncDisposable
SelectedSkillId = null; SelectedSkillId = null;
return; return;
} }
if (SelectedSkillId.HasValue && skills.Any(skill => skill.Id == SelectedSkillId.Value)) if (SelectedSkillId.HasValue && skills.Any(skill => skill.Id == SelectedSkillId.Value))
{
return; return;
}
SelectedSkillId = skills[0].Id; SelectedSkillId = skills[0].Id;
} }
private string OwnerLabel(Guid ownerUserId) private string OwnerLabel(Guid ownerUserId)
{ {
if (User is not null && ownerUserId == User.Id) if (User is not null && ownerUserId == User.Id)
{
return "You"; return "You";
}
if (SelectedCampaign is not null && ownerUserId == SelectedCampaign.Gm.Id) if (SelectedCampaign is not null && ownerUserId == SelectedCampaign.Gm.Id)
{
return $"{SelectedCampaign.Gm.DisplayName} (GM)"; return $"{SelectedCampaign.Gm.DisplayName} (GM)";
}
return ownerUserId.ToString("N")[..8]; return ownerUserId.ToString("N")[..8];
} }
private string CharacterLabel(Guid characterId) private string CharacterLabel(Guid characterId)
{ {
return SelectedCampaign?.Characters.FirstOrDefault(c => c.Id == characterId)?.Name ?? "Hidden character"; return SelectedCampaign?.Characters.FirstOrDefault(c => c.Id == characterId)?.Name ?? "Hidden character";
} }
private string SkillLabel(Guid skillId) private string SkillLabel(Guid skillId)
{ {
return SelectedCampaign?.Skills.FirstOrDefault(s => s.Id == skillId)?.Name ?? "Hidden skill"; return SelectedCampaign?.Skills.FirstOrDefault(s => s.Id == skillId)?.Name ?? "Hidden skill";
} }
private string SkillDefinitionLabel(SkillSummary skill) private string SkillDefinitionLabel(SkillSummary skill)
{ {
if (!string.Equals(SelectedCampaign?.RulesetId, "d6", StringComparison.OrdinalIgnoreCase)) if (!string.Equals(SelectedCampaign?.RulesetId, "d6", StringComparison.OrdinalIgnoreCase))
{
return skill.DiceRollDefinition; return skill.DiceRollDefinition;
}
var fumbleLabel = skill.AllowFumble ? "fumble on" : "fumble off"; var fumbleLabel = skill.AllowFumble ? "fumble on" : "fumble off";
return $"{skill.DiceRollDefinition} | wild {skill.WildDice}, {fumbleLabel}"; return $"{skill.DiceRollDefinition} | wild {skill.WildDice}, {fumbleLabel}";
} }
private string RollerLabel(CampaignLogEntry entry) private string RollerLabel(CampaignLogEntry entry)
{ {
if (User is not null && entry.RollerUserId == User.Id) if (User is not null && entry.RollerUserId == User.Id)
{
return "You"; return "You";
}
if (SelectedCampaign is not null && entry.RollerUserId == SelectedCampaign.Gm.Id) if (SelectedCampaign is not null && entry.RollerUserId == SelectedCampaign.Gm.Id)
{
return "GM"; return "GM";
}
return "Participant"; return "Participant";
} }
private string VisibilityLabel(CampaignLogEntry entry) private string VisibilityLabel(CampaignLogEntry entry)
{ {
if (!string.Equals(entry.Visibility, "private", StringComparison.OrdinalIgnoreCase)) if (!string.Equals(entry.Visibility, "private", StringComparison.OrdinalIgnoreCase))
{
return "Public"; return "Public";
}
if (User is not null && entry.RollerUserId == User.Id) if (User is not null && entry.RollerUserId == User.Id)
{
return "Private (you)"; return "Private (you)";
}
return IsCurrentUserGm ? "Private (GM view)" : "Private"; return IsCurrentUserGm ? "Private (GM view)" : "Private";
} }
private string VisibilityBadgeCssClass(CampaignLogEntry entry) private string VisibilityBadgeCssClass(CampaignLogEntry entry)
{ {
if (!string.Equals(entry.Visibility, "private", StringComparison.OrdinalIgnoreCase)) if (!string.Equals(entry.Visibility, "private", StringComparison.OrdinalIgnoreCase))
{
return "public"; return "public";
}
if (User is not null && entry.RollerUserId == User.Id) if (User is not null && entry.RollerUserId == User.Id)
{
return "private-self"; return "private-self";
}
return IsCurrentUserGm ? "private-gm" : "private-generic"; return IsCurrentUserGm ? "private-gm" : "private-generic";
} }
private string LogEntryCssClass(CampaignLogEntry entry) private string LogEntryCssClass(CampaignLogEntry entry)
{ {
if (!string.Equals(entry.Visibility, "private", StringComparison.OrdinalIgnoreCase)) if (!string.Equals(entry.Visibility, "private", StringComparison.OrdinalIgnoreCase))
{
return "public"; return "public";
}
if (User is not null && entry.RollerUserId == User.Id) if (User is not null && entry.RollerUserId == User.Id)
{
return "private-self"; return "private-self";
}
return IsCurrentUserGm ? "private-gm" : "private-generic"; return IsCurrentUserGm ? "private-gm" : "private-generic";
} }
private void ClearAuthenticatedState() private void ClearAuthenticatedState()
{ {
User = null; User = null;
@@ -777,16 +633,102 @@ public partial class Workspace : IAsyncDisposable
CreateCharacterFormVersion = 0; CreateCharacterFormVersion = 0;
EditCharacterFormVersion = 0; EditCharacterFormVersion = 0;
} }
private void SetStatus(string message, bool isError) private void SetStatus(string message, bool isError)
{ {
StatusMessage = message; StatusMessage = message;
StatusIsError = isError; StatusIsError = isError;
Announce(message); Announce(message);
} }
private void Announce(string message) private void Announce(string message)
{ {
LiveAnnouncement = message; LiveAnnouncement = message;
} }
}
[Inject]
private IJSRuntime JS { get; set; } = null!;
[Inject]
private RpgRollerApiClient ApiClient { get; set; } = null!;
private UserSummary? User { get; set; }
private Guid? ActiveCharacterId { get; set; }
private Guid? SelectedCampaignId { get; set; }
private CampaignDetails? SelectedCampaign { get; set; }
private List<CampaignSummary> Campaigns { get; set; } = [];
private List<CampaignLogEntry> CampaignLog { get; set; } = [];
private List<RulesetDefinition> Rulesets { get; set; } = [];
private Guid? SelectedCharacterId { get; set; }
private Guid? SelectedSkillId { get; set; }
private RollResult? LastRoll { get; set; }
private string RollVisibility { get; set; } = "public";
private bool IsMutating { get; set; }
private bool IsCampaignDataLoading { get; set; }
private bool HasHealthIssue { get; set; }
private string HealthIssueMessage { get; set; } = "Retry to restore the API connection.";
private string? StatusMessage { get; set; }
private bool StatusIsError { get; set; }
private string CurrentScreen { get; set; } = "play";
private string MobilePanel { get; set; } = "character";
private string ConnectionState { get; set; } = "offline";
private string LiveAnnouncement { get; set; } = string.Empty;
private bool ShowCreateCharacterModal { get; set; }
private bool ShowEditCharacterModal { get; set; }
private Guid? EditingCharacterId { get; set; }
private CharacterFormModel CreateCharacterInitialModel { get; set; } = new();
private CharacterFormModel EditCharacterInitialModel { get; set; } = new();
private int CreateCharacterFormVersion { get; set; }
private int EditCharacterFormVersion { get; set; }
private bool StateRefreshInProgress { get; set; }
private bool HasInteractiveRenderStarted { get; set; }
private DotNetObjectReference<Workspace>? DotNetRef { get; set; }
[Parameter]
public EventCallback<string?> LoggedOut { get; set; }
private string? SelectedCampaignName => SelectedCampaign?.Name;
private CharacterSummary? SelectedCharacter =>
SelectedCampaign?.Characters.FirstOrDefault(c => c.Id == SelectedCharacterId);
private SkillSummary? SelectedSkill =>
SelectedCampaign?.Skills.FirstOrDefault(s => s.Id == SelectedSkillId);
private string? ActiveCharacterName =>
SelectedCampaign?.Characters.FirstOrDefault(c => c.Id == SelectedCharacterId)?.Name;
private bool IsCurrentUserGm =>
SelectedCampaign is not null && User is not null && SelectedCampaign.Gm.Id == User.Id;
private bool IsSelectedCampaignD6 =>
string.Equals(SelectedCampaign?.RulesetId, "d6", StringComparison.OrdinalIgnoreCase);
private List<SkillSummary> SelectedCharacterSkills =>
SelectedCampaign is null || !SelectedCharacterId.HasValue ? [] : SelectedCampaign.Skills.Where(skill => skill.CharacterId == SelectedCharacterId.Value).OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase).ToList();
private bool IsPlayScreen => string.Equals(CurrentScreen, "play", StringComparison.OrdinalIgnoreCase);
private bool IsManagementScreen => !IsPlayScreen;
private string ConnectionStateLabel => ConnectionState switch
{
"connected" => "Connected",
"reconnecting" => "Reconnecting",
_ => "Offline fallback"
};
private string ConnectionStateCssClass => ConnectionState switch
{
"connected" => "ok",
"reconnecting" => "warn",
_ => "offline"
};
private string AppCssClass => IsPlayScreen ? "rr-app app-play" : "rr-app";
private const string ScreenSessionKey = "screen";
private const string CampaignSessionKey = "campaign";
private const string MobilePanelSessionKey = "play-panel";
}

View File

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

View File

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

View File

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

View File

@@ -5,18 +5,10 @@ namespace RpgRoller.Data;
public sealed class RpgRollerDbContext : DbContext public sealed class RpgRollerDbContext : DbContext
{ {
public RpgRollerDbContext(DbContextOptions<RpgRollerDbContext> options) public RpgRollerDbContext(DbContextOptions<RpgRollerDbContext> options) : base(options)
: base(options)
{ {
} }
public DbSet<UserAccount> Users => Set<UserAccount>();
public DbSet<UserSession> Sessions => Set<UserSession>();
public DbSet<Campaign> Campaigns => Set<Campaign>();
public DbSet<Character> Characters => Set<Character>();
public DbSet<Skill> Skills => Set<Skill>();
public DbSet<RollLogEntry> RollLogEntries => Set<RollLogEntry>();
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
modelBuilder.Entity<UserAccount>(entity => modelBuilder.Entity<UserAccount>(entity =>
@@ -77,4 +69,11 @@ public sealed class RpgRollerDbContext : DbContext
entity.HasIndex(x => x.CharacterId); entity.HasIndex(x => x.CharacterId);
}); });
} }
}
public DbSet<UserAccount> Users => Set<UserAccount>();
public DbSet<UserSession> Sessions => Set<UserSession>();
public DbSet<Campaign> Campaigns => Set<Campaign>();
public DbSet<Character> Characters => Set<Character>();
public DbSet<Skill> Skills => Set<Skill>();
public DbSet<RollLogEntry> RollLogEntries => Set<RollLogEntry>();
}

View File

@@ -70,4 +70,4 @@ public sealed class RollLogEntry
public required DateTimeOffset TimestampUtc { get; init; } public required DateTimeOffset TimestampUtc { get; init; }
} }
public sealed record DiceExpression(int DiceCount, int Sides, int Modifier, string Canonical); public sealed record DiceExpression(int DiceCount, int Sides, int Modifier, string Canonical);

View File

@@ -14,4 +14,4 @@ public static class ApplicationInitializationExtensions
SqliteSchemaUpgrader.ApplyPendingChanges(db); SqliteSchemaUpgrader.ApplyPendingChanges(db);
_ = scope.ServiceProvider.GetRequiredService<IGameService>(); _ = scope.ServiceProvider.GetRequiredService<IGameService>();
} }
} }

View File

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

View File

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

View File

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

View File

@@ -1,17 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.2"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.2">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.2" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.2"/>
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

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

View File

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

View File

@@ -3,4 +3,4 @@ namespace RpgRoller.Services;
public interface IDiceRoller public interface IDiceRoller
{ {
int Roll(int sides); int Roll(int sides);
} }

View File

@@ -1,5 +1,4 @@
using RpgRoller.Contracts; using RpgRoller.Contracts;
using RpgRoller.Domain;
namespace RpgRoller.Services; namespace RpgRoller.Services;
@@ -29,4 +28,4 @@ public interface IGameService
ServiceResult<IReadOnlyList<CampaignLogEntry>> GetCampaignLog(string sessionToken, Guid campaignId); ServiceResult<IReadOnlyList<CampaignLogEntry>> GetCampaignLog(string sessionToken, Guid campaignId);
ServiceResult<long> GetCampaignVersion(string sessionToken, Guid campaignId); ServiceResult<long> GetCampaignVersion(string sessionToken, Guid campaignId);
} }

View File

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

View File

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

View File

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

View File

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

View File

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