Code cleanup

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -77,4 +77,4 @@ public sealed class ServiceD6RollTests
Assert.Equal(0, dndSkill.WildDice);
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(service.GetCampaignLog(string.Empty, campaign.Id).Succeeded);
}
}
}

View File

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

View File

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

View File

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

View File

@@ -8,8 +8,7 @@ public static class ApiEndpointRegistration
api.MapSystemEndpoints();
api.MapAuthEndpoints();
var authenticatedApi = api.MapGroup(string.Empty)
.AddEndpointFilter<RequireSessionTokenFilter>();
var authenticatedApi = api.MapGroup(string.Empty).AddEndpointFilter<RequireSessionTokenFilter>();
authenticatedApi.MapMeEndpoints();
authenticatedApi.MapCampaignEndpoints();
@@ -17,4 +16,4 @@ public static class ApiEndpointRegistration
authenticatedApi.MapSkillEndpoints();
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)
{
if (result.Succeeded)
{
return TypedResults.Ok(result.Value!);
}
if (result.Error!.Code == "unauthorized")
{
return TypedResults.Unauthorized();
}
return TypedResults.BadRequest(new ApiError(result.Error.Message));
}
@@ -25,4 +21,4 @@ internal static class ApiResultMapper
{
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);
if (!result.Succeeded)
{
return ApiResultMapper.ToBadRequest(result.Error!);
}
return TypedResults.Ok(result.Value!);
});
@@ -23,11 +21,9 @@ internal static class AuthEndpoints
{
var result = game.Login(request.Username, request.Password);
if (!result.Succeeded)
{
return ApiResultMapper.ToBadRequest(result.Error!);
}
context.Response.Cookies.Append(SessionCookie.Name, result.Value.SessionToken, new CookieOptions
context.Response.Cookies.Append(SessionCookie.Name, result.Value.SessionToken, new()
{
HttpOnly = true,
SameSite = SameSiteMode.Strict,
@@ -41,9 +37,7 @@ internal static class AuthEndpoints
group.MapPost("/auth/logout", (HttpContext context, IGameService game) =>
{
if (context.TryReadSessionTokenFromCookie(out var sessionToken))
{
game.Logout(sessionToken);
}
context.Response.Cookies.Delete(SessionCookie.Name);
return TypedResults.NoContent();
@@ -51,4 +45,4 @@ internal static class AuthEndpoints
return group;
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Components;
using RpgRoller.Contracts;
using System.Diagnostics.CodeAnalysis;
namespace RpgRoller.Components.Pages.HomeControls;
@@ -9,25 +9,25 @@ public partial class CampaignLogPanel
{
[Parameter]
public bool IsCampaignDataLoading { get; set; }
[Parameter]
public IReadOnlyList<CampaignLogEntry> CampaignLog { get; set; } = [];
[Parameter]
public Func<CampaignLogEntry, string> RollerLabel { get; set; } = _ => string.Empty;
[Parameter]
public Func<Guid, string> SkillLabel { get; set; } = _ => string.Empty;
[Parameter]
public Func<Guid, string> CharacterLabel { get; set; } = _ => string.Empty;
[Parameter]
public Func<CampaignLogEntry, string> LogEntryCssClass { get; set; } = _ => string.Empty;
[Parameter]
public Func<CampaignLogEntry, string> VisibilityLabel { get; set; } = _ => string.Empty;
[Parameter]
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">
<section class="card">
<h2>Campaign Selector</h2>
@@ -16,7 +11,9 @@
<select id="campaign-select" @onchange="CampaignSelectionChanged">
@foreach (var campaign in Campaigns)
{
<option value="@campaign.Id" selected="@(campaign.Id == SelectedCampaignId)">@campaign.Name (@campaign.RulesetId)</option>
<option value="@campaign.Id"
selected="@(campaign.Id == SelectedCampaignId)">@campaign.Name (@campaign.RulesetId)
</option>
}
</select>
}
@@ -31,7 +28,7 @@
}
<form class="form-grid" @onsubmit="SubmitCreateCampaignAsync" @onsubmit:preventDefault>
<label for="campaign-name">Campaign name</label>
<input id="campaign-name" @bind="CampaignState.Model.Name" @bind:event="oninput" />
<input id="campaign-name" @bind="CampaignState.Model.Name" @bind:event="oninput"/>
@if (CampaignState.Errors.TryGetValue("name", out var campaignNameError))
{
<p class="field-error">@campaignNameError</p>
@@ -48,7 +45,8 @@
{
<p class="field-error">@campaignRulesetError</p>
}
<button type="submit" disabled="@(IsMutating || IsCreatingCampaign)">@(IsCreatingCampaign ? "Creating..." : "Create Campaign")</button>
<button type="submit"
disabled="@(IsMutating || IsCreatingCampaign)">@(IsCreatingCampaign ? "Creating..." : "Create Campaign")</button>
</form>
</section>
@@ -62,7 +60,8 @@
{
<p>Name: <strong>@SelectedCampaign.Name</strong></p>
<p>Ruleset: <strong>@SelectedCampaign.RulesetId</strong></p>
<p>GM: <strong>@SelectedCampaign.Gm.DisplayName</strong> <span class="muted">(@SelectedCampaign.Gm.Username)</span></p>
<p>GM: <strong>@SelectedCampaign.Gm.DisplayName</strong> <span
class="muted">(@SelectedCampaign.Gm.Username)</span></p>
<p>Characters visible: <strong>@SelectedCampaign.Characters.Count</strong></p>
}
</section>
@@ -70,7 +69,9 @@
<section class="card">
<div class="section-head">
<h2>Character Management</h2>
<button type="button" disabled="@(IsMutating || IsCreatingCampaign || SelectedCampaign is null)" @onclick="CreateCharacterRequested">Create Character</button>
<button type="button" disabled="@(IsMutating || IsCreatingCampaign || SelectedCampaign is null)"
@onclick="CreateCharacterRequested">Create Character
</button>
</div>
@if (SelectedCampaign is null)
{
@@ -86,9 +87,13 @@
@foreach (var character in SelectedCampaign.Characters)
{
<li>
<div><strong>@character.Name</strong><p class="muted">Owner: @OwnerLabel(character.OwnerUserId)</p></div>
<div><strong>@character.Name</strong>
<p class="muted">Owner: @OwnerLabel(character.OwnerUserId)</p></div>
<div class="inline-actions">
<button type="button" disabled="@(IsMutating || IsCreatingCampaign || !CanEditCharacter(character))" @onclick="() => EditCharacterRequested.InvokeAsync(character)">Edit</button>
<button type="button"
disabled="@(IsMutating || IsCreatingCampaign || !CanEditCharacter(character))"
@onclick="() => EditCharacterRequested.InvokeAsync(character)">Edit
</button>
</div>
</li>
}

View File

@@ -1,92 +1,39 @@
using Microsoft.AspNetCore.Components;
using RpgRoller.Components.Pages;
using RpgRoller.Components;
using RpgRoller.Contracts;
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Components;
using RpgRoller.Contracts;
namespace RpgRoller.Components.Pages.HomeControls;
[ExcludeFromCodeCoverage]
public partial class CampaignManagementPanel
{
[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()
{
if (string.IsNullOrWhiteSpace(CampaignState.Model.RulesetId) && Rulesets.Count > 0)
{
CampaignState.Model.RulesetId = Rulesets[0].Id;
}
}
private async Task SubmitCreateCampaignAsync()
{
CampaignState.ResetValidation();
if (string.IsNullOrWhiteSpace(CampaignState.Model.Name))
{
CampaignState.Errors["name"] = "Campaign name is required.";
}
if (string.IsNullOrWhiteSpace(CampaignState.Model.RulesetId))
{
CampaignState.Errors["rulesetId"] = "Ruleset is required.";
}
if (CampaignState.Errors.Count > 0)
{
CampaignState.ErrorMessage = "Resolve validation issues before submitting.";
return;
}
IsCreatingCampaign = true;
try
{
var campaign = await ApiClient.RequestAsync<CampaignSummary>(
"POST",
"/api/campaigns",
new CreateCampaignRequest(CampaignState.Model.Name.Trim(), CampaignState.Model.RulesetId));
var campaign = await ApiClient.RequestAsync<CampaignSummary>("POST", "/api/campaigns", new CreateCampaignRequest(CampaignState.Model.Name.Trim(), CampaignState.Model.RulesetId));
CampaignState.Model.Name = string.Empty;
await CampaignCreated.InvokeAsync(campaign.Id);
}
@@ -99,4 +46,46 @@ public partial class CampaignManagementPanel
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)
{
<div class="modal-overlay" role="presentation">
@@ -14,7 +9,7 @@
}
<form class="form-grid" @onsubmit="SubmitAsync" @onsubmit:preventDefault>
<label for="@NameInputId">Character name</label>
<input id="@NameInputId" @bind="FormState.Model.Name" @bind:event="oninput" />
<input id="@NameInputId" @bind="FormState.Model.Name" @bind:event="oninput"/>
@if (FormState.Errors.TryGetValue("name", out var nameError))
{
<p class="field-error">@nameError</p>

View File

@@ -1,109 +1,52 @@
using Microsoft.AspNetCore.Components;
using RpgRoller.Components.Pages;
using RpgRoller.Components;
using RpgRoller.Contracts;
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Components;
using RpgRoller.Contracts;
namespace RpgRoller.Components.Pages.HomeControls;
[ExcludeFromCodeCoverage]
public partial class CharacterFormModal
{
[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()
{
if (!Visible || FormVersion == AppliedFormVersion)
{
return;
}
FormState.Model.Name = InitialModel.Name;
FormState.Model.CampaignId = InitialModel.CampaignId;
FormState.ResetValidation();
AppliedFormVersion = FormVersion;
}
private async Task SubmitAsync()
{
FormState.ResetValidation();
if (string.IsNullOrWhiteSpace(FormState.Model.Name))
{
FormState.Errors["name"] = "Character name is required.";
}
if (!Guid.TryParse(FormState.Model.CampaignId, out var campaignId))
{
FormState.Errors["campaignId"] = "Campaign is required.";
}
if (FormState.Errors.Count > 0)
{
FormState.ErrorMessage = "Resolve validation issues before submitting.";
return;
}
IsSubmitting = true;
try
{
CharacterSummary character;
if (EditingCharacterId.HasValue)
{
character = await ApiClient.RequestAsync<CharacterSummary>(
"PUT",
$"/api/characters/{EditingCharacterId.Value}",
new UpdateCharacterRequest(FormState.Model.Name.Trim(), campaignId));
character = await ApiClient.RequestAsync<CharacterSummary>("PUT", $"/api/characters/{EditingCharacterId.Value}", new UpdateCharacterRequest(FormState.Model.Name.Trim(), campaignId));
}
else
{
character = await ApiClient.RequestAsync<CharacterSummary>(
"POST",
"/api/characters",
new CreateCharacterRequest(FormState.Model.Name.Trim(), campaignId));
character = await ApiClient.RequestAsync<CharacterSummary>("POST", "/api/characters", new CreateCharacterRequest(FormState.Model.Name.Trim(), campaignId));
}
await CharacterSaved.InvokeAsync(character.CampaignId);
}
catch (ApiRequestException ex)
@@ -115,4 +58,47 @@ public partial class CharacterFormModal
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">
<div class="section-head"><h2>Character Context</h2></div>
@if (IsCampaignDataLoading)
{
<div class="skeleton-stack"><div class="skeleton-line"></div><div class="skeleton-line short"></div><div class="skeleton-line"></div></div>
<div class="skeleton-stack">
<div class="skeleton-line"></div>
<div class="skeleton-line short"></div>
<div class="skeleton-line"></div>
</div>
}
else if (SelectedCampaign is null)
{
@@ -22,7 +22,8 @@
@foreach (var character in SelectedCampaign.Characters)
{
var isSelectedCharacter = SelectedCharacterId == character.Id;
<button type="button" class="icon-tab @(isSelectedCharacter ? "active" : string.Empty)" aria-label="@character.Name" @onclick="() => CharacterSelected.InvokeAsync(character.Id)">
<button type="button" class="icon-tab @(isSelectedCharacter ? "active" : string.Empty)"
aria-label="@character.Name" @onclick="() => CharacterSelected.InvokeAsync(character.Id)">
<span class="icon-tab-glyph">@InitialsFor(character.Name)</span>
<span class="icon-tab-text">@character.Name</span>
</button>
@@ -36,15 +37,22 @@
<p>Campaign: @SelectedCampaign.Name</p>
<span class="badge active">Active</span>
<div class="inline-actions">
<button type="button" disabled="@(IsMutating || !CanEditCharacter(SelectedCharacter))" @onclick="() => EditCharacterRequested.InvokeAsync(SelectedCharacter)">Edit Character</button>
<button type="button" disabled="@(IsMutating || !CanEditCharacter(SelectedCharacter))"
@onclick="() => EditCharacterRequested.InvokeAsync(SelectedCharacter)">Edit Character
</button>
</div>
</article>
<article class="skills-section">
<div class="section-head">
<h3>Skills</h3>
<div class="inline-actions">
<button type="button" disabled="@(IsMutating || !CanEditCharacter(SelectedCharacter))" @onclick="OpenCreateSkillModal">Create Skill</button>
<button type="button" disabled="@(IsMutating || SelectedSkill is null || !CanEditSkill(SelectedSkill))" @onclick="OpenEditSkillModal">Edit Skill</button>
<button type="button" disabled="@(IsMutating || !CanEditCharacter(SelectedCharacter))"
@onclick="OpenCreateSkillModal">Create Skill
</button>
<button type="button"
disabled="@(IsMutating || SelectedSkill is null || !CanEditSkill(SelectedSkill))"
@onclick="OpenEditSkillModal">Edit Skill
</button>
</div>
</div>
@if (SelectedCharacterSkills.Count == 0)
@@ -57,7 +65,8 @@
@foreach (var skill in SelectedCharacterSkills)
{
var isSelectedSkill = SelectedSkillId == skill.Id;
<button type="button" class="skill-item @(isSelectedSkill ? "active" : string.Empty)" @onclick="() => SkillSelected.InvokeAsync(skill.Id)">
<button type="button" class="skill-item @(isSelectedSkill ? "active" : string.Empty)"
@onclick="() => SkillSelected.InvokeAsync(skill.Id)">
<strong>@skill.Name</strong><span>@SkillDefinitionLabel(skill)</span>
</button>
}
@@ -69,7 +78,9 @@
<option value="public">Public</option>
<option value="private">Private</option>
</select>
<button type="submit" disabled="@(IsMutating || SelectedSkill is null || !CanRollSkill(SelectedSkill))">Roll Skill</button>
<button type="submit"
disabled="@(IsMutating || SelectedSkill is null || !CanRollSkill(SelectedSkill))">Roll Skill
</button>
</form>
</article>
}
@@ -83,9 +94,13 @@
else
{
<p class="roll-total">@LastRoll.Result</p>
<RollDiceStrip Dice="LastRoll.Dice" AriaLabel="Last roll dice" />
<RollDiceStrip Dice="LastRoll.Dice" AriaLabel="Last roll dice"/>
<p>@LastRoll.Breakdown</p>
<p><span class="badge @(LastRoll.Visibility == "private" ? "private-self" : "public")">@LastRoll.Visibility</span> <time title="@LastRoll.TimestampUtc.ToString("O")">@LastRoll.TimestampUtc.ToLocalTime().ToString("g")</time></p>
<p><span
class="badge @(LastRoll.Visibility == "private" ? "private-self" : "public")">@LastRoll.Visibility</span>
<time
title="@LastRoll.TimestampUtc.ToString("O")">@LastRoll.TimestampUtc.ToLocalTime().ToString("g")</time>
</p>
}
</article>
</section>
@@ -105,7 +120,7 @@
EditingSkillId="null"
IsMutating="IsMutating"
SkillSaved="OnSkillCreatedAsync"
CancelRequested="CloseSkillModals" />
CancelRequested="CloseSkillModals"/>
<SkillFormModal
Visible="ShowEditSkillModal"
@@ -122,4 +137,4 @@
EditingSkillId="EditingSkillId"
IsMutating="IsMutating"
SkillSaved="OnSkillUpdatedAsync"
CancelRequested="CloseSkillModals" />
CancelRequested="CloseSkillModals"/>

View File

@@ -1,13 +1,86 @@
using Microsoft.AspNetCore.Components;
using RpgRoller.Components.Pages;
using RpgRoller.Contracts;
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Components;
using RpgRoller.Contracts;
namespace RpgRoller.Components.Pages.HomeControls;
[ExcludeFromCodeCoverage]
public partial class CharacterPanel
{
private void OpenCreateSkillModal()
{
CreateSkillInitialModel = new()
{
Name = string.Empty,
DiceRollDefinition = string.Empty,
WildDice = IsD6 ? 1 : 0,
AllowFumble = IsD6
};
CreateSkillFormVersion++;
ShowCreateSkillModal = true;
}
private void OpenEditSkillModal()
{
if (SelectedSkill is null)
return;
EditingSkillId = SelectedSkill.Id;
EditSkillInitialModel = new()
{
Name = SelectedSkill.Name,
DiceRollDefinition = SelectedSkill.DiceRollDefinition,
WildDice = SelectedSkill.WildDice,
AllowFumble = SelectedSkill.AllowFumble
};
EditSkillFormVersion++;
ShowEditSkillModal = true;
}
private void CloseSkillModals()
{
ShowCreateSkillModal = false;
ShowEditSkillModal = false;
EditingSkillId = null;
}
private async Task OnSkillCreatedAsync(Guid skillId)
{
CloseSkillModals();
await SkillCreated.InvokeAsync(skillId);
}
private async Task OnSkillUpdatedAsync(Guid skillId)
{
CloseSkillModals();
await SkillUpdated.InvokeAsync(skillId);
}
private async Task OnRollVisibilityChangedAsync(ChangeEventArgs args)
{
var selectedVisibility = args.Value?.ToString() ?? "public";
await RollVisibilityChanged.InvokeAsync(selectedVisibility);
}
private async Task OnRollSubmitAsync()
{
await RollRequested.InvokeAsync();
}
private static string InitialsFor(string value)
{
var words = value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (words.Length == 0)
return "?";
if (words.Length == 1)
return words[0].Length >= 2 ? words[0][..2].ToUpperInvariant() : words[0].ToUpperInvariant();
return string.Concat(words[0][0], words[1][0]).ToUpperInvariant();
}
private bool ShowCreateSkillModal { get; set; }
private bool ShowEditSkillModal { get; set; }
private Guid? EditingSkillId { get; set; }
@@ -15,153 +88,73 @@ public partial class CharacterPanel
private SkillFormModel EditSkillInitialModel { get; set; } = new();
private int CreateSkillFormVersion { get; set; }
private int EditSkillFormVersion { get; set; }
[Parameter]
public bool IsCampaignDataLoading { get; set; }
[Parameter]
public CampaignDetails? SelectedCampaign { get; set; }
[Parameter]
public Guid? SelectedCharacterId { get; set; }
[Parameter]
public CharacterSummary? SelectedCharacter { get; set; }
[Parameter]
public bool IsMutating { get; set; }
[Parameter]
public IReadOnlyList<SkillSummary> SelectedCharacterSkills { get; set; } = [];
[Parameter]
public Guid? SelectedSkillId { get; set; }
[Parameter]
public SkillSummary? SelectedSkill { get; set; }
[Parameter]
public bool IsD6 { get; set; }
[Parameter]
public string RollVisibility { get; set; } = "public";
[Parameter]
public EventCallback<string> RollVisibilityChanged { get; set; }
[Parameter]
public RollResult? LastRoll { get; set; }
[Parameter]
public Func<Guid, string> OwnerLabel { get; set; } = _ => string.Empty;
[Parameter]
public Func<SkillSummary, string> SkillDefinitionLabel { get; set; } = _ => string.Empty;
[Parameter]
public Func<CharacterSummary, bool> CanEditCharacter { get; set; } = _ => false;
[Parameter]
public Func<SkillSummary, bool> CanEditSkill { get; set; } = _ => false;
[Parameter]
public Func<SkillSummary, bool> CanRollSkill { get; set; } = _ => false;
[Parameter]
public EventCallback<Guid> CharacterSelected { get; set; }
[Parameter]
public EventCallback<Guid> SkillSelected { get; set; }
[Parameter]
public EventCallback<CharacterSummary> EditCharacterRequested { get; set; }
[Parameter]
public EventCallback<Guid> SkillCreated { get; set; }
[Parameter]
public EventCallback<Guid> SkillUpdated { get; set; }
[Parameter]
public EventCallback RollRequested { get; set; }
private void OpenCreateSkillModal()
{
CreateSkillInitialModel = new SkillFormModel
{
Name = string.Empty,
DiceRollDefinition = string.Empty,
WildDice = IsD6 ? 1 : 0,
AllowFumble = IsD6
};
CreateSkillFormVersion++;
ShowCreateSkillModal = true;
}
private void OpenEditSkillModal()
{
if (SelectedSkill is null)
{
return;
}
EditingSkillId = SelectedSkill.Id;
EditSkillInitialModel = new SkillFormModel
{
Name = SelectedSkill.Name,
DiceRollDefinition = SelectedSkill.DiceRollDefinition,
WildDice = SelectedSkill.WildDice,
AllowFumble = SelectedSkill.AllowFumble
};
EditSkillFormVersion++;
ShowEditSkillModal = true;
}
private void CloseSkillModals()
{
ShowCreateSkillModal = false;
ShowEditSkillModal = false;
EditingSkillId = null;
}
private async Task OnSkillCreatedAsync(Guid skillId)
{
CloseSkillModals();
await SkillCreated.InvokeAsync(skillId);
}
private async Task OnSkillUpdatedAsync(Guid skillId)
{
CloseSkillModals();
await SkillUpdated.InvokeAsync(skillId);
}
private async Task OnRollVisibilityChangedAsync(ChangeEventArgs args)
{
var selectedVisibility = args.Value?.ToString() ?? "public";
await RollVisibilityChanged.InvokeAsync(selectedVisibility);
}
private async Task OnRollSubmitAsync()
{
await RollRequested.InvokeAsync();
}
private static string InitialsFor(string value)
{
var words = value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (words.Length == 0)
{
return "?";
}
if (words.Length == 1)
{
return words[0].Length >= 2 ? words[0][..2].ToUpperInvariant() : words[0].ToUpperInvariant();
}
return string.Concat(words[0][0], words[1][0]).ToUpperInvariant();
}
}
}

View File

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

View File

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

View File

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

View File

@@ -1,73 +1,17 @@
using Microsoft.AspNetCore.Components;
using RpgRoller.Components.Pages;
using RpgRoller.Components;
using RpgRoller.Contracts;
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Components;
using RpgRoller.Contracts;
namespace RpgRoller.Components.Pages.HomeControls;
[ExcludeFromCodeCoverage]
public partial class SkillFormModal
{
[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()
{
if (!Visible || FormVersion == AppliedFormVersion)
{
return;
}
FormState.Model.Name = InitialModel.Name;
FormState.Model.DiceRollDefinition = InitialModel.DiceRollDefinition;
FormState.Model.WildDice = InitialModel.WildDice;
@@ -75,46 +19,33 @@ public partial class SkillFormModal
FormState.ResetValidation();
AppliedFormVersion = FormVersion;
}
private async Task SubmitAsync()
{
FormState.ResetValidation();
if (string.IsNullOrWhiteSpace(FormState.Model.Name))
{
FormState.Errors["name"] = "Skill name is required.";
}
if (string.IsNullOrWhiteSpace(FormState.Model.DiceRollDefinition))
{
FormState.Errors["diceRollDefinition"] = "Expression is required.";
}
if (IsD6 && FormState.Model.WildDice < 1)
{
FormState.Errors["wildDice"] = "D6 skills require at least one wild die.";
}
if (FormState.Errors.Count > 0)
{
FormState.ErrorMessage = "Resolve validation issues before submitting.";
return;
}
IsSubmitting = true;
try
{
SkillSummary skill;
if (EditingSkillId.HasValue)
{
skill = await ApiClient.RequestAsync<SkillSummary>(
"PUT",
$"/api/skills/{EditingSkillId.Value}",
new UpdateSkillRequest(
FormState.Model.Name.Trim(),
FormState.Model.DiceRollDefinition.Trim(),
FormState.Model.WildDice,
FormState.Model.AllowFumble));
skill = await ApiClient.RequestAsync<SkillSummary>("PUT", $"/api/skills/{EditingSkillId.Value}", new UpdateSkillRequest(FormState.Model.Name.Trim(), FormState.Model.DiceRollDefinition.Trim(), FormState.Model.WildDice, FormState.Model.AllowFumble));
}
else
{
@@ -123,17 +54,10 @@ public partial class SkillFormModal
FormState.ErrorMessage = "Select a character first.";
return;
}
skill = await ApiClient.RequestAsync<SkillSummary>(
"POST",
$"/api/characters/{SelectedCharacterId.Value}/skills",
new CreateSkillRequest(
FormState.Model.Name.Trim(),
FormState.Model.DiceRollDefinition.Trim(),
FormState.Model.WildDice,
FormState.Model.AllowFumble));
skill = await ApiClient.RequestAsync<SkillSummary>("POST", $"/api/characters/{SelectedCharacterId.Value}/skills", new CreateSkillRequest(FormState.Model.Name.Trim(), FormState.Model.DiceRollDefinition.Trim(), FormState.Model.WildDice, FormState.Model.AllowFumble));
}
await SkillSaved.InvokeAsync(skill.Id);
}
catch (ApiRequestException ex)
@@ -145,4 +69,56 @@ public partial class SkillFormModal
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.Contracts
<div class="@AppCssClass">
<p class="sr-only" aria-live="polite">@LiveAnnouncement</p>
@@ -39,8 +34,12 @@
<div class="header-group controls">
<p class="connection @ConnectionStateCssClass">@ConnectionStateLabel</p>
<div class="switch-group" role="tablist" aria-label="Screen selector">
<button type="button" class="switch @(CurrentScreen == "play" ? "active" : string.Empty)" @onclick="SwitchToPlayAsync">Play</button>
<button type="button" class="switch @(CurrentScreen == "management" ? "active" : string.Empty)" @onclick="SwitchToManagementAsync">Campaign Management</button>
<button type="button" class="switch @(CurrentScreen == "play" ? "active" : string.Empty)"
@onclick="SwitchToPlayAsync">Play
</button>
<button type="button" class="switch @(CurrentScreen == "management" ? "active" : string.Empty)"
@onclick="SwitchToManagementAsync">Campaign Management
</button>
</div>
<div class="header-actions">
<button type="button" @onclick="ManualRefreshAsync" disabled="@IsMutating">Refresh</button>
@@ -80,7 +79,7 @@
EditCharacterRequested="OpenEditCharacterModal"
SkillCreated="OnSkillCreatedAsync"
SkillUpdated="OnSkillUpdatedAsync"
RollRequested="RollSelectedSkillAsync" />
RollRequested="RollSelectedSkillAsync"/>
<CampaignLogPanel
IsCampaignDataLoading="IsCampaignDataLoading"
@@ -90,11 +89,15 @@
CharacterLabel="CharacterLabel"
LogEntryCssClass="LogEntryCssClass"
VisibilityLabel="VisibilityLabel"
VisibilityBadgeCssClass="VisibilityBadgeCssClass" />
VisibilityBadgeCssClass="VisibilityBadgeCssClass"/>
</main>
<nav class="mobile-bottom-nav" aria-label="Play panel selector">
<button type="button" class="switch @(MobilePanel == "character" ? "active" : string.Empty)" @onclick="SetMobilePanelCharacterAsync">Character</button>
<button type="button" class="switch @(MobilePanel == "log" ? "active" : string.Empty)" @onclick="SetMobilePanelLogAsync">Log</button>
<button type="button" class="switch @(MobilePanel == "character" ? "active" : string.Empty)"
@onclick="SetMobilePanelCharacterAsync">Character
</button>
<button type="button" class="switch @(MobilePanel == "log" ? "active" : string.Empty)"
@onclick="SetMobilePanelLogAsync">Log
</button>
</nav>
}
@@ -112,7 +115,7 @@
CampaignSelectionChanged="OnCampaignSelectionChangedAsync"
CampaignCreated="OnCampaignCreatedAsync"
CreateCharacterRequested="OpenCreateCharacterModal"
EditCharacterRequested="OpenEditCharacterModal" />
EditCharacterRequested="OpenEditCharacterModal"/>
}
</div>
</div>
@@ -129,7 +132,7 @@
Campaigns="Campaigns"
IsMutating="IsMutating"
CharacterSaved="OnCharacterCreatedAsync"
CancelRequested="CloseCharacterModals" />
CancelRequested="CloseCharacterModals"/>
<CharacterFormModal
Visible="ShowEditCharacterModal"
@@ -143,4 +146,4 @@
Campaigns="Campaigns"
IsMutating="IsMutating"
CharacterSaved="OnCharacterUpdatedAsync"
CancelRequested="CloseCharacterModals" />
CancelRequested="CloseCharacterModals"/>

View File

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

View File

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

View File

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

View File

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

View File

@@ -70,4 +70,4 @@ public sealed class RollLogEntry
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);
_ = scope.ServiceProvider.GetRequiredService<IGameService>();
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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