Refactor API/service boundaries and modularize frontend

This commit is contained in:
2026-02-24 23:33:12 +01:00
parent 1d512d321b
commit c6e95f16e1
39 changed files with 1628 additions and 1315 deletions

4
FAQ.md
View File

@@ -27,3 +27,7 @@ To start with a clean backend state, stop the app and remove the corresponding S
## Does the backend read SQLite on every API call?
No. The backend loads state from SQLite once during startup into in-memory state and serves requests from memory. Successful state mutations are then written back to SQLite.
## Why do backend services use `*Command` types instead of API request DTOs?
Service workflows now consume service-layer command models (for example, `CreateCampaignCommand`) so endpoint transport contracts stay isolated in the API layer. This reduces coupling and keeps service code reusable when input shapes evolve at the HTTP boundary.

View File

@@ -7,6 +7,22 @@ Fresh full-stack starter scaffold:
- `RpgRoller.Tests/`: xUnit integration-heavy test project
- `RpgRoller.sln`: solution used by local CI script
## Code Organization
Backend:
- `RpgRoller/Program.cs`: thin app bootstrap only
- `RpgRoller/Hosting/`: service registration + startup initialization
- `RpgRoller/Api/`: endpoint mapping modules, API-to-service request mapping, auth/session filter helpers
- `RpgRoller/Services/`: game workflows and service-layer command models (`*Command` records)
Frontend:
- `RpgRoller/frontend/app.ts`: orchestration entrypoint
- `RpgRoller/frontend/app/`: split modules (`dom`, `state`, `loaders`, `render`, `events`, `actions`)
- `RpgRoller/frontend/generated/`: generated API client source
- `RpgRoller/wwwroot/`: compiled browser assets
Backend state persistence:
- EF Core with SQLite (`Microsoft.EntityFrameworkCore.Sqlite`)

View File

@@ -1,6 +1,5 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using RpgRoller.Contracts;
using RpgRoller.Data;
using RpgRoller.Domain;
using RpgRoller.Services;
@@ -15,11 +14,11 @@ public sealed class GameServiceTests
using var harness = CreateHarness();
var service = harness.Service;
var invalidUsername = service.Register(new RegisterRequest("", "Password123", "Display"));
var invalidDisplay = service.Register(new RegisterRequest("user", "Password123", ""));
var invalidPassword = service.Register(new RegisterRequest("user", "short", "Display"));
var valid = service.Register(new RegisterRequest("user", "Password123", "Display"));
var duplicate = service.Register(new RegisterRequest("user", "Password123", "Display 2"));
var invalidUsername = service.Register(new RegisterCommand("", "Password123", "Display"));
var invalidDisplay = service.Register(new RegisterCommand("user", "Password123", ""));
var invalidPassword = service.Register(new RegisterCommand("user", "short", "Display"));
var valid = service.Register(new RegisterCommand("user", "Password123", "Display"));
var duplicate = service.Register(new RegisterCommand("user", "Password123", "Display 2"));
Assert.False(invalidUsername.Succeeded);
Assert.False(invalidDisplay.Succeeded);
@@ -33,11 +32,11 @@ public sealed class GameServiceTests
{
using var harness = CreateHarness();
var service = harness.Service;
service.Register(new RegisterRequest("user", "Password123", "Display"));
service.Register(new RegisterCommand("user", "Password123", "Display"));
var invalidUser = service.Login(new LoginRequest("missing", "Password123"));
var invalidPassword = service.Login(new LoginRequest("user", "bad-password"));
var valid = service.Login(new LoginRequest("user", "Password123"));
var invalidUser = service.Login(new LoginCommand("missing", "Password123"));
var invalidPassword = service.Login(new LoginCommand("user", "bad-password"));
var valid = service.Login(new LoginCommand("user", "Password123"));
Assert.False(invalidUser.Succeeded);
Assert.False(invalidPassword.Succeeded);
@@ -57,8 +56,8 @@ public sealed class GameServiceTests
using var harness = CreateHarness(hasher);
var service = harness.Service;
service.Register(new RegisterRequest("user", "Password123", "Display"));
var login = service.Login(new LoginRequest("user", "Password123"));
service.Register(new RegisterCommand("user", "Password123", "Display"));
var login = service.Login(new LoginCommand("user", "Password123"));
Assert.True(login.Succeeded);
Assert.Equal(2, hasher.HashCalls);
@@ -70,20 +69,20 @@ public sealed class GameServiceTests
using var harness = CreateHarness();
var service = harness.Service;
var unauthorizedCampaign = service.CreateCampaign("missing", new CreateCampaignRequest("Name", "d6"));
var unauthorizedCampaign = service.CreateCampaign("missing", new CreateCampaignCommand("Name", "d6"));
Assert.False(unauthorizedCampaign.Succeeded);
service.Register(new RegisterRequest("gm", "Password123", "GM"));
var gmSession = GetValue(service.Login(new LoginRequest("gm", "Password123"))).SessionToken;
var campaign = GetValue(service.CreateCampaign(gmSession, new CreateCampaignRequest("Name", "d6")));
service.Register(new RegisterCommand("gm", "Password123", "GM"));
var gmSession = GetValue(service.Login(new LoginCommand("gm", "Password123"))).SessionToken;
var campaign = GetValue(service.CreateCampaign(gmSession, new CreateCampaignCommand("Name", "d6")));
var invalidRuleset = service.CreateCampaign(gmSession, new CreateCampaignRequest("Name 2", "unknown"));
var invalidRuleset = service.CreateCampaign(gmSession, new CreateCampaignCommand("Name 2", "unknown"));
Assert.False(invalidRuleset.Succeeded);
var noCampaignCharacter = service.CreateCharacter(gmSession, new CreateCharacterRequest("Hero", Guid.NewGuid()));
var noCampaignCharacter = service.CreateCharacter(gmSession, new CreateCharacterCommand("Hero", Guid.NewGuid()));
Assert.False(noCampaignCharacter.Succeeded);
var character = GetValue(service.CreateCharacter(gmSession, new CreateCharacterRequest("Hero", campaign.Id)));
var character = GetValue(service.CreateCharacter(gmSession, new CreateCharacterCommand("Hero", campaign.Id)));
var missingCharacterActivate = service.ActivateCharacter(gmSession, Guid.NewGuid());
Assert.False(missingCharacterActivate.Succeeded);
@@ -104,54 +103,54 @@ public sealed class GameServiceTests
{
using var harness = CreateHarness(3, 4, 5, 6);
var service = harness.Service;
service.Register(new RegisterRequest("gm", "Password123", "GM"));
service.Register(new RegisterRequest("owner", "Password123", "Owner"));
service.Register(new RegisterRequest("other", "Password123", "Other"));
service.Register(new RegisterCommand("gm", "Password123", "GM"));
service.Register(new RegisterCommand("owner", "Password123", "Owner"));
service.Register(new RegisterCommand("other", "Password123", "Other"));
var gmSession = GetValue(service.Login(new LoginRequest("gm", "Password123"))).SessionToken;
var ownerSession = GetValue(service.Login(new LoginRequest("owner", "Password123"))).SessionToken;
var otherSession = GetValue(service.Login(new LoginRequest("other", "Password123"))).SessionToken;
var gmSession = GetValue(service.Login(new LoginCommand("gm", "Password123"))).SessionToken;
var ownerSession = GetValue(service.Login(new LoginCommand("owner", "Password123"))).SessionToken;
var otherSession = GetValue(service.Login(new LoginCommand("other", "Password123"))).SessionToken;
var campaign = GetValue(service.CreateCampaign(gmSession, new CreateCampaignRequest("Main", "dnd5e")));
var character = GetValue(service.CreateCharacter(ownerSession, new CreateCharacterRequest("Owner Char", campaign.Id)));
var campaign = GetValue(service.CreateCampaign(gmSession, new CreateCampaignCommand("Main", "dnd5e")));
var character = GetValue(service.CreateCharacter(ownerSession, new CreateCharacterCommand("Owner Char", campaign.Id)));
var noPermissionUpdate = service.UpdateCharacter(otherSession, character.Id, new UpdateCharacterRequest("Renamed", campaign.Id));
var noPermissionUpdate = service.UpdateCharacter(otherSession, character.Id, new UpdateCharacterCommand("Renamed", campaign.Id));
Assert.False(noPermissionUpdate.Succeeded);
var invalidCharacterName = service.UpdateCharacter(ownerSession, character.Id, new UpdateCharacterRequest("", campaign.Id));
var invalidCharacterName = service.UpdateCharacter(ownerSession, character.Id, new UpdateCharacterCommand("", campaign.Id));
Assert.False(invalidCharacterName.Succeeded);
var missingTargetCampaign = service.UpdateCharacter(ownerSession, character.Id, new UpdateCharacterRequest("Renamed", Guid.NewGuid()));
var missingTargetCampaign = service.UpdateCharacter(ownerSession, character.Id, new UpdateCharacterCommand("Renamed", Guid.NewGuid()));
Assert.False(missingTargetCampaign.Succeeded);
var noSkillName = service.CreateSkill(ownerSession, character.Id, new CreateSkillRequest("", "1d20"));
var noSkillName = service.CreateSkill(ownerSession, character.Id, new CreateSkillCommand("", "1d20"));
Assert.False(noSkillName.Succeeded);
var invalidExpression = service.CreateSkill(ownerSession, character.Id, new CreateSkillRequest("Skill", "5D+4"));
var invalidExpression = service.CreateSkill(ownerSession, character.Id, new CreateSkillCommand("Skill", "5D+4"));
Assert.False(invalidExpression.Succeeded);
var skill = GetValue(service.CreateSkill(ownerSession, character.Id, new CreateSkillRequest("Skill", "1d20+2")));
var skill = GetValue(service.CreateSkill(ownerSession, character.Id, new CreateSkillCommand("Skill", "1d20+2")));
var missingSkillUpdate = service.UpdateSkill(ownerSession, Guid.NewGuid(), new UpdateSkillRequest("X", "1d20"));
var missingSkillUpdate = service.UpdateSkill(ownerSession, Guid.NewGuid(), new UpdateSkillCommand("X", "1d20"));
Assert.False(missingSkillUpdate.Succeeded);
var forbiddenSkillUpdate = service.UpdateSkill(otherSession, skill.Id, new UpdateSkillRequest("X", "1d20"));
var forbiddenSkillUpdate = service.UpdateSkill(otherSession, skill.Id, new UpdateSkillCommand("X", "1d20"));
Assert.False(forbiddenSkillUpdate.Succeeded);
var gmSkillUpdate = service.UpdateSkill(gmSession, skill.Id, new UpdateSkillRequest("GM Edit", "2d6+1"));
var gmSkillUpdate = service.UpdateSkill(gmSession, skill.Id, new UpdateSkillCommand("GM Edit", "2d6+1"));
Assert.True(gmSkillUpdate.Succeeded);
var missingRoll = service.RollSkill(ownerSession, Guid.NewGuid(), new RollSkillRequest("public"));
var missingRoll = service.RollSkill(ownerSession, Guid.NewGuid(), new RollSkillCommand("public"));
Assert.False(missingRoll.Succeeded);
var invalidVisibility = service.RollSkill(ownerSession, skill.Id, new RollSkillRequest("hidden"));
var invalidVisibility = service.RollSkill(ownerSession, skill.Id, new RollSkillCommand("hidden"));
Assert.False(invalidVisibility.Succeeded);
var forbiddenRoll = service.RollSkill(otherSession, skill.Id, new RollSkillRequest("public"));
var forbiddenRoll = service.RollSkill(otherSession, skill.Id, new RollSkillCommand("public"));
Assert.False(forbiddenRoll.Succeeded);
var privateRoll = service.RollSkill(ownerSession, skill.Id, new RollSkillRequest("private"));
var publicRoll = service.RollSkill(ownerSession, skill.Id, new RollSkillRequest("public"));
var privateRoll = service.RollSkill(ownerSession, skill.Id, new RollSkillCommand("private"));
var publicRoll = service.RollSkill(ownerSession, skill.Id, new RollSkillCommand("public"));
Assert.True(privateRoll.Succeeded);
Assert.True(publicRoll.Succeeded);
@@ -174,8 +173,8 @@ public sealed class GameServiceTests
{
using var harness = CreateHarness();
var service = harness.Service;
service.Register(new RegisterRequest("user", "Password123", "User"));
var sessionToken = GetValue(service.Login(new LoginRequest("user", "Password123"))).SessionToken;
service.Register(new RegisterCommand("user", "Password123", "User"));
var sessionToken = GetValue(service.Login(new LoginCommand("user", "Password123"))).SessionToken;
var result = service.GetCurrentCampaignCharacters(sessionToken);
Assert.False(result.Succeeded);
@@ -186,14 +185,14 @@ public sealed class GameServiceTests
{
using var harness = CreateHarness();
var service = harness.Service;
service.Register(new RegisterRequest("gm", "Password123", "GM"));
service.Register(new RegisterRequest("player", "Password123", "Player"));
var gmSession = GetValue(service.Login(new LoginRequest("gm", "Password123"))).SessionToken;
var playerSession = GetValue(service.Login(new LoginRequest("player", "Password123"))).SessionToken;
service.Register(new RegisterCommand("gm", "Password123", "GM"));
service.Register(new RegisterCommand("player", "Password123", "Player"));
var gmSession = GetValue(service.Login(new LoginCommand("gm", "Password123"))).SessionToken;
var playerSession = GetValue(service.Login(new LoginCommand("player", "Password123"))).SessionToken;
var gmCampaign = GetValue(service.CreateCampaign(gmSession, new CreateCampaignRequest("Owned", "d6")));
_ = GetValue(service.CreateCampaign(gmSession, new CreateCampaignRequest("Owned 2", "d6")));
_ = service.CreateCharacter(playerSession, new CreateCharacterRequest("Joiner", gmCampaign.Id));
var gmCampaign = GetValue(service.CreateCampaign(gmSession, new CreateCampaignCommand("Owned", "d6")));
_ = GetValue(service.CreateCampaign(gmSession, new CreateCampaignCommand("Owned 2", "d6")));
_ = service.CreateCharacter(playerSession, new CreateCharacterCommand("Joiner", gmCampaign.Id));
var playerCampaigns = service.GetCampaigns(playerSession);
Assert.True(playerCampaigns.Succeeded);
@@ -208,35 +207,35 @@ public sealed class GameServiceTests
using var harness = CreateHarness(2, 3, 4);
var service = harness.Service;
var invalidCredentials = service.Login(new LoginRequest("", ""));
var invalidCredentials = service.Login(new LoginCommand("", ""));
Assert.False(invalidCredentials.Succeeded);
service.Logout("missing-session");
service.Register(new RegisterRequest("gm", "Password123", "GM"));
service.Register(new RegisterRequest("owner", "Password123", "Owner"));
service.Register(new RegisterRequest("other", "Password123", "Other"));
service.Register(new RegisterCommand("gm", "Password123", "GM"));
service.Register(new RegisterCommand("owner", "Password123", "Owner"));
service.Register(new RegisterCommand("other", "Password123", "Other"));
var gmSession = GetValue(service.Login(new LoginRequest("gm", "Password123"))).SessionToken;
var ownerSession = GetValue(service.Login(new LoginRequest("owner", "Password123"))).SessionToken;
var otherSession = GetValue(service.Login(new LoginRequest("other", "Password123"))).SessionToken;
var gmSession = GetValue(service.Login(new LoginCommand("gm", "Password123"))).SessionToken;
var ownerSession = GetValue(service.Login(new LoginCommand("owner", "Password123"))).SessionToken;
var otherSession = GetValue(service.Login(new LoginCommand("other", "Password123"))).SessionToken;
var campaign = GetValue(service.CreateCampaign(gmSession, new CreateCampaignRequest("Main", "d6")));
var ownerCharacter = GetValue(service.CreateCharacter(ownerSession, new CreateCharacterRequest("Owner Character", campaign.Id)));
var campaign = GetValue(service.CreateCampaign(gmSession, new CreateCampaignCommand("Main", "d6")));
var ownerCharacter = GetValue(service.CreateCharacter(ownerSession, new CreateCharacterCommand("Owner Character", campaign.Id)));
Assert.False(service.GetMe(string.Empty).Succeeded);
Assert.False(service.CreateCampaign(gmSession, new CreateCampaignRequest("", "d6")).Succeeded);
Assert.False(service.CreateCampaign(gmSession, new CreateCampaignCommand("", "d6")).Succeeded);
Assert.False(service.GetCampaigns(string.Empty).Succeeded);
Assert.False(service.CreateCharacter(ownerSession, new CreateCharacterRequest("", campaign.Id)).Succeeded);
Assert.False(service.CreateCharacter(string.Empty, new CreateCharacterRequest("Name", campaign.Id)).Succeeded);
Assert.False(service.UpdateCharacter(string.Empty, ownerCharacter.Id, new UpdateCharacterRequest("Renamed", campaign.Id)).Succeeded);
Assert.False(service.UpdateCharacter(ownerSession, Guid.NewGuid(), new UpdateCharacterRequest("Renamed", campaign.Id)).Succeeded);
Assert.False(service.CreateCharacter(ownerSession, new CreateCharacterCommand("", campaign.Id)).Succeeded);
Assert.False(service.CreateCharacter(string.Empty, new CreateCharacterCommand("Name", campaign.Id)).Succeeded);
Assert.False(service.UpdateCharacter(string.Empty, ownerCharacter.Id, new UpdateCharacterCommand("Renamed", campaign.Id)).Succeeded);
Assert.False(service.UpdateCharacter(ownerSession, Guid.NewGuid(), new UpdateCharacterCommand("Renamed", campaign.Id)).Succeeded);
Assert.False(service.ActivateCharacter(string.Empty, ownerCharacter.Id).Succeeded);
Assert.False(service.ActivateCharacter(gmSession, ownerCharacter.Id).Succeeded);
Assert.False(service.GetCurrentCampaignCharacters(string.Empty).Succeeded);
Assert.False(service.CreateSkill(string.Empty, ownerCharacter.Id, new CreateSkillRequest("Stealth", "2D+1")).Succeeded);
Assert.False(service.CreateSkill(ownerSession, Guid.NewGuid(), new CreateSkillRequest("Stealth", "2D+1")).Succeeded);
Assert.False(service.CreateSkill(otherSession, ownerCharacter.Id, new CreateSkillRequest("Stealth", "2D+1")).Succeeded);
Assert.False(service.CreateSkill(string.Empty, ownerCharacter.Id, new CreateSkillCommand("Stealth", "2D+1")).Succeeded);
Assert.False(service.CreateSkill(ownerSession, Guid.NewGuid(), new CreateSkillCommand("Stealth", "2D+1")).Succeeded);
Assert.False(service.CreateSkill(otherSession, ownerCharacter.Id, new CreateSkillCommand("Stealth", "2D+1")).Succeeded);
using (var db = harness.CreateDbContext())
{
@@ -274,11 +273,11 @@ public sealed class GameServiceTests
Assert.Null(db.Users.Single(u => u.UsernameNormalized == "OWNER").ActiveCharacterId);
}
var skill = GetValue(service.CreateSkill(ownerSession, ownerCharacter.Id, new CreateSkillRequest("Stealth", "2D+1")));
Assert.False(service.UpdateSkill(ownerSession, skill.Id, new UpdateSkillRequest("", "2D+1")).Succeeded);
Assert.False(service.UpdateSkill(string.Empty, skill.Id, new UpdateSkillRequest("Stealth", "2D+1")).Succeeded);
Assert.False(service.UpdateSkill(ownerSession, skill.Id, new UpdateSkillRequest("Stealth", "bad")).Succeeded);
Assert.False(service.RollSkill(string.Empty, skill.Id, new RollSkillRequest("public")).Succeeded);
var skill = GetValue(service.CreateSkill(ownerSession, ownerCharacter.Id, new CreateSkillCommand("Stealth", "2D+1")));
Assert.False(service.UpdateSkill(ownerSession, skill.Id, new UpdateSkillCommand("", "2D+1")).Succeeded);
Assert.False(service.UpdateSkill(string.Empty, skill.Id, new UpdateSkillCommand("Stealth", "2D+1")).Succeeded);
Assert.False(service.UpdateSkill(ownerSession, skill.Id, new UpdateSkillCommand("Stealth", "bad")).Succeeded);
Assert.False(service.RollSkill(string.Empty, skill.Id, new RollSkillCommand("public")).Succeeded);
using (var db = harness.CreateDbContext())
{
@@ -288,7 +287,7 @@ public sealed class GameServiceTests
}
using var invalidExpressionHarness = CreateHarnessFromPath(harness.DbPath, 2, 3, 4);
Assert.False(invalidExpressionHarness.Service.RollSkill(ownerSession, skill.Id, new RollSkillRequest("public")).Succeeded);
Assert.False(invalidExpressionHarness.Service.RollSkill(ownerSession, skill.Id, new RollSkillCommand("public")).Succeeded);
Assert.False(service.GetCampaignLog(string.Empty, campaign.Id).Succeeded);
}
@@ -298,20 +297,20 @@ public sealed class GameServiceTests
using var harness = CreateHarness();
var service = harness.Service;
service.Register(new RegisterRequest("gm", "Password123", "GM"));
service.Register(new RegisterRequest("owner", "Password123", "Owner"));
service.Register(new RegisterRequest("other", "Password123", "Other"));
service.Register(new RegisterCommand("gm", "Password123", "GM"));
service.Register(new RegisterCommand("owner", "Password123", "Owner"));
service.Register(new RegisterCommand("other", "Password123", "Other"));
var gmSession = GetValue(service.Login(new LoginRequest("gm", "Password123"))).SessionToken;
var ownerSession = GetValue(service.Login(new LoginRequest("owner", "Password123"))).SessionToken;
var otherSession = GetValue(service.Login(new LoginRequest("other", "Password123"))).SessionToken;
var gmSession = GetValue(service.Login(new LoginCommand("gm", "Password123"))).SessionToken;
var ownerSession = GetValue(service.Login(new LoginCommand("owner", "Password123"))).SessionToken;
var otherSession = GetValue(service.Login(new LoginCommand("other", "Password123"))).SessionToken;
var campaign = GetValue(service.CreateCampaign(gmSession, new CreateCampaignRequest("Main", "d6")));
var ownerCharacter = GetValue(service.CreateCharacter(ownerSession, new CreateCharacterRequest("Owner Character", campaign.Id)));
var otherCharacter = GetValue(service.CreateCharacter(otherSession, new CreateCharacterRequest("Other Character", campaign.Id)));
var campaign = GetValue(service.CreateCampaign(gmSession, new CreateCampaignCommand("Main", "d6")));
var ownerCharacter = GetValue(service.CreateCharacter(ownerSession, new CreateCharacterCommand("Owner Character", campaign.Id)));
var otherCharacter = GetValue(service.CreateCharacter(otherSession, new CreateCharacterCommand("Other Character", campaign.Id)));
var ownerSkill = GetValue(service.CreateSkill(ownerSession, ownerCharacter.Id, new CreateSkillRequest("Stealth", "2D+1")));
_ = GetValue(service.CreateSkill(otherSession, otherCharacter.Id, new CreateSkillRequest("Perception", "1D+2")));
var ownerSkill = GetValue(service.CreateSkill(ownerSession, ownerCharacter.Id, new CreateSkillCommand("Stealth", "2D+1")));
_ = GetValue(service.CreateSkill(otherSession, otherCharacter.Id, new CreateSkillCommand("Perception", "1D+2")));
var ownerView = GetValue(service.GetCampaign(ownerSession, campaign.Id));
Assert.Single(ownerView.Characters);
@@ -329,17 +328,21 @@ public sealed class GameServiceTests
var d6 = DiceRules.ParseExpression(RulesetKind.D6, "5D+4");
var dnd = DiceRules.ParseExpression(RulesetKind.Dnd5e, "2d12+2");
var emptyExpression = DiceRules.ParseExpression(RulesetKind.Dnd5e, "");
var badFormat = DiceRules.ParseExpression(RulesetKind.Dnd5e, "abc");
var tooManyDice = DiceRules.ParseExpression(RulesetKind.D6, "51D+1");
var tooManySides = DiceRules.ParseExpression(RulesetKind.Dnd5e, "1d1001");
var tooLargeModifier = DiceRules.ParseExpression(RulesetKind.Dnd5e, "1d20+1001");
var unknownRulesetExpression = DiceRules.ParseExpression((RulesetKind)99, "1d20+1");
Assert.True(d6.Succeeded);
Assert.True(dnd.Succeeded);
Assert.False(emptyExpression.Succeeded);
Assert.False(badFormat.Succeeded);
Assert.False(tooManyDice.Succeeded);
Assert.False(tooManySides.Succeeded);
Assert.False(tooLargeModifier.Succeeded);
Assert.False(unknownRulesetExpression.Succeeded);
Assert.Equal("d6", DiceRules.ToRulesetId(RulesetKind.D6));
Assert.Equal("dnd5e", DiceRules.ToRulesetId(RulesetKind.Dnd5e));
@@ -476,3 +479,4 @@ public sealed class GameServiceTests
}
}
}

View File

@@ -0,0 +1,20 @@
namespace RpgRoller.Api;
public static class ApiEndpointRegistration
{
public static void MapRpgRollerApi(this IEndpointRouteBuilder app)
{
var api = app.MapGroup("/api");
api.MapSystemEndpoints();
api.MapAuthEndpoints();
var authenticatedApi = api.MapGroup(string.Empty)
.AddEndpointFilter<RequireSessionTokenFilter>();
authenticatedApi.MapMeEndpoints();
authenticatedApi.MapCampaignEndpoints();
authenticatedApi.MapCharacterEndpoints();
authenticatedApi.MapSkillEndpoints();
authenticatedApi.MapStateEventEndpoints();
}
}

View File

@@ -0,0 +1,28 @@
using Microsoft.AspNetCore.Http.HttpResults;
using RpgRoller.Contracts;
using RpgRoller.Services;
namespace RpgRoller.Api;
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));
}
public static BadRequest<ApiError> ToBadRequest(ServiceError error)
{
return TypedResults.BadRequest(new ApiError(error.Message));
}
}

View File

@@ -0,0 +1,54 @@
using Microsoft.AspNetCore.Http.HttpResults;
using RpgRoller.Contracts;
using RpgRoller.Services;
namespace RpgRoller.Api;
internal static class AuthEndpoints
{
public static RouteGroupBuilder MapAuthEndpoints(this RouteGroupBuilder group)
{
group.MapPost("/auth/register", Results<Ok<UserSummary>, BadRequest<ApiError>> (RegisterRequest request, IGameService game) =>
{
var result = game.Register(request.ToCommand());
if (!result.Succeeded)
{
return ApiResultMapper.ToBadRequest(result.Error!);
}
return TypedResults.Ok(result.Value!);
});
group.MapPost("/auth/login", Results<Ok<UserSummary>, BadRequest<ApiError>> (LoginRequest request, HttpContext context, IGameService game) =>
{
var result = game.Login(request.ToCommand());
if (!result.Succeeded)
{
return ApiResultMapper.ToBadRequest(result.Error!);
}
context.Response.Cookies.Append(SessionCookie.Name, result.Value.SessionToken, new CookieOptions
{
HttpOnly = true,
SameSite = SameSiteMode.Strict,
IsEssential = true,
Secure = context.Request.IsHttps
});
return TypedResults.Ok(result.Value.User);
});
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();
});
return group;
}
}

View File

@@ -0,0 +1,36 @@
using RpgRoller.Contracts;
using RpgRoller.Services;
namespace RpgRoller.Api;
internal static class CampaignEndpoints
{
public static RouteGroupBuilder MapCampaignEndpoints(this RouteGroupBuilder group)
{
group.MapPost("/campaigns", (CreateCampaignRequest request, HttpContext context, IGameService game) =>
{
var result = game.CreateCampaign(context.GetRequiredSessionToken(), request.ToCommand());
return ApiResultMapper.ToApiResult(result);
});
group.MapGet("/campaigns", (HttpContext context, IGameService game) =>
{
var result = game.GetCampaigns(context.GetRequiredSessionToken());
return ApiResultMapper.ToApiResult(result);
});
group.MapGet("/campaigns/{campaignId:guid}", (Guid campaignId, HttpContext context, IGameService game) =>
{
var result = game.GetCampaign(context.GetRequiredSessionToken(), campaignId);
return ApiResultMapper.ToApiResult(result);
});
group.MapGet("/campaigns/{campaignId:guid}/log", (Guid campaignId, HttpContext context, IGameService game) =>
{
var result = game.GetCampaignLog(context.GetRequiredSessionToken(), campaignId);
return ApiResultMapper.ToApiResult(result);
});
return group;
}
}

View File

@@ -0,0 +1,36 @@
using RpgRoller.Contracts;
using RpgRoller.Services;
namespace RpgRoller.Api;
internal static class CharacterEndpoints
{
public static RouteGroupBuilder MapCharacterEndpoints(this RouteGroupBuilder group)
{
group.MapPost("/characters", (CreateCharacterRequest request, HttpContext context, IGameService game) =>
{
var result = game.CreateCharacter(context.GetRequiredSessionToken(), request.ToCommand());
return ApiResultMapper.ToApiResult(result);
});
group.MapPut("/characters/{characterId:guid}", (Guid characterId, UpdateCharacterRequest request, HttpContext context, IGameService game) =>
{
var result = game.UpdateCharacter(context.GetRequiredSessionToken(), characterId, request.ToCommand());
return ApiResultMapper.ToApiResult(result);
});
group.MapPost("/characters/{characterId:guid}/activate", (Guid characterId, HttpContext context, IGameService game) =>
{
var result = game.ActivateCharacter(context.GetRequiredSessionToken(), characterId);
return ApiResultMapper.ToApiResult(result);
});
group.MapGet("/characters/current-campaign", (HttpContext context, IGameService game) =>
{
var result = game.GetCurrentCampaignCharacters(context.GetRequiredSessionToken());
return ApiResultMapper.ToApiResult(result);
});
return group;
}
}

View File

@@ -0,0 +1,19 @@
using Microsoft.AspNetCore.Http.HttpResults;
using RpgRoller.Contracts;
using RpgRoller.Services;
namespace RpgRoller.Api;
internal static class MeEndpoints
{
public static RouteGroupBuilder MapMeEndpoints(this RouteGroupBuilder group)
{
group.MapGet("/me", Results<Ok<MeResponse>, BadRequest<ApiError>, UnauthorizedHttpResult> (HttpContext context, IGameService game) =>
{
var result = game.GetMe(context.GetRequiredSessionToken());
return ApiResultMapper.ToApiResult(result);
});
return group;
}
}

View File

@@ -0,0 +1,47 @@
using RpgRoller.Contracts;
using RpgRoller.Services;
namespace RpgRoller.Api;
internal static class RequestMappings
{
public static RegisterCommand ToCommand(this RegisterRequest request)
{
return new RegisterCommand(request.Username, request.Password, request.DisplayName);
}
public static LoginCommand ToCommand(this LoginRequest request)
{
return new LoginCommand(request.Username, request.Password);
}
public static CreateCampaignCommand ToCommand(this CreateCampaignRequest request)
{
return new CreateCampaignCommand(request.Name, request.RulesetId);
}
public static CreateCharacterCommand ToCommand(this CreateCharacterRequest request)
{
return new CreateCharacterCommand(request.Name, request.CampaignId);
}
public static UpdateCharacterCommand ToCommand(this UpdateCharacterRequest request)
{
return new UpdateCharacterCommand(request.Name, request.CampaignId);
}
public static CreateSkillCommand ToCommand(this CreateSkillRequest request)
{
return new CreateSkillCommand(request.Name, request.DiceRollDefinition);
}
public static UpdateSkillCommand ToCommand(this UpdateSkillRequest request)
{
return new UpdateSkillCommand(request.Name, request.DiceRollDefinition);
}
public static RollSkillCommand ToCommand(this RollSkillRequest request)
{
return new RollSkillCommand(request.Visibility);
}
}

View File

@@ -0,0 +1,15 @@
namespace RpgRoller.Api;
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

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

View File

@@ -0,0 +1,29 @@
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;
return !string.IsNullOrWhiteSpace(sessionToken);
}
public static void StoreSessionToken(this HttpContext context, string sessionToken)
{
context.Items[SessionTokenItemKey] = sessionToken;
}
public static string GetRequiredSessionToken(this HttpContext context)
{
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.");
}
}

View File

@@ -0,0 +1,30 @@
using RpgRoller.Contracts;
using RpgRoller.Services;
namespace RpgRoller.Api;
internal static class SkillEndpoints
{
public static RouteGroupBuilder MapSkillEndpoints(this RouteGroupBuilder group)
{
group.MapPost("/characters/{characterId:guid}/skills", (Guid characterId, CreateSkillRequest request, HttpContext context, IGameService game) =>
{
var result = game.CreateSkill(context.GetRequiredSessionToken(), characterId, request.ToCommand());
return ApiResultMapper.ToApiResult(result);
});
group.MapPut("/skills/{skillId:guid}", (Guid skillId, UpdateSkillRequest request, HttpContext context, IGameService game) =>
{
var result = game.UpdateSkill(context.GetRequiredSessionToken(), skillId, request.ToCommand());
return ApiResultMapper.ToApiResult(result);
});
group.MapPost("/skills/{skillId:guid}/roll", (Guid skillId, RollSkillRequest request, HttpContext context, IGameService game) =>
{
var result = game.RollSkill(context.GetRequiredSessionToken(), skillId, request.ToCommand());
return ApiResultMapper.ToApiResult(result);
});
return group;
}
}

View File

@@ -0,0 +1,66 @@
using RpgRoller.Contracts;
using RpgRoller.Services;
namespace RpgRoller.Api;
internal static class StateEventEndpoints
{
public static RouteGroupBuilder MapStateEventEndpoints(this RouteGroupBuilder group)
{
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));
}
context.Response.Headers.CacheControl = "no-cache";
context.Response.Headers.Connection = "keep-alive";
context.Response.ContentType = "text/event-stream";
var lastVersion = versionResult.Value;
await context.Response.WriteAsync($"event: state\ndata: {{\"campaignId\":\"{campaignId}\",\"version\":{lastVersion}}}\n\n");
await context.Response.Body.FlushAsync();
try
{
while (!context.RequestAborted.IsCancellationRequested)
{
await Task.Delay(TimeSpan.FromSeconds(1), context.RequestAborted);
var currentVersionResult = game.GetCampaignVersion(sessionToken, campaignId);
if (!currentVersionResult.Succeeded)
{
break;
}
if (currentVersionResult.Value != lastVersion)
{
lastVersion = currentVersionResult.Value;
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();
}
}
catch (OperationCanceledException)
{
}
return TypedResults.Empty;
});
return group;
}
}

View File

@@ -0,0 +1,14 @@
using RpgRoller.Contracts;
using RpgRoller.Services;
namespace RpgRoller.Api;
internal static class SystemEndpoints
{
public static RouteGroupBuilder MapSystemEndpoints(this RouteGroupBuilder group)
{
group.MapGet("/health", () => TypedResults.Ok(new HealthResponse("ok")));
group.MapGet("/rulesets", (IGameService game) => TypedResults.Ok(game.GetRulesets()));
return group;
}
}

View File

@@ -0,0 +1,17 @@
using Microsoft.EntityFrameworkCore;
using RpgRoller.Data;
using RpgRoller.Services;
namespace RpgRoller.Hosting;
public static class ApplicationInitializationExtensions
{
public static void InitializeRpgRollerState(this WebApplication app)
{
using var scope = app.Services.CreateScope();
var dbFactory = scope.ServiceProvider.GetRequiredService<IDbContextFactory<RpgRollerDbContext>>();
using var db = dbFactory.CreateDbContext();
db.Database.EnsureCreated();
_ = scope.ServiceProvider.GetRequiredService<IGameService>();
}
}

View File

@@ -0,0 +1,46 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using RpgRoller.Data;
using RpgRoller.Domain;
using RpgRoller.Services;
namespace RpgRoller.Hosting;
public static class ServiceCollectionExtensions
{
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.AddSingleton<IDiceRoller, RandomDiceRoller>();
services.AddSingleton<IGameService, GameService>();
return services;
}
private static void EnsureSqliteDataDirectory(string connectionString, string contentRootPath)
{
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 directory = Path.GetDirectoryName(fullPath);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
}
}

View File

@@ -1,324 +1,16 @@
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Identity;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using RpgRoller.Contracts;
using RpgRoller.Data;
using RpgRoller.Domain;
using RpgRoller.Services;
const string SessionCookieName = "rpgroller_session";
using RpgRoller.Api;
using RpgRoller.Hosting;
var builder = WebApplication.CreateBuilder(args);
var sqliteConnectionString = builder.Configuration.GetConnectionString("RpgRoller") ?? "Data Source=App_Data/rpgroller.db";
EnsureSqliteDataDirectory(sqliteConnectionString, builder.Environment.ContentRootPath);
builder.Services.AddSingleton<IPasswordHasher<UserAccount>, PasswordHasher<UserAccount>>();
builder.Services.AddDbContextFactory<RpgRollerDbContext>(options => options.UseSqlite(sqliteConnectionString));
builder.Services.AddSingleton<IDiceRoller, RandomDiceRoller>();
builder.Services.AddSingleton<IGameService, GameService>();
builder.Services.AddRpgRollerCore(builder.Configuration, builder.Environment);
var app = builder.Build();
using (var scope = app.Services.CreateScope())
{
var dbFactory = scope.ServiceProvider.GetRequiredService<IDbContextFactory<RpgRollerDbContext>>();
using var db = dbFactory.CreateDbContext();
db.Database.EnsureCreated();
_ = scope.ServiceProvider.GetRequiredService<IGameService>();
}
app.InitializeRpgRollerState();
app.UseDefaultFiles();
app.UseStaticFiles();
app.MapGet("/api/health", () => TypedResults.Ok(new HealthResponse("ok")));
app.MapGet("/api/rulesets", (IGameService game) => TypedResults.Ok(game.GetRulesets()));
app.MapPost("/api/auth/register", Results<Ok<UserSummary>, BadRequest<ApiError>> (RegisterRequest request, IGameService game) =>
{
var result = game.Register(request);
if (!result.Succeeded)
{
return ToBadRequest(result.Error!);
}
return TypedResults.Ok(result.Value!);
});
app.MapPost("/api/auth/login", Results<Ok<UserSummary>, BadRequest<ApiError>> (LoginRequest request, HttpContext context, IGameService game) =>
{
var result = game.Login(request);
if (!result.Succeeded)
{
return ToBadRequest(result.Error!);
}
context.Response.Cookies.Append(SessionCookieName, result.Value.SessionToken, new CookieOptions
{
HttpOnly = true,
SameSite = SameSiteMode.Strict,
IsEssential = true,
Secure = context.Request.IsHttps
});
return TypedResults.Ok(result.Value.User);
});
app.MapPost("/api/auth/logout", (HttpContext context, IGameService game) =>
{
if (TryGetSessionToken(context, out var sessionToken))
{
game.Logout(sessionToken);
}
context.Response.Cookies.Delete(SessionCookieName);
return TypedResults.NoContent();
});
app.MapGet("/api/me", Results<Ok<MeResponse>, BadRequest<ApiError>, UnauthorizedHttpResult> (HttpContext context, IGameService game) =>
{
if (!TryGetSessionToken(context, out var sessionToken))
{
return TypedResults.Unauthorized();
}
var result = game.GetMe(sessionToken);
if (!result.Succeeded)
{
return result.Error!.Code == "unauthorized"
? TypedResults.Unauthorized()
: TypedResults.BadRequest(new ApiError(result.Error.Message));
}
return TypedResults.Ok(result.Value!);
});
app.MapPost("/api/campaigns", (CreateCampaignRequest request, HttpContext context, IGameService game) =>
{
if (!TryGetSessionToken(context, out var sessionToken))
{
return TypedResults.Unauthorized();
}
var result = game.CreateCampaign(sessionToken, request);
return ToApiResult(result);
});
app.MapGet("/api/campaigns", (HttpContext context, IGameService game) =>
{
if (!TryGetSessionToken(context, out var sessionToken))
{
return TypedResults.Unauthorized();
}
var result = game.GetCampaigns(sessionToken);
return ToApiResult(result);
});
app.MapGet("/api/campaigns/{campaignId:guid}", (Guid campaignId, HttpContext context, IGameService game) =>
{
if (!TryGetSessionToken(context, out var sessionToken))
{
return TypedResults.Unauthorized();
}
var result = game.GetCampaign(sessionToken, campaignId);
return ToApiResult(result);
});
app.MapGet("/api/campaigns/{campaignId:guid}/log", (Guid campaignId, HttpContext context, IGameService game) =>
{
if (!TryGetSessionToken(context, out var sessionToken))
{
return TypedResults.Unauthorized();
}
var result = game.GetCampaignLog(sessionToken, campaignId);
return ToApiResult(result);
});
app.MapPost("/api/characters", (CreateCharacterRequest request, HttpContext context, IGameService game) =>
{
if (!TryGetSessionToken(context, out var sessionToken))
{
return TypedResults.Unauthorized();
}
var result = game.CreateCharacter(sessionToken, request);
return ToApiResult(result);
});
app.MapPut("/api/characters/{characterId:guid}", (Guid characterId, UpdateCharacterRequest request, HttpContext context, IGameService game) =>
{
if (!TryGetSessionToken(context, out var sessionToken))
{
return TypedResults.Unauthorized();
}
var result = game.UpdateCharacter(sessionToken, characterId, request);
return ToApiResult(result);
});
app.MapPost("/api/characters/{characterId:guid}/activate", (Guid characterId, HttpContext context, IGameService game) =>
{
if (!TryGetSessionToken(context, out var sessionToken))
{
return TypedResults.Unauthorized();
}
var result = game.ActivateCharacter(sessionToken, characterId);
return ToApiResult(result);
});
app.MapGet("/api/characters/current-campaign", (HttpContext context, IGameService game) =>
{
if (!TryGetSessionToken(context, out var sessionToken))
{
return TypedResults.Unauthorized();
}
var result = game.GetCurrentCampaignCharacters(sessionToken);
return ToApiResult(result);
});
app.MapPost("/api/characters/{characterId:guid}/skills", (Guid characterId, CreateSkillRequest request, HttpContext context, IGameService game) =>
{
if (!TryGetSessionToken(context, out var sessionToken))
{
return TypedResults.Unauthorized();
}
var result = game.CreateSkill(sessionToken, characterId, request);
return ToApiResult(result);
});
app.MapPut("/api/skills/{skillId:guid}", (Guid skillId, UpdateSkillRequest request, HttpContext context, IGameService game) =>
{
if (!TryGetSessionToken(context, out var sessionToken))
{
return TypedResults.Unauthorized();
}
var result = game.UpdateSkill(sessionToken, skillId, request);
return ToApiResult(result);
});
app.MapPost("/api/skills/{skillId:guid}/roll", (Guid skillId, RollSkillRequest request, HttpContext context, IGameService game) =>
{
if (!TryGetSessionToken(context, out var sessionToken))
{
return TypedResults.Unauthorized();
}
var result = game.RollSkill(sessionToken, skillId, request);
return ToApiResult(result);
});
app.MapGet("/api/events/state", async Task<IResult> (
Guid campaignId,
HttpContext context,
IGameService game) =>
{
if (!TryGetSessionToken(context, out var sessionToken))
{
return TypedResults.Unauthorized();
}
var versionResult = game.GetCampaignVersion(sessionToken, campaignId);
if (!versionResult.Succeeded)
{
return versionResult.Error!.Code == "unauthorized"
? TypedResults.Unauthorized()
: TypedResults.BadRequest(new ApiError(versionResult.Error.Message));
}
context.Response.Headers.CacheControl = "no-cache";
context.Response.Headers.Connection = "keep-alive";
context.Response.ContentType = "text/event-stream";
var lastVersion = versionResult.Value;
await context.Response.WriteAsync($"event: state\ndata: {{\"campaignId\":\"{campaignId}\",\"version\":{lastVersion}}}\n\n");
await context.Response.Body.FlushAsync();
try
{
while (!context.RequestAborted.IsCancellationRequested)
{
await Task.Delay(TimeSpan.FromSeconds(1), context.RequestAborted);
var currentVersionResult = game.GetCampaignVersion(sessionToken, campaignId);
if (!currentVersionResult.Succeeded)
{
break;
}
if (currentVersionResult.Value != lastVersion)
{
lastVersion = currentVersionResult.Value;
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();
}
}
catch (OperationCanceledException)
{
}
return TypedResults.Empty;
});
app.MapRpgRollerApi();
app.Run();
return;
static bool TryGetSessionToken(HttpContext context, out string sessionToken)
{
sessionToken = context.Request.Cookies[SessionCookieName] ?? string.Empty;
return !string.IsNullOrWhiteSpace(sessionToken);
}
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));
}
static BadRequest<ApiError> ToBadRequest(ServiceError error)
{
return TypedResults.BadRequest(new ApiError(error.Message));
}
static void EnsureSqliteDataDirectory(string connectionString, string contentRootPath)
{
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 directory = Path.GetDirectoryName(fullPath);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
}
public partial class Program;

View File

@@ -35,7 +35,7 @@ public sealed class GameService : IGameService
.ToArray();
}
public ServiceResult<UserSummary> Register(RegisterRequest request)
public ServiceResult<UserSummary> Register(RegisterCommand request)
{
if (string.IsNullOrWhiteSpace(request.Username))
{
@@ -81,7 +81,7 @@ public sealed class GameService : IGameService
}
}
public ServiceResult<(UserSummary User, string SessionToken)> Login(LoginRequest request)
public ServiceResult<(UserSummary User, string SessionToken)> Login(LoginCommand request)
{
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password))
{
@@ -162,7 +162,7 @@ public sealed class GameService : IGameService
}
}
public ServiceResult<CampaignSummary> CreateCampaign(string sessionToken, CreateCampaignRequest request)
public ServiceResult<CampaignSummary> CreateCampaign(string sessionToken, CreateCampaignCommand request)
{
if (string.IsNullOrWhiteSpace(request.Name))
{
@@ -263,7 +263,7 @@ public sealed class GameService : IGameService
}
}
public ServiceResult<CharacterSummary> CreateCharacter(string sessionToken, CreateCharacterRequest request)
public ServiceResult<CharacterSummary> CreateCharacter(string sessionToken, CreateCharacterCommand request)
{
if (string.IsNullOrWhiteSpace(request.Name))
{
@@ -299,7 +299,7 @@ public sealed class GameService : IGameService
}
}
public ServiceResult<CharacterSummary> UpdateCharacter(string sessionToken, Guid characterId, UpdateCharacterRequest request)
public ServiceResult<CharacterSummary> UpdateCharacter(string sessionToken, Guid characterId, UpdateCharacterCommand request)
{
if (string.IsNullOrWhiteSpace(request.Name))
{
@@ -399,7 +399,7 @@ public sealed class GameService : IGameService
}
}
public ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, CreateSkillRequest request)
public ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, CreateSkillCommand request)
{
if (string.IsNullOrWhiteSpace(request.Name))
{
@@ -447,7 +447,7 @@ public sealed class GameService : IGameService
}
}
public ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, UpdateSkillRequest request)
public ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, UpdateSkillCommand request)
{
if (string.IsNullOrWhiteSpace(request.Name))
{
@@ -489,7 +489,7 @@ public sealed class GameService : IGameService
}
}
public ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, RollSkillRequest request)
public ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, RollSkillCommand request)
{
lock (m_Gate)
{

View File

@@ -7,25 +7,25 @@ public interface IGameService
{
IReadOnlyList<RulesetDefinition> GetRulesets();
ServiceResult<UserSummary> Register(RegisterRequest request);
ServiceResult<(UserSummary User, string SessionToken)> Login(LoginRequest request);
ServiceResult<UserSummary> Register(RegisterCommand request);
ServiceResult<(UserSummary User, string SessionToken)> Login(LoginCommand request);
void Logout(string sessionToken);
UserSummary? GetUserBySession(string sessionToken);
ServiceResult<MeResponse> GetMe(string sessionToken);
ServiceResult<CampaignSummary> CreateCampaign(string sessionToken, CreateCampaignRequest request);
ServiceResult<CampaignSummary> CreateCampaign(string sessionToken, CreateCampaignCommand request);
ServiceResult<IReadOnlyList<CampaignSummary>> GetCampaigns(string sessionToken);
ServiceResult<CampaignDetails> GetCampaign(string sessionToken, Guid campaignId);
ServiceResult<CharacterSummary> CreateCharacter(string sessionToken, CreateCharacterRequest request);
ServiceResult<CharacterSummary> UpdateCharacter(string sessionToken, Guid characterId, UpdateCharacterRequest request);
ServiceResult<CharacterSummary> CreateCharacter(string sessionToken, CreateCharacterCommand request);
ServiceResult<CharacterSummary> UpdateCharacter(string sessionToken, Guid characterId, UpdateCharacterCommand request);
ServiceResult<bool> ActivateCharacter(string sessionToken, Guid characterId);
ServiceResult<IReadOnlyList<CharacterSummary>> GetCurrentCampaignCharacters(string sessionToken);
ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, CreateSkillRequest request);
ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, UpdateSkillRequest request);
ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, CreateSkillCommand request);
ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, UpdateSkillCommand request);
ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, RollSkillRequest request);
ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, RollSkillCommand request);
ServiceResult<IReadOnlyList<CampaignLogEntry>> GetCampaignLog(string sessionToken, Guid campaignId);
ServiceResult<long> GetCampaignVersion(string sessionToken, Guid campaignId);

View File

@@ -0,0 +1,13 @@
namespace RpgRoller.Services;
public sealed record RegisterCommand(string Username, string Password, string DisplayName);
public sealed record LoginCommand(string Username, string Password);
public sealed record CreateCampaignCommand(string Name, string RulesetId);
public sealed record CreateCharacterCommand(string Name, Guid CampaignId);
public sealed record UpdateCharacterCommand(string Name, Guid CampaignId);
public sealed record CreateSkillCommand(string Name, string DiceRollDefinition);
public sealed record UpdateSkillCommand(string Name, string DiceRollDefinition);
public sealed record RollSkillCommand(string Visibility);

View File

@@ -1,193 +1,143 @@
import {
activateCharacter,
type CampaignDetails,
type CampaignLogEntry,
type CampaignSummary,
createCampaign,
createCharacter,
createSkill,
getCampaign,
getCampaignLog,
getCampaigns,
getHealth,
getMe,
getRulesets,
loginUser,
logoutUser,
registerUser,
rollSkill,
type RulesetDefinition,
type SkillSummary,
type UserSummary,
updateSkill
} from "./generated/api-client.js";
import { runAction, setMessage } from "./app/actions.js";
import { getAppElements } from "./app/dom.js";
import { closeStateEvents, connectStateEvents } from "./app/events.js";
import {
ensureRulesets,
refreshHealth,
reloadCampaignLog,
reloadCampaigns,
reloadSelectedCampaign,
reloadSession
} from "./app/loaders.js";
import { renderAll, renderCampaignDetails, renderCampaignLog, renderCampaignMeta, renderCharacterSelect, renderSkillSelect } from "./app/render.js";
import { createInitialState, resetAuthenticatedState, resetStateAfterLogout, syncSelectedCharacter } from "./app/state.js";
const healthElement = mustElement("health");
const messageElement = mustElement("message");
const campaignMetaElement = mustElement("campaign-meta");
const campaignDetailsElement = mustElement("campaign-details");
const rollResultElement = mustElement("roll-result");
const campaignLogElement = mustElement("campaign-log");
const elements = getAppElements();
const state = createInitialState();
const registerForm = mustForm("register-form");
const loginForm = mustForm("login-form");
const logoutButton = mustButton("logout-button");
const registerUsername = mustInput("register-username");
const registerDisplayName = mustInput("register-display-name");
const registerPassword = mustInput("register-password");
const loginUsername = mustInput("login-username");
const loginPassword = mustInput("login-password");
const campaignForm = mustForm("campaign-form");
const campaignNameInput = mustInput("campaign-name");
const campaignRulesetSelect = mustSelect("campaign-ruleset");
const campaignSelect = mustSelect("campaign-select");
const refreshCampaignButton = mustButton("refresh-campaign-button");
const characterForm = mustForm("character-form");
const characterNameInput = mustInput("character-name");
const characterSelect = mustSelect("character-select");
const activateCharacterButton = mustButton("activate-character-button");
const skillForm = mustForm("skill-form");
const skillNameInput = mustInput("skill-name");
const skillExpressionInput = mustInput("skill-expression");
const updateSkillButton = mustButton("update-skill-button");
const skillSelect = mustSelect("skill-select");
const rollForm = mustForm("roll-form");
const rollVisibilitySelect = mustSelect("roll-visibility");
type AppState = {
user: UserSummary | null;
activeCharacterId: string | null;
selectedCharacterId: string | null;
campaigns: CampaignSummary[];
selectedCampaignId: string | null;
selectedCampaign: CampaignDetails | null;
campaignLog: CampaignLogEntry[];
rulesets: RulesetDefinition[];
eventSource: EventSource | null;
};
const state: AppState = {
user: null,
activeCharacterId: null,
selectedCharacterId: null,
campaigns: [],
selectedCampaignId: null,
selectedCampaign: null,
campaignLog: [],
rulesets: [],
eventSource: null
};
registerForm.addEventListener("submit", async (event) => {
elements.registerForm.addEventListener("submit", async (event) => {
event.preventDefault();
await runAction(async () => {
await runAppAction(async () => {
await registerUser({
username: registerUsername.value.trim(),
displayName: registerDisplayName.value.trim(),
password: registerPassword.value
username: elements.registerUsername.value.trim(),
displayName: elements.registerDisplayName.value.trim(),
password: elements.registerPassword.value
});
registerPassword.value = "";
setMessage("Registration successful. You can log in now.", false);
elements.registerPassword.value = "";
setStatus("Registration successful. You can log in now.", false);
});
});
loginForm.addEventListener("submit", async (event) => {
elements.loginForm.addEventListener("submit", async (event) => {
event.preventDefault();
await runAction(async () => {
await runAppAction(async () => {
await loginUser({
username: loginUsername.value.trim(),
password: loginPassword.value
username: elements.loginUsername.value.trim(),
password: elements.loginPassword.value
});
loginPassword.value = "";
elements.loginPassword.value = "";
await reloadAll();
setMessage("Logged in.", false);
setStatus("Logged in.", false);
});
});
logoutButton.addEventListener("click", async () => {
await runAction(async () => {
elements.logoutButton.addEventListener("click", async () => {
await runAppAction(async () => {
await logoutUser();
resetStateAfterLogout();
renderAll();
setMessage("Logged out.", false);
resetStateAfterLogout(state);
closeStateEvents(state);
renderAll(state, elements);
setStatus("Logged out.", false);
});
});
campaignForm.addEventListener("submit", async (event) => {
elements.campaignForm.addEventListener("submit", async (event) => {
event.preventDefault();
await runAction(async () => {
await runAppAction(async () => {
const createdCampaign = await createCampaign({
name: campaignNameInput.value.trim(),
rulesetId: campaignRulesetSelect.value
name: elements.campaignNameInput.value.trim(),
rulesetId: elements.campaignRulesetSelect.value
});
campaignNameInput.value = "";
await reloadCampaigns(createdCampaign.id);
setMessage("Campaign created.", false);
elements.campaignNameInput.value = "";
await reloadCampaigns(state, createdCampaign.id);
await reloadSelectedCampaign(state);
await reloadCampaignLog(state);
syncEventStream();
renderAll(state, elements);
setStatus("Campaign created.", false);
});
});
campaignSelect.addEventListener("change", async () => {
await runAction(async () => {
const selected = campaignSelect.value;
elements.campaignSelect.addEventListener("change", async () => {
await runAppAction(async () => {
const selected = elements.campaignSelect.value;
state.selectedCampaignId = selected.length > 0 ? selected : null;
await reloadSelectedCampaign();
syncSelectedCharacter();
renderCharacterSelect();
renderSkillSelect();
connectStateEvents();
renderCampaignMeta();
renderCampaignDetails();
renderCampaignLog();
await reloadSelectedCampaign(state);
syncSelectedCharacter(state);
renderCharacterSelect(state, elements);
renderSkillSelect(state, elements);
syncEventStream();
renderCampaignMeta(state, elements);
renderCampaignDetails(state, elements);
renderCampaignLog(state, elements);
});
});
characterSelect.addEventListener("change", () => {
state.selectedCharacterId = characterSelect.value.length > 0 ? characterSelect.value : null;
renderSkillSelect();
elements.characterSelect.addEventListener("change", () => {
state.selectedCharacterId = elements.characterSelect.value.length > 0 ? elements.characterSelect.value : null;
renderSkillSelect(state, elements);
});
refreshCampaignButton.addEventListener("click", async () => {
await runAction(async () => {
await reloadSelectedCampaign();
renderAll();
setMessage("Campaign refreshed.", false);
elements.refreshCampaignButton.addEventListener("click", async () => {
await runAppAction(async () => {
await reloadSelectedCampaign(state);
await reloadCampaignLog(state);
renderAll(state, elements);
setStatus("Campaign refreshed.", false);
});
});
characterForm.addEventListener("submit", async (event) => {
elements.characterForm.addEventListener("submit", async (event) => {
event.preventDefault();
await runAction(async () => {
await runAppAction(async () => {
if (!state.selectedCampaignId) {
throw new Error("Select a campaign first.");
}
await createCharacter({
name: characterNameInput.value.trim(),
name: elements.characterNameInput.value.trim(),
campaignId: state.selectedCampaignId
});
characterNameInput.value = "";
await reloadSelectedCampaign();
await reloadCampaignLog();
setMessage("Character created.", false);
elements.characterNameInput.value = "";
await reloadSelectedCampaign(state);
await reloadCampaignLog(state);
renderAll(state, elements);
setStatus("Character created.", false);
});
});
activateCharacterButton.addEventListener("click", async () => {
await runAction(async () => {
const characterId = characterSelect.value;
elements.activateCharacterButton.addEventListener("click", async () => {
await runAppAction(async () => {
const characterId = elements.characterSelect.value;
if (characterId.length === 0) {
throw new Error("Select a character to activate.");
}
@@ -196,426 +146,107 @@ activateCharacterButton.addEventListener("click", async () => {
state.activeCharacterId = characterId;
state.selectedCharacterId = characterId;
await reloadAll();
setMessage("Active character updated.", false);
setStatus("Active character updated.", false);
});
});
skillForm.addEventListener("submit", async (event) => {
elements.skillForm.addEventListener("submit", async (event) => {
event.preventDefault();
await runAction(async () => {
const characterId = characterSelect.value;
await runAppAction(async () => {
const characterId = elements.characterSelect.value;
if (characterId.length === 0) {
throw new Error("Select a character first.");
}
await createSkill(characterId, {
name: skillNameInput.value.trim(),
diceRollDefinition: skillExpressionInput.value.trim()
name: elements.skillNameInput.value.trim(),
diceRollDefinition: elements.skillExpressionInput.value.trim()
});
skillNameInput.value = "";
skillExpressionInput.value = "";
await reloadSelectedCampaign();
setMessage("Skill created.", false);
elements.skillNameInput.value = "";
elements.skillExpressionInput.value = "";
await reloadSelectedCampaign(state);
renderAll(state, elements);
setStatus("Skill created.", false);
});
});
updateSkillButton.addEventListener("click", async () => {
await runAction(async () => {
const selectedSkillId = skillSelect.value;
elements.updateSkillButton.addEventListener("click", async () => {
await runAppAction(async () => {
const selectedSkillId = elements.skillSelect.value;
if (selectedSkillId.length === 0) {
throw new Error("Select a skill to update.");
}
await updateSkill(selectedSkillId, {
name: skillNameInput.value.trim(),
diceRollDefinition: skillExpressionInput.value.trim()
name: elements.skillNameInput.value.trim(),
diceRollDefinition: elements.skillExpressionInput.value.trim()
});
await reloadSelectedCampaign();
setMessage("Skill updated.", false);
await reloadSelectedCampaign(state);
renderAll(state, elements);
setStatus("Skill updated.", false);
});
});
rollForm.addEventListener("submit", async (event) => {
elements.rollForm.addEventListener("submit", async (event) => {
event.preventDefault();
await runAction(async () => {
const selectedSkillId = skillSelect.value;
await runAppAction(async () => {
const selectedSkillId = elements.skillSelect.value;
if (selectedSkillId.length === 0) {
throw new Error("Select a skill to roll.");
}
const roll = await rollSkill(selectedSkillId, { visibility: rollVisibilitySelect.value });
rollResultElement.textContent = `Roll ${roll.rollId}: ${roll.breakdown} (${roll.visibility})`;
const roll = await rollSkill(selectedSkillId, { visibility: elements.rollVisibilitySelect.value });
elements.rollResultElement.textContent = `Roll ${roll.rollId}: ${roll.breakdown} (${roll.visibility})`;
await reloadCampaignLog();
setMessage("Roll recorded.", false);
await reloadCampaignLog(state);
renderCampaignLog(state, elements);
setStatus("Roll recorded.", false);
});
});
await runAction(async () => {
await refreshHealth();
await runAppAction(async () => {
await refreshHealth(elements);
await reloadAll();
setMessage("Ready.", false);
setStatus("Ready.", false);
});
async function refreshHealth(): Promise<void> {
const health = await getHealth();
healthElement.textContent = `API status: ${health.status}`;
}
async function reloadAll(): Promise<void> {
await ensureRulesets();
await reloadSession();
await ensureRulesets(state, elements);
await reloadSession(state);
if (state.user) {
await reloadCampaigns(state.selectedCampaignId);
await reloadSelectedCampaign();
await reloadCampaignLog();
connectStateEvents();
await reloadCampaigns(state, state.selectedCampaignId);
await reloadSelectedCampaign(state);
await reloadCampaignLog(state);
syncEventStream();
}
else {
resetAuthenticatedState();
resetAuthenticatedState(state);
closeStateEvents(state);
}
renderAll();
renderAll(state, elements);
}
async function reloadSession(): Promise<void> {
try {
const me = await getMe();
state.user = me.user;
state.activeCharacterId = me.activeCharacterId ?? null;
state.selectedCampaignId = me.currentCampaignId ?? state.selectedCampaignId;
}
catch {
state.user = null;
state.activeCharacterId = null;
}
}
async function ensureRulesets(): Promise<void> {
if (state.rulesets.length > 0) {
return;
}
state.rulesets = await getRulesets();
campaignRulesetSelect.innerHTML = state.rulesets
.map((ruleset) => `<option value="${ruleset.id}">${ruleset.name}</option>`)
.join("");
}
async function reloadCampaigns(preferredCampaignId: string | null): Promise<void> {
state.campaigns = await getCampaigns();
if (state.campaigns.length === 0) {
state.selectedCampaignId = null;
return;
}
const availableIds = new Set(state.campaigns.map((campaign) => campaign.id));
if (preferredCampaignId && availableIds.has(preferredCampaignId)) {
state.selectedCampaignId = preferredCampaignId;
return;
}
if (state.selectedCampaignId && availableIds.has(state.selectedCampaignId)) {
return;
}
state.selectedCampaignId = state.campaigns[0].id;
}
async function reloadSelectedCampaign(): Promise<void> {
if (!state.selectedCampaignId) {
state.selectedCampaign = null;
return;
}
state.selectedCampaign = await getCampaign(state.selectedCampaignId);
syncSelectedCharacter();
}
async function reloadCampaignLog(): Promise<void> {
if (!state.selectedCampaignId) {
state.campaignLog = [];
return;
}
state.campaignLog = await getCampaignLog(state.selectedCampaignId);
}
function connectStateEvents(): void {
if (!state.selectedCampaignId || !state.user) {
closeStateEvents();
return;
}
if (state.eventSource && state.eventSource.url.endsWith(`campaignId=${state.selectedCampaignId}`)) {
return;
}
closeStateEvents();
state.eventSource = new EventSource(`/api/events/state?campaignId=${state.selectedCampaignId}`);
state.eventSource.addEventListener("state", () => {
void runAction(async () => {
await reloadSelectedCampaign();
await reloadCampaignLog();
renderAll();
function syncEventStream(): void {
connectStateEvents(state, () => {
void runAppAction(async () => {
await reloadSelectedCampaign(state);
await reloadCampaignLog(state);
renderAll(state, elements);
});
});
state.eventSource.onerror = () => {
closeStateEvents();
};
}
function closeStateEvents(): void {
if (state.eventSource) {
state.eventSource.close();
state.eventSource = null;
}
async function runAppAction(action: () => Promise<void>): Promise<void> {
await runAction(action, (message) => {
setStatus(message, true);
});
}
function resetStateAfterLogout(): void {
state.user = null;
resetAuthenticatedState();
closeStateEvents();
}
function resetAuthenticatedState(): void {
state.activeCharacterId = null;
state.selectedCharacterId = null;
state.campaigns = [];
state.selectedCampaignId = null;
state.selectedCampaign = null;
state.campaignLog = [];
}
function renderAll(): void {
renderCampaignSelect();
renderCampaignMeta();
renderCampaignDetails();
renderCharacterSelect();
renderSkillSelect();
renderCampaignLog();
}
function renderCampaignSelect(): void {
campaignSelect.innerHTML = state.campaigns
.map((campaign) => {
const selected = campaign.id === state.selectedCampaignId ? " selected" : "";
return `<option value="${campaign.id}"${selected}>${campaign.name} (${campaign.rulesetId})</option>`;
})
.join("");
}
function renderCampaignMeta(): void {
if (!state.user) {
campaignMetaElement.textContent = "Log in to manage campaigns.";
return;
}
if (!state.selectedCampaign) {
campaignMetaElement.textContent = "No campaign selected.";
return;
}
const isGm = state.selectedCampaign.gm.id === state.user.id;
const activeCharacter = state.selectedCampaign.characters.find((character) => character.id === state.activeCharacterId);
const activeLabel = activeCharacter ? ` | Active: ${activeCharacter.name}` : "";
campaignMetaElement.textContent = `Selected: ${state.selectedCampaign.name} (${state.selectedCampaign.rulesetId}) - ${isGm ? "You are GM" : "Player context"}${activeLabel}`;
}
function renderCampaignDetails(): void {
if (!state.selectedCampaign) {
campaignDetailsElement.textContent = "No details available.";
return;
}
const characters = state.selectedCampaign.characters
.map((character) => `<li>${character.name} (${character.id})</li>`)
.join("");
const skills = state.selectedCampaign.skills
.map((skill) => `<li>${skill.name} [${skill.diceRollDefinition}]</li>`)
.join("");
campaignDetailsElement.innerHTML = `
<p>GM: ${state.selectedCampaign.gm.displayName}</p>
<p>Characters visible to you: ${state.selectedCampaign.characters.length}</p>
<ul>${characters}</ul>
<p>Skills visible to you: ${state.selectedCampaign.skills.length}</p>
<ul>${skills}</ul>
`;
}
function renderCharacterSelect(): void {
if (!state.selectedCampaign) {
characterSelect.innerHTML = "";
state.selectedCharacterId = null;
return;
}
const selectedCharacterId = resolveSelectedCharacterId();
const options = state.selectedCampaign.characters
.map((character) => {
const selected = character.id === selectedCharacterId ? " selected" : "";
return `<option value="${character.id}"${selected}>${character.name}</option>`;
})
.join("");
characterSelect.innerHTML = options;
state.selectedCharacterId = selectedCharacterId;
}
function renderSkillSelect(): void {
if (!state.selectedCampaign) {
skillSelect.innerHTML = "";
return;
}
const selectedCharacterId = resolveSelectedCharacterId();
const characterSkills = state.selectedCampaign.skills.filter((skill) => skill.characterId === selectedCharacterId);
const options = characterSkills
.map((skill) => `<option value="${skill.id}">${skill.name} (${skill.diceRollDefinition})</option>`)
.join("");
skillSelect.innerHTML = options;
const selectedSkill = selectedSkillFromCampaign();
if (selectedSkill) {
skillNameInput.value = selectedSkill.name;
skillExpressionInput.value = selectedSkill.diceRollDefinition;
}
}
function selectedSkillFromCampaign(): SkillSummary | null {
if (!state.selectedCampaign) {
return null;
}
const selectedCharacterId = resolveSelectedCharacterId();
const selectedSkillId = skillSelect.value;
return state.selectedCampaign.skills
.filter((skill) => skill.characterId === selectedCharacterId)
.find((skill) => skill.id === selectedSkillId) ?? null;
}
function renderCampaignLog(): void {
if (state.campaignLog.length === 0) {
campaignLogElement.innerHTML = "<li class=\"log-item\">No rolls yet.</li>";
return;
}
campaignLogElement.innerHTML = state.campaignLog
.map((entry) => `
<li class="log-item">
<strong>${entry.visibility.toUpperCase()}</strong>
<span> ${entry.breakdown}</span>
<small> ${new Date(entry.timestampUtc).toLocaleString()}</small>
</li>
`)
.join("");
}
async function runAction(action: () => Promise<void>): Promise<void> {
try {
await action();
}
catch (error: unknown) {
setMessage(formatError(error), true);
}
}
function setMessage(message: string, isError: boolean): void {
messageElement.textContent = message;
messageElement.style.color = isError ? "#b91c1c" : "#1d4ed8";
}
function formatError(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return String(error);
}
function mustElement(id: string): HTMLElement {
const element = document.getElementById(id);
if (!(element instanceof HTMLElement)) {
throw new Error(`Missing HTMLElement: ${id}`);
}
return element;
}
function mustInput(id: string): HTMLInputElement {
const element = document.getElementById(id);
if (!(element instanceof HTMLInputElement)) {
throw new Error(`Missing HTMLInputElement: ${id}`);
}
return element;
}
function mustSelect(id: string): HTMLSelectElement {
const element = document.getElementById(id);
if (!(element instanceof HTMLSelectElement)) {
throw new Error(`Missing HTMLSelectElement: ${id}`);
}
return element;
}
function mustForm(id: string): HTMLFormElement {
const element = document.getElementById(id);
if (!(element instanceof HTMLFormElement)) {
throw new Error(`Missing HTMLFormElement: ${id}`);
}
return element;
}
function mustButton(id: string): HTMLButtonElement {
const element = document.getElementById(id);
if (!(element instanceof HTMLButtonElement)) {
throw new Error(`Missing HTMLButtonElement: ${id}`);
}
return element;
}
function syncSelectedCharacter(): void {
if (!state.selectedCampaign) {
state.selectedCharacterId = null;
return;
}
const availableIds = new Set(state.selectedCampaign.characters.map((character) => character.id));
if (state.selectedCharacterId && availableIds.has(state.selectedCharacterId)) {
return;
}
if (state.activeCharacterId && availableIds.has(state.activeCharacterId)) {
state.selectedCharacterId = state.activeCharacterId;
return;
}
state.selectedCharacterId = state.selectedCampaign.characters.length > 0
? state.selectedCampaign.characters[0].id
: null;
}
function resolveSelectedCharacterId(): string | null {
if (!state.selectedCampaign) {
return null;
}
syncSelectedCharacter();
return state.selectedCharacterId;
function setStatus(message: string, isError: boolean): void {
setMessage(elements.messageElement, message, isError);
}

View File

@@ -0,0 +1,24 @@
export async function runAction(
action: () => Promise<void>,
onError: (message: string) => void
): Promise<void> {
try {
await action();
}
catch (error: unknown) {
onError(formatError(error));
}
}
export function setMessage(messageElement: HTMLElement, message: string, isError: boolean): void {
messageElement.textContent = message;
messageElement.style.color = isError ? "#b91c1c" : "#1d4ed8";
}
function formatError(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return String(error);
}

View File

@@ -0,0 +1,112 @@
export type AppElements = {
healthElement: HTMLElement;
messageElement: HTMLElement;
campaignMetaElement: HTMLElement;
campaignDetailsElement: HTMLElement;
rollResultElement: HTMLElement;
campaignLogElement: HTMLElement;
registerForm: HTMLFormElement;
loginForm: HTMLFormElement;
logoutButton: HTMLButtonElement;
registerUsername: HTMLInputElement;
registerDisplayName: HTMLInputElement;
registerPassword: HTMLInputElement;
loginUsername: HTMLInputElement;
loginPassword: HTMLInputElement;
campaignForm: HTMLFormElement;
campaignNameInput: HTMLInputElement;
campaignRulesetSelect: HTMLSelectElement;
campaignSelect: HTMLSelectElement;
refreshCampaignButton: HTMLButtonElement;
characterForm: HTMLFormElement;
characterNameInput: HTMLInputElement;
characterSelect: HTMLSelectElement;
activateCharacterButton: HTMLButtonElement;
skillForm: HTMLFormElement;
skillNameInput: HTMLInputElement;
skillExpressionInput: HTMLInputElement;
updateSkillButton: HTMLButtonElement;
skillSelect: HTMLSelectElement;
rollForm: HTMLFormElement;
rollVisibilitySelect: HTMLSelectElement;
};
export function getAppElements(): AppElements {
return {
healthElement: mustElement("health"),
messageElement: mustElement("message"),
campaignMetaElement: mustElement("campaign-meta"),
campaignDetailsElement: mustElement("campaign-details"),
rollResultElement: mustElement("roll-result"),
campaignLogElement: mustElement("campaign-log"),
registerForm: mustForm("register-form"),
loginForm: mustForm("login-form"),
logoutButton: mustButton("logout-button"),
registerUsername: mustInput("register-username"),
registerDisplayName: mustInput("register-display-name"),
registerPassword: mustInput("register-password"),
loginUsername: mustInput("login-username"),
loginPassword: mustInput("login-password"),
campaignForm: mustForm("campaign-form"),
campaignNameInput: mustInput("campaign-name"),
campaignRulesetSelect: mustSelect("campaign-ruleset"),
campaignSelect: mustSelect("campaign-select"),
refreshCampaignButton: mustButton("refresh-campaign-button"),
characterForm: mustForm("character-form"),
characterNameInput: mustInput("character-name"),
characterSelect: mustSelect("character-select"),
activateCharacterButton: mustButton("activate-character-button"),
skillForm: mustForm("skill-form"),
skillNameInput: mustInput("skill-name"),
skillExpressionInput: mustInput("skill-expression"),
updateSkillButton: mustButton("update-skill-button"),
skillSelect: mustSelect("skill-select"),
rollForm: mustForm("roll-form"),
rollVisibilitySelect: mustSelect("roll-visibility")
};
}
function mustElement(id: string): HTMLElement {
const element = document.getElementById(id);
if (!(element instanceof HTMLElement)) {
throw new Error(`Missing HTMLElement: ${id}`);
}
return element;
}
function mustInput(id: string): HTMLInputElement {
const element = document.getElementById(id);
if (!(element instanceof HTMLInputElement)) {
throw new Error(`Missing HTMLInputElement: ${id}`);
}
return element;
}
function mustSelect(id: string): HTMLSelectElement {
const element = document.getElementById(id);
if (!(element instanceof HTMLSelectElement)) {
throw new Error(`Missing HTMLSelectElement: ${id}`);
}
return element;
}
function mustForm(id: string): HTMLFormElement {
const element = document.getElementById(id);
if (!(element instanceof HTMLFormElement)) {
throw new Error(`Missing HTMLFormElement: ${id}`);
}
return element;
}
function mustButton(id: string): HTMLButtonElement {
const element = document.getElementById(id);
if (!(element instanceof HTMLButtonElement)) {
throw new Error(`Missing HTMLButtonElement: ${id}`);
}
return element;
}

View File

@@ -0,0 +1,30 @@
import type { AppState } from "./types.js";
export function connectStateEvents(state: AppState, onStateChanged: () => void): void {
if (!state.selectedCampaignId || !state.user) {
closeStateEvents(state);
return;
}
if (state.eventSource && state.eventSource.url.endsWith(`campaignId=${state.selectedCampaignId}`)) {
return;
}
closeStateEvents(state);
state.eventSource = new EventSource(`/api/events/state?campaignId=${state.selectedCampaignId}`);
state.eventSource.addEventListener("state", () => {
onStateChanged();
});
state.eventSource.onerror = () => {
closeStateEvents(state);
};
}
export function closeStateEvents(state: AppState): void {
if (state.eventSource) {
state.eventSource.close();
state.eventSource = null;
}
}

View File

@@ -0,0 +1,74 @@
import { getCampaign, getCampaignLog, getCampaigns, getHealth, getMe, getRulesets } from "../generated/api-client.js";
import type { AppElements } from "./dom.js";
import { syncSelectedCharacter } from "./state.js";
import type { AppState } from "./types.js";
export async function refreshHealth(elements: AppElements): Promise<void> {
const health = await getHealth();
elements.healthElement.textContent = `API status: ${health.status}`;
}
export async function ensureRulesets(state: AppState, elements: AppElements): Promise<void> {
if (state.rulesets.length > 0) {
return;
}
state.rulesets = await getRulesets();
elements.campaignRulesetSelect.innerHTML = state.rulesets
.map((ruleset) => `<option value="${ruleset.id}">${ruleset.name}</option>`)
.join("");
}
export async function reloadSession(state: AppState): Promise<void> {
try {
const me = await getMe();
state.user = me.user;
state.activeCharacterId = me.activeCharacterId ?? null;
state.selectedCampaignId = me.currentCampaignId ?? state.selectedCampaignId;
}
catch {
state.user = null;
state.activeCharacterId = null;
}
}
export async function reloadCampaigns(state: AppState, preferredCampaignId: string | null): Promise<void> {
state.campaigns = await getCampaigns();
if (state.campaigns.length === 0) {
state.selectedCampaignId = null;
return;
}
const availableIds = new Set(state.campaigns.map((campaign) => campaign.id));
if (preferredCampaignId && availableIds.has(preferredCampaignId)) {
state.selectedCampaignId = preferredCampaignId;
return;
}
if (state.selectedCampaignId && availableIds.has(state.selectedCampaignId)) {
return;
}
state.selectedCampaignId = state.campaigns[0].id;
}
export async function reloadSelectedCampaign(state: AppState): Promise<void> {
if (!state.selectedCampaignId) {
state.selectedCampaign = null;
return;
}
state.selectedCampaign = await getCampaign(state.selectedCampaignId);
syncSelectedCharacter(state);
}
export async function reloadCampaignLog(state: AppState): Promise<void> {
if (!state.selectedCampaignId) {
state.campaignLog = [];
return;
}
state.campaignLog = await getCampaignLog(state.selectedCampaignId);
}

View File

@@ -0,0 +1,117 @@
import type { AppElements } from "./dom.js";
import { resolveSelectedCharacterId, selectedSkillFromCampaign } from "./state.js";
import type { AppState } from "./types.js";
export function renderAll(state: AppState, elements: AppElements): void {
renderCampaignSelect(state, elements);
renderCampaignMeta(state, elements);
renderCampaignDetails(state, elements);
renderCharacterSelect(state, elements);
renderSkillSelect(state, elements);
renderCampaignLog(state, elements);
}
export function renderCampaignMeta(state: AppState, elements: AppElements): void {
if (!state.user) {
elements.campaignMetaElement.textContent = "Log in to manage campaigns.";
return;
}
if (!state.selectedCampaign) {
elements.campaignMetaElement.textContent = "No campaign selected.";
return;
}
const isGm = state.selectedCampaign.gm.id === state.user.id;
const activeCharacter = state.selectedCampaign.characters.find((character) => character.id === state.activeCharacterId);
const activeLabel = activeCharacter ? ` | Active: ${activeCharacter.name}` : "";
elements.campaignMetaElement.textContent = `Selected: ${state.selectedCampaign.name} (${state.selectedCampaign.rulesetId}) - ${isGm ? "You are GM" : "Player context"}${activeLabel}`;
}
export function renderCampaignDetails(state: AppState, elements: AppElements): void {
if (!state.selectedCampaign) {
elements.campaignDetailsElement.textContent = "No details available.";
return;
}
const characters = state.selectedCampaign.characters
.map((character) => `<li>${character.name} (${character.id})</li>`)
.join("");
const skills = state.selectedCampaign.skills
.map((skill) => `<li>${skill.name} [${skill.diceRollDefinition}]</li>`)
.join("");
elements.campaignDetailsElement.innerHTML = `
<p>GM: ${state.selectedCampaign.gm.displayName}</p>
<p>Characters visible to you: ${state.selectedCampaign.characters.length}</p>
<ul>${characters}</ul>
<p>Skills visible to you: ${state.selectedCampaign.skills.length}</p>
<ul>${skills}</ul>
`;
}
export function renderCharacterSelect(state: AppState, elements: AppElements): void {
if (!state.selectedCampaign) {
elements.characterSelect.innerHTML = "";
state.selectedCharacterId = null;
return;
}
const selectedCharacterId = resolveSelectedCharacterId(state);
const options = state.selectedCampaign.characters
.map((character) => {
const selected = character.id === selectedCharacterId ? " selected" : "";
return `<option value="${character.id}"${selected}>${character.name}</option>`;
})
.join("");
elements.characterSelect.innerHTML = options;
state.selectedCharacterId = selectedCharacterId;
}
export function renderSkillSelect(state: AppState, elements: AppElements): void {
if (!state.selectedCampaign) {
elements.skillSelect.innerHTML = "";
return;
}
const selectedCharacterId = resolveSelectedCharacterId(state);
const characterSkills = state.selectedCampaign.skills.filter((skill) => skill.characterId === selectedCharacterId);
const options = characterSkills
.map((skill) => `<option value="${skill.id}">${skill.name} (${skill.diceRollDefinition})</option>`)
.join("");
elements.skillSelect.innerHTML = options;
const selectedSkill = selectedSkillFromCampaign(state, elements.skillSelect.value);
if (selectedSkill) {
elements.skillNameInput.value = selectedSkill.name;
elements.skillExpressionInput.value = selectedSkill.diceRollDefinition;
}
}
export function renderCampaignLog(state: AppState, elements: AppElements): void {
if (state.campaignLog.length === 0) {
elements.campaignLogElement.innerHTML = "<li class=\"log-item\">No rolls yet.</li>";
return;
}
elements.campaignLogElement.innerHTML = state.campaignLog
.map((entry) => `
<li class="log-item">
<strong>${entry.visibility.toUpperCase()}</strong>
<span> ${entry.breakdown}</span>
<small> ${new Date(entry.timestampUtc).toLocaleString()}</small>
</li>
`)
.join("");
}
function renderCampaignSelect(state: AppState, elements: AppElements): void {
elements.campaignSelect.innerHTML = state.campaigns
.map((campaign) => {
const selected = campaign.id === state.selectedCampaignId ? " selected" : "";
return `<option value="${campaign.id}"${selected}>${campaign.name} (${campaign.rulesetId})</option>`;
})
.join("");
}

View File

@@ -0,0 +1,71 @@
import type { SkillSummary } from "../generated/api-client.js";
import type { AppState } from "./types.js";
export function createInitialState(): AppState {
return {
user: null,
activeCharacterId: null,
selectedCharacterId: null,
campaigns: [],
selectedCampaignId: null,
selectedCampaign: null,
campaignLog: [],
rulesets: [],
eventSource: null
};
}
export function resetAuthenticatedState(state: AppState): void {
state.activeCharacterId = null;
state.selectedCharacterId = null;
state.campaigns = [];
state.selectedCampaignId = null;
state.selectedCampaign = null;
state.campaignLog = [];
}
export function resetStateAfterLogout(state: AppState): void {
state.user = null;
resetAuthenticatedState(state);
}
export function syncSelectedCharacter(state: AppState): void {
if (!state.selectedCampaign) {
state.selectedCharacterId = null;
return;
}
const availableIds = new Set(state.selectedCampaign.characters.map((character) => character.id));
if (state.selectedCharacterId && availableIds.has(state.selectedCharacterId)) {
return;
}
if (state.activeCharacterId && availableIds.has(state.activeCharacterId)) {
state.selectedCharacterId = state.activeCharacterId;
return;
}
state.selectedCharacterId = state.selectedCampaign.characters.length > 0
? state.selectedCampaign.characters[0].id
: null;
}
export function resolveSelectedCharacterId(state: AppState): string | null {
if (!state.selectedCampaign) {
return null;
}
syncSelectedCharacter(state);
return state.selectedCharacterId;
}
export function selectedSkillFromCampaign(state: AppState, selectedSkillId: string): SkillSummary | null {
if (!state.selectedCampaign) {
return null;
}
const selectedCharacterId = resolveSelectedCharacterId(state);
return state.selectedCampaign.skills
.filter((skill) => skill.characterId === selectedCharacterId)
.find((skill) => skill.id === selectedSkillId) ?? null;
}

View File

@@ -0,0 +1,19 @@
import type {
CampaignDetails,
CampaignLogEntry,
CampaignSummary,
RulesetDefinition,
UserSummary
} from "../generated/api-client.js";
export type AppState = {
user: UserSummary | null;
activeCharacterId: string | null;
selectedCharacterId: string | null;
campaigns: CampaignSummary[];
selectedCampaignId: string | null;
selectedCampaign: CampaignDetails | null;
campaignLog: CampaignLogEntry[];
rulesets: RulesetDefinition[];
eventSource: EventSource | null;
};

View File

@@ -1,133 +1,107 @@
import { activateCharacter, createCampaign, createCharacter, createSkill, getCampaign, getCampaignLog, getCampaigns, getHealth, getMe, getRulesets, loginUser, logoutUser, registerUser, rollSkill, updateSkill } from "./generated/api-client.js";
const healthElement = mustElement("health");
const messageElement = mustElement("message");
const campaignMetaElement = mustElement("campaign-meta");
const campaignDetailsElement = mustElement("campaign-details");
const rollResultElement = mustElement("roll-result");
const campaignLogElement = mustElement("campaign-log");
const registerForm = mustForm("register-form");
const loginForm = mustForm("login-form");
const logoutButton = mustButton("logout-button");
const registerUsername = mustInput("register-username");
const registerDisplayName = mustInput("register-display-name");
const registerPassword = mustInput("register-password");
const loginUsername = mustInput("login-username");
const loginPassword = mustInput("login-password");
const campaignForm = mustForm("campaign-form");
const campaignNameInput = mustInput("campaign-name");
const campaignRulesetSelect = mustSelect("campaign-ruleset");
const campaignSelect = mustSelect("campaign-select");
const refreshCampaignButton = mustButton("refresh-campaign-button");
const characterForm = mustForm("character-form");
const characterNameInput = mustInput("character-name");
const characterSelect = mustSelect("character-select");
const activateCharacterButton = mustButton("activate-character-button");
const skillForm = mustForm("skill-form");
const skillNameInput = mustInput("skill-name");
const skillExpressionInput = mustInput("skill-expression");
const updateSkillButton = mustButton("update-skill-button");
const skillSelect = mustSelect("skill-select");
const rollForm = mustForm("roll-form");
const rollVisibilitySelect = mustSelect("roll-visibility");
const state = {
user: null,
activeCharacterId: null,
selectedCharacterId: null,
campaigns: [],
selectedCampaignId: null,
selectedCampaign: null,
campaignLog: [],
rulesets: [],
eventSource: null
};
registerForm.addEventListener("submit", async (event) => {
import { activateCharacter, createCampaign, createCharacter, createSkill, loginUser, logoutUser, registerUser, rollSkill, updateSkill } from "./generated/api-client.js";
import { runAction, setMessage } from "./app/actions.js";
import { getAppElements } from "./app/dom.js";
import { closeStateEvents, connectStateEvents } from "./app/events.js";
import { ensureRulesets, refreshHealth, reloadCampaignLog, reloadCampaigns, reloadSelectedCampaign, reloadSession } from "./app/loaders.js";
import { renderAll, renderCampaignDetails, renderCampaignLog, renderCampaignMeta, renderCharacterSelect, renderSkillSelect } from "./app/render.js";
import { createInitialState, resetAuthenticatedState, resetStateAfterLogout, syncSelectedCharacter } from "./app/state.js";
const elements = getAppElements();
const state = createInitialState();
elements.registerForm.addEventListener("submit", async (event) => {
event.preventDefault();
await runAction(async () => {
await runAppAction(async () => {
await registerUser({
username: registerUsername.value.trim(),
displayName: registerDisplayName.value.trim(),
password: registerPassword.value
username: elements.registerUsername.value.trim(),
displayName: elements.registerDisplayName.value.trim(),
password: elements.registerPassword.value
});
registerPassword.value = "";
setMessage("Registration successful. You can log in now.", false);
elements.registerPassword.value = "";
setStatus("Registration successful. You can log in now.", false);
});
});
loginForm.addEventListener("submit", async (event) => {
elements.loginForm.addEventListener("submit", async (event) => {
event.preventDefault();
await runAction(async () => {
await runAppAction(async () => {
await loginUser({
username: loginUsername.value.trim(),
password: loginPassword.value
username: elements.loginUsername.value.trim(),
password: elements.loginPassword.value
});
loginPassword.value = "";
elements.loginPassword.value = "";
await reloadAll();
setMessage("Logged in.", false);
setStatus("Logged in.", false);
});
});
logoutButton.addEventListener("click", async () => {
await runAction(async () => {
elements.logoutButton.addEventListener("click", async () => {
await runAppAction(async () => {
await logoutUser();
resetStateAfterLogout();
renderAll();
setMessage("Logged out.", false);
resetStateAfterLogout(state);
closeStateEvents(state);
renderAll(state, elements);
setStatus("Logged out.", false);
});
});
campaignForm.addEventListener("submit", async (event) => {
elements.campaignForm.addEventListener("submit", async (event) => {
event.preventDefault();
await runAction(async () => {
await runAppAction(async () => {
const createdCampaign = await createCampaign({
name: campaignNameInput.value.trim(),
rulesetId: campaignRulesetSelect.value
name: elements.campaignNameInput.value.trim(),
rulesetId: elements.campaignRulesetSelect.value
});
campaignNameInput.value = "";
await reloadCampaigns(createdCampaign.id);
setMessage("Campaign created.", false);
elements.campaignNameInput.value = "";
await reloadCampaigns(state, createdCampaign.id);
await reloadSelectedCampaign(state);
await reloadCampaignLog(state);
syncEventStream();
renderAll(state, elements);
setStatus("Campaign created.", false);
});
});
campaignSelect.addEventListener("change", async () => {
await runAction(async () => {
const selected = campaignSelect.value;
elements.campaignSelect.addEventListener("change", async () => {
await runAppAction(async () => {
const selected = elements.campaignSelect.value;
state.selectedCampaignId = selected.length > 0 ? selected : null;
await reloadSelectedCampaign();
syncSelectedCharacter();
renderCharacterSelect();
renderSkillSelect();
connectStateEvents();
renderCampaignMeta();
renderCampaignDetails();
renderCampaignLog();
await reloadSelectedCampaign(state);
syncSelectedCharacter(state);
renderCharacterSelect(state, elements);
renderSkillSelect(state, elements);
syncEventStream();
renderCampaignMeta(state, elements);
renderCampaignDetails(state, elements);
renderCampaignLog(state, elements);
});
});
characterSelect.addEventListener("change", () => {
state.selectedCharacterId = characterSelect.value.length > 0 ? characterSelect.value : null;
renderSkillSelect();
elements.characterSelect.addEventListener("change", () => {
state.selectedCharacterId = elements.characterSelect.value.length > 0 ? elements.characterSelect.value : null;
renderSkillSelect(state, elements);
});
refreshCampaignButton.addEventListener("click", async () => {
await runAction(async () => {
await reloadSelectedCampaign();
renderAll();
setMessage("Campaign refreshed.", false);
elements.refreshCampaignButton.addEventListener("click", async () => {
await runAppAction(async () => {
await reloadSelectedCampaign(state);
await reloadCampaignLog(state);
renderAll(state, elements);
setStatus("Campaign refreshed.", false);
});
});
characterForm.addEventListener("submit", async (event) => {
elements.characterForm.addEventListener("submit", async (event) => {
event.preventDefault();
await runAction(async () => {
await runAppAction(async () => {
if (!state.selectedCampaignId) {
throw new Error("Select a campaign first.");
}
await createCharacter({
name: characterNameInput.value.trim(),
name: elements.characterNameInput.value.trim(),
campaignId: state.selectedCampaignId
});
characterNameInput.value = "";
await reloadSelectedCampaign();
await reloadCampaignLog();
setMessage("Character created.", false);
elements.characterNameInput.value = "";
await reloadSelectedCampaign(state);
await reloadCampaignLog(state);
renderAll(state, elements);
setStatus("Character created.", false);
});
});
activateCharacterButton.addEventListener("click", async () => {
await runAction(async () => {
const characterId = characterSelect.value;
elements.activateCharacterButton.addEventListener("click", async () => {
await runAppAction(async () => {
const characterId = elements.characterSelect.value;
if (characterId.length === 0) {
throw new Error("Select a character to activate.");
}
@@ -135,349 +109,90 @@ activateCharacterButton.addEventListener("click", async () => {
state.activeCharacterId = characterId;
state.selectedCharacterId = characterId;
await reloadAll();
setMessage("Active character updated.", false);
setStatus("Active character updated.", false);
});
});
skillForm.addEventListener("submit", async (event) => {
elements.skillForm.addEventListener("submit", async (event) => {
event.preventDefault();
await runAction(async () => {
const characterId = characterSelect.value;
await runAppAction(async () => {
const characterId = elements.characterSelect.value;
if (characterId.length === 0) {
throw new Error("Select a character first.");
}
await createSkill(characterId, {
name: skillNameInput.value.trim(),
diceRollDefinition: skillExpressionInput.value.trim()
name: elements.skillNameInput.value.trim(),
diceRollDefinition: elements.skillExpressionInput.value.trim()
});
skillNameInput.value = "";
skillExpressionInput.value = "";
await reloadSelectedCampaign();
setMessage("Skill created.", false);
elements.skillNameInput.value = "";
elements.skillExpressionInput.value = "";
await reloadSelectedCampaign(state);
renderAll(state, elements);
setStatus("Skill created.", false);
});
});
updateSkillButton.addEventListener("click", async () => {
await runAction(async () => {
const selectedSkillId = skillSelect.value;
elements.updateSkillButton.addEventListener("click", async () => {
await runAppAction(async () => {
const selectedSkillId = elements.skillSelect.value;
if (selectedSkillId.length === 0) {
throw new Error("Select a skill to update.");
}
await updateSkill(selectedSkillId, {
name: skillNameInput.value.trim(),
diceRollDefinition: skillExpressionInput.value.trim()
name: elements.skillNameInput.value.trim(),
diceRollDefinition: elements.skillExpressionInput.value.trim()
});
await reloadSelectedCampaign();
setMessage("Skill updated.", false);
await reloadSelectedCampaign(state);
renderAll(state, elements);
setStatus("Skill updated.", false);
});
});
rollForm.addEventListener("submit", async (event) => {
elements.rollForm.addEventListener("submit", async (event) => {
event.preventDefault();
await runAction(async () => {
const selectedSkillId = skillSelect.value;
await runAppAction(async () => {
const selectedSkillId = elements.skillSelect.value;
if (selectedSkillId.length === 0) {
throw new Error("Select a skill to roll.");
}
const roll = await rollSkill(selectedSkillId, { visibility: rollVisibilitySelect.value });
rollResultElement.textContent = `Roll ${roll.rollId}: ${roll.breakdown} (${roll.visibility})`;
await reloadCampaignLog();
setMessage("Roll recorded.", false);
const roll = await rollSkill(selectedSkillId, { visibility: elements.rollVisibilitySelect.value });
elements.rollResultElement.textContent = `Roll ${roll.rollId}: ${roll.breakdown} (${roll.visibility})`;
await reloadCampaignLog(state);
renderCampaignLog(state, elements);
setStatus("Roll recorded.", false);
});
});
await runAction(async () => {
await refreshHealth();
await runAppAction(async () => {
await refreshHealth(elements);
await reloadAll();
setMessage("Ready.", false);
setStatus("Ready.", false);
});
async function refreshHealth() {
const health = await getHealth();
healthElement.textContent = `API status: ${health.status}`;
}
async function reloadAll() {
await ensureRulesets();
await reloadSession();
await ensureRulesets(state, elements);
await reloadSession(state);
if (state.user) {
await reloadCampaigns(state.selectedCampaignId);
await reloadSelectedCampaign();
await reloadCampaignLog();
connectStateEvents();
await reloadCampaigns(state, state.selectedCampaignId);
await reloadSelectedCampaign(state);
await reloadCampaignLog(state);
syncEventStream();
}
else {
resetAuthenticatedState();
resetAuthenticatedState(state);
closeStateEvents(state);
}
renderAll();
renderAll(state, elements);
}
async function reloadSession() {
try {
const me = await getMe();
state.user = me.user;
state.activeCharacterId = me.activeCharacterId ?? null;
state.selectedCampaignId = me.currentCampaignId ?? state.selectedCampaignId;
}
catch {
state.user = null;
state.activeCharacterId = null;
}
}
async function ensureRulesets() {
if (state.rulesets.length > 0) {
return;
}
state.rulesets = await getRulesets();
campaignRulesetSelect.innerHTML = state.rulesets
.map((ruleset) => `<option value="${ruleset.id}">${ruleset.name}</option>`)
.join("");
}
async function reloadCampaigns(preferredCampaignId) {
state.campaigns = await getCampaigns();
if (state.campaigns.length === 0) {
state.selectedCampaignId = null;
return;
}
const availableIds = new Set(state.campaigns.map((campaign) => campaign.id));
if (preferredCampaignId && availableIds.has(preferredCampaignId)) {
state.selectedCampaignId = preferredCampaignId;
return;
}
if (state.selectedCampaignId && availableIds.has(state.selectedCampaignId)) {
return;
}
state.selectedCampaignId = state.campaigns[0].id;
}
async function reloadSelectedCampaign() {
if (!state.selectedCampaignId) {
state.selectedCampaign = null;
return;
}
state.selectedCampaign = await getCampaign(state.selectedCampaignId);
syncSelectedCharacter();
}
async function reloadCampaignLog() {
if (!state.selectedCampaignId) {
state.campaignLog = [];
return;
}
state.campaignLog = await getCampaignLog(state.selectedCampaignId);
}
function connectStateEvents() {
if (!state.selectedCampaignId || !state.user) {
closeStateEvents();
return;
}
if (state.eventSource && state.eventSource.url.endsWith(`campaignId=${state.selectedCampaignId}`)) {
return;
}
closeStateEvents();
state.eventSource = new EventSource(`/api/events/state?campaignId=${state.selectedCampaignId}`);
state.eventSource.addEventListener("state", () => {
void runAction(async () => {
await reloadSelectedCampaign();
await reloadCampaignLog();
renderAll();
function syncEventStream() {
connectStateEvents(state, () => {
void runAppAction(async () => {
await reloadSelectedCampaign(state);
await reloadCampaignLog(state);
renderAll(state, elements);
});
});
state.eventSource.onerror = () => {
closeStateEvents();
};
}
function closeStateEvents() {
if (state.eventSource) {
state.eventSource.close();
state.eventSource = null;
}
async function runAppAction(action) {
await runAction(action, (message) => {
setStatus(message, true);
});
}
function resetStateAfterLogout() {
state.user = null;
resetAuthenticatedState();
closeStateEvents();
}
function resetAuthenticatedState() {
state.activeCharacterId = null;
state.selectedCharacterId = null;
state.campaigns = [];
state.selectedCampaignId = null;
state.selectedCampaign = null;
state.campaignLog = [];
}
function renderAll() {
renderCampaignSelect();
renderCampaignMeta();
renderCampaignDetails();
renderCharacterSelect();
renderSkillSelect();
renderCampaignLog();
}
function renderCampaignSelect() {
campaignSelect.innerHTML = state.campaigns
.map((campaign) => {
const selected = campaign.id === state.selectedCampaignId ? " selected" : "";
return `<option value="${campaign.id}"${selected}>${campaign.name} (${campaign.rulesetId})</option>`;
})
.join("");
}
function renderCampaignMeta() {
if (!state.user) {
campaignMetaElement.textContent = "Log in to manage campaigns.";
return;
}
if (!state.selectedCampaign) {
campaignMetaElement.textContent = "No campaign selected.";
return;
}
const isGm = state.selectedCampaign.gm.id === state.user.id;
const activeCharacter = state.selectedCampaign.characters.find((character) => character.id === state.activeCharacterId);
const activeLabel = activeCharacter ? ` | Active: ${activeCharacter.name}` : "";
campaignMetaElement.textContent = `Selected: ${state.selectedCampaign.name} (${state.selectedCampaign.rulesetId}) - ${isGm ? "You are GM" : "Player context"}${activeLabel}`;
}
function renderCampaignDetails() {
if (!state.selectedCampaign) {
campaignDetailsElement.textContent = "No details available.";
return;
}
const characters = state.selectedCampaign.characters
.map((character) => `<li>${character.name} (${character.id})</li>`)
.join("");
const skills = state.selectedCampaign.skills
.map((skill) => `<li>${skill.name} [${skill.diceRollDefinition}]</li>`)
.join("");
campaignDetailsElement.innerHTML = `
<p>GM: ${state.selectedCampaign.gm.displayName}</p>
<p>Characters visible to you: ${state.selectedCampaign.characters.length}</p>
<ul>${characters}</ul>
<p>Skills visible to you: ${state.selectedCampaign.skills.length}</p>
<ul>${skills}</ul>
`;
}
function renderCharacterSelect() {
if (!state.selectedCampaign) {
characterSelect.innerHTML = "";
state.selectedCharacterId = null;
return;
}
const selectedCharacterId = resolveSelectedCharacterId();
const options = state.selectedCampaign.characters
.map((character) => {
const selected = character.id === selectedCharacterId ? " selected" : "";
return `<option value="${character.id}"${selected}>${character.name}</option>`;
})
.join("");
characterSelect.innerHTML = options;
state.selectedCharacterId = selectedCharacterId;
}
function renderSkillSelect() {
if (!state.selectedCampaign) {
skillSelect.innerHTML = "";
return;
}
const selectedCharacterId = resolveSelectedCharacterId();
const characterSkills = state.selectedCampaign.skills.filter((skill) => skill.characterId === selectedCharacterId);
const options = characterSkills
.map((skill) => `<option value="${skill.id}">${skill.name} (${skill.diceRollDefinition})</option>`)
.join("");
skillSelect.innerHTML = options;
const selectedSkill = selectedSkillFromCampaign();
if (selectedSkill) {
skillNameInput.value = selectedSkill.name;
skillExpressionInput.value = selectedSkill.diceRollDefinition;
}
}
function selectedSkillFromCampaign() {
if (!state.selectedCampaign) {
return null;
}
const selectedCharacterId = resolveSelectedCharacterId();
const selectedSkillId = skillSelect.value;
return state.selectedCampaign.skills
.filter((skill) => skill.characterId === selectedCharacterId)
.find((skill) => skill.id === selectedSkillId) ?? null;
}
function renderCampaignLog() {
if (state.campaignLog.length === 0) {
campaignLogElement.innerHTML = "<li class=\"log-item\">No rolls yet.</li>";
return;
}
campaignLogElement.innerHTML = state.campaignLog
.map((entry) => `
<li class="log-item">
<strong>${entry.visibility.toUpperCase()}</strong>
<span> ${entry.breakdown}</span>
<small> ${new Date(entry.timestampUtc).toLocaleString()}</small>
</li>
`)
.join("");
}
async function runAction(action) {
try {
await action();
}
catch (error) {
setMessage(formatError(error), true);
}
}
function setMessage(message, isError) {
messageElement.textContent = message;
messageElement.style.color = isError ? "#b91c1c" : "#1d4ed8";
}
function formatError(error) {
if (error instanceof Error) {
return error.message;
}
return String(error);
}
function mustElement(id) {
const element = document.getElementById(id);
if (!(element instanceof HTMLElement)) {
throw new Error(`Missing HTMLElement: ${id}`);
}
return element;
}
function mustInput(id) {
const element = document.getElementById(id);
if (!(element instanceof HTMLInputElement)) {
throw new Error(`Missing HTMLInputElement: ${id}`);
}
return element;
}
function mustSelect(id) {
const element = document.getElementById(id);
if (!(element instanceof HTMLSelectElement)) {
throw new Error(`Missing HTMLSelectElement: ${id}`);
}
return element;
}
function mustForm(id) {
const element = document.getElementById(id);
if (!(element instanceof HTMLFormElement)) {
throw new Error(`Missing HTMLFormElement: ${id}`);
}
return element;
}
function mustButton(id) {
const element = document.getElementById(id);
if (!(element instanceof HTMLButtonElement)) {
throw new Error(`Missing HTMLButtonElement: ${id}`);
}
return element;
}
function syncSelectedCharacter() {
if (!state.selectedCampaign) {
state.selectedCharacterId = null;
return;
}
const availableIds = new Set(state.selectedCampaign.characters.map((character) => character.id));
if (state.selectedCharacterId && availableIds.has(state.selectedCharacterId)) {
return;
}
if (state.activeCharacterId && availableIds.has(state.activeCharacterId)) {
state.selectedCharacterId = state.activeCharacterId;
return;
}
state.selectedCharacterId = state.selectedCampaign.characters.length > 0
? state.selectedCampaign.characters[0].id
: null;
}
function resolveSelectedCharacterId() {
if (!state.selectedCampaign) {
return null;
}
syncSelectedCharacter();
return state.selectedCharacterId;
function setStatus(message, isError) {
setMessage(elements.messageElement, message, isError);
}

View File

@@ -0,0 +1,18 @@
export async function runAction(action, onError) {
try {
await action();
}
catch (error) {
onError(formatError(error));
}
}
export function setMessage(messageElement, message, isError) {
messageElement.textContent = message;
messageElement.style.color = isError ? "#b91c1c" : "#1d4ed8";
}
function formatError(error) {
if (error instanceof Error) {
return error.message;
}
return String(error);
}

View File

@@ -0,0 +1,69 @@
export function getAppElements() {
return {
healthElement: mustElement("health"),
messageElement: mustElement("message"),
campaignMetaElement: mustElement("campaign-meta"),
campaignDetailsElement: mustElement("campaign-details"),
rollResultElement: mustElement("roll-result"),
campaignLogElement: mustElement("campaign-log"),
registerForm: mustForm("register-form"),
loginForm: mustForm("login-form"),
logoutButton: mustButton("logout-button"),
registerUsername: mustInput("register-username"),
registerDisplayName: mustInput("register-display-name"),
registerPassword: mustInput("register-password"),
loginUsername: mustInput("login-username"),
loginPassword: mustInput("login-password"),
campaignForm: mustForm("campaign-form"),
campaignNameInput: mustInput("campaign-name"),
campaignRulesetSelect: mustSelect("campaign-ruleset"),
campaignSelect: mustSelect("campaign-select"),
refreshCampaignButton: mustButton("refresh-campaign-button"),
characterForm: mustForm("character-form"),
characterNameInput: mustInput("character-name"),
characterSelect: mustSelect("character-select"),
activateCharacterButton: mustButton("activate-character-button"),
skillForm: mustForm("skill-form"),
skillNameInput: mustInput("skill-name"),
skillExpressionInput: mustInput("skill-expression"),
updateSkillButton: mustButton("update-skill-button"),
skillSelect: mustSelect("skill-select"),
rollForm: mustForm("roll-form"),
rollVisibilitySelect: mustSelect("roll-visibility")
};
}
function mustElement(id) {
const element = document.getElementById(id);
if (!(element instanceof HTMLElement)) {
throw new Error(`Missing HTMLElement: ${id}`);
}
return element;
}
function mustInput(id) {
const element = document.getElementById(id);
if (!(element instanceof HTMLInputElement)) {
throw new Error(`Missing HTMLInputElement: ${id}`);
}
return element;
}
function mustSelect(id) {
const element = document.getElementById(id);
if (!(element instanceof HTMLSelectElement)) {
throw new Error(`Missing HTMLSelectElement: ${id}`);
}
return element;
}
function mustForm(id) {
const element = document.getElementById(id);
if (!(element instanceof HTMLFormElement)) {
throw new Error(`Missing HTMLFormElement: ${id}`);
}
return element;
}
function mustButton(id) {
const element = document.getElementById(id);
if (!(element instanceof HTMLButtonElement)) {
throw new Error(`Missing HTMLButtonElement: ${id}`);
}
return element;
}

View File

@@ -0,0 +1,23 @@
export function connectStateEvents(state, onStateChanged) {
if (!state.selectedCampaignId || !state.user) {
closeStateEvents(state);
return;
}
if (state.eventSource && state.eventSource.url.endsWith(`campaignId=${state.selectedCampaignId}`)) {
return;
}
closeStateEvents(state);
state.eventSource = new EventSource(`/api/events/state?campaignId=${state.selectedCampaignId}`);
state.eventSource.addEventListener("state", () => {
onStateChanged();
});
state.eventSource.onerror = () => {
closeStateEvents(state);
};
}
export function closeStateEvents(state) {
if (state.eventSource) {
state.eventSource.close();
state.eventSource = null;
}
}

View File

@@ -0,0 +1,58 @@
import { getCampaign, getCampaignLog, getCampaigns, getHealth, getMe, getRulesets } from "../generated/api-client.js";
import { syncSelectedCharacter } from "./state.js";
export async function refreshHealth(elements) {
const health = await getHealth();
elements.healthElement.textContent = `API status: ${health.status}`;
}
export async function ensureRulesets(state, elements) {
if (state.rulesets.length > 0) {
return;
}
state.rulesets = await getRulesets();
elements.campaignRulesetSelect.innerHTML = state.rulesets
.map((ruleset) => `<option value="${ruleset.id}">${ruleset.name}</option>`)
.join("");
}
export async function reloadSession(state) {
try {
const me = await getMe();
state.user = me.user;
state.activeCharacterId = me.activeCharacterId ?? null;
state.selectedCampaignId = me.currentCampaignId ?? state.selectedCampaignId;
}
catch {
state.user = null;
state.activeCharacterId = null;
}
}
export async function reloadCampaigns(state, preferredCampaignId) {
state.campaigns = await getCampaigns();
if (state.campaigns.length === 0) {
state.selectedCampaignId = null;
return;
}
const availableIds = new Set(state.campaigns.map((campaign) => campaign.id));
if (preferredCampaignId && availableIds.has(preferredCampaignId)) {
state.selectedCampaignId = preferredCampaignId;
return;
}
if (state.selectedCampaignId && availableIds.has(state.selectedCampaignId)) {
return;
}
state.selectedCampaignId = state.campaigns[0].id;
}
export async function reloadSelectedCampaign(state) {
if (!state.selectedCampaignId) {
state.selectedCampaign = null;
return;
}
state.selectedCampaign = await getCampaign(state.selectedCampaignId);
syncSelectedCharacter(state);
}
export async function reloadCampaignLog(state) {
if (!state.selectedCampaignId) {
state.campaignLog = [];
return;
}
state.campaignLog = await getCampaignLog(state.selectedCampaignId);
}

View File

@@ -0,0 +1,98 @@
import { resolveSelectedCharacterId, selectedSkillFromCampaign } from "./state.js";
export function renderAll(state, elements) {
renderCampaignSelect(state, elements);
renderCampaignMeta(state, elements);
renderCampaignDetails(state, elements);
renderCharacterSelect(state, elements);
renderSkillSelect(state, elements);
renderCampaignLog(state, elements);
}
export function renderCampaignMeta(state, elements) {
if (!state.user) {
elements.campaignMetaElement.textContent = "Log in to manage campaigns.";
return;
}
if (!state.selectedCampaign) {
elements.campaignMetaElement.textContent = "No campaign selected.";
return;
}
const isGm = state.selectedCampaign.gm.id === state.user.id;
const activeCharacter = state.selectedCampaign.characters.find((character) => character.id === state.activeCharacterId);
const activeLabel = activeCharacter ? ` | Active: ${activeCharacter.name}` : "";
elements.campaignMetaElement.textContent = `Selected: ${state.selectedCampaign.name} (${state.selectedCampaign.rulesetId}) - ${isGm ? "You are GM" : "Player context"}${activeLabel}`;
}
export function renderCampaignDetails(state, elements) {
if (!state.selectedCampaign) {
elements.campaignDetailsElement.textContent = "No details available.";
return;
}
const characters = state.selectedCampaign.characters
.map((character) => `<li>${character.name} (${character.id})</li>`)
.join("");
const skills = state.selectedCampaign.skills
.map((skill) => `<li>${skill.name} [${skill.diceRollDefinition}]</li>`)
.join("");
elements.campaignDetailsElement.innerHTML = `
<p>GM: ${state.selectedCampaign.gm.displayName}</p>
<p>Characters visible to you: ${state.selectedCampaign.characters.length}</p>
<ul>${characters}</ul>
<p>Skills visible to you: ${state.selectedCampaign.skills.length}</p>
<ul>${skills}</ul>
`;
}
export function renderCharacterSelect(state, elements) {
if (!state.selectedCampaign) {
elements.characterSelect.innerHTML = "";
state.selectedCharacterId = null;
return;
}
const selectedCharacterId = resolveSelectedCharacterId(state);
const options = state.selectedCampaign.characters
.map((character) => {
const selected = character.id === selectedCharacterId ? " selected" : "";
return `<option value="${character.id}"${selected}>${character.name}</option>`;
})
.join("");
elements.characterSelect.innerHTML = options;
state.selectedCharacterId = selectedCharacterId;
}
export function renderSkillSelect(state, elements) {
if (!state.selectedCampaign) {
elements.skillSelect.innerHTML = "";
return;
}
const selectedCharacterId = resolveSelectedCharacterId(state);
const characterSkills = state.selectedCampaign.skills.filter((skill) => skill.characterId === selectedCharacterId);
const options = characterSkills
.map((skill) => `<option value="${skill.id}">${skill.name} (${skill.diceRollDefinition})</option>`)
.join("");
elements.skillSelect.innerHTML = options;
const selectedSkill = selectedSkillFromCampaign(state, elements.skillSelect.value);
if (selectedSkill) {
elements.skillNameInput.value = selectedSkill.name;
elements.skillExpressionInput.value = selectedSkill.diceRollDefinition;
}
}
export function renderCampaignLog(state, elements) {
if (state.campaignLog.length === 0) {
elements.campaignLogElement.innerHTML = "<li class=\"log-item\">No rolls yet.</li>";
return;
}
elements.campaignLogElement.innerHTML = state.campaignLog
.map((entry) => `
<li class="log-item">
<strong>${entry.visibility.toUpperCase()}</strong>
<span> ${entry.breakdown}</span>
<small> ${new Date(entry.timestampUtc).toLocaleString()}</small>
</li>
`)
.join("");
}
function renderCampaignSelect(state, elements) {
elements.campaignSelect.innerHTML = state.campaigns
.map((campaign) => {
const selected = campaign.id === state.selectedCampaignId ? " selected" : "";
return `<option value="${campaign.id}"${selected}>${campaign.name} (${campaign.rulesetId})</option>`;
})
.join("");
}

View File

@@ -0,0 +1,58 @@
export function createInitialState() {
return {
user: null,
activeCharacterId: null,
selectedCharacterId: null,
campaigns: [],
selectedCampaignId: null,
selectedCampaign: null,
campaignLog: [],
rulesets: [],
eventSource: null
};
}
export function resetAuthenticatedState(state) {
state.activeCharacterId = null;
state.selectedCharacterId = null;
state.campaigns = [];
state.selectedCampaignId = null;
state.selectedCampaign = null;
state.campaignLog = [];
}
export function resetStateAfterLogout(state) {
state.user = null;
resetAuthenticatedState(state);
}
export function syncSelectedCharacter(state) {
if (!state.selectedCampaign) {
state.selectedCharacterId = null;
return;
}
const availableIds = new Set(state.selectedCampaign.characters.map((character) => character.id));
if (state.selectedCharacterId && availableIds.has(state.selectedCharacterId)) {
return;
}
if (state.activeCharacterId && availableIds.has(state.activeCharacterId)) {
state.selectedCharacterId = state.activeCharacterId;
return;
}
state.selectedCharacterId = state.selectedCampaign.characters.length > 0
? state.selectedCampaign.characters[0].id
: null;
}
export function resolveSelectedCharacterId(state) {
if (!state.selectedCampaign) {
return null;
}
syncSelectedCharacter(state);
return state.selectedCharacterId;
}
export function selectedSkillFromCampaign(state, selectedSkillId) {
if (!state.selectedCampaign) {
return null;
}
const selectedCharacterId = resolveSelectedCharacterId(state);
return state.selectedCampaign.skills
.filter((skill) => skill.characterId === selectedCharacterId)
.find((skill) => skill.id === selectedSkillId) ?? null;
}

View File

@@ -0,0 +1 @@
export {};

View File

@@ -5,12 +5,15 @@
- Root solution: `RpgRoller.sln`
- Backend/full-stack project: `RpgRoller` (Minimal API + static `wwwroot` frontend)
- Frontend source: `RpgRoller/frontend` (TypeScript)
- Frontend module split: `RpgRoller/frontend/app/*` (dom/state/loaders/render/events/actions)
- Test project: `RpgRoller.Tests` (xUnit + `WebApplicationFactory` integration tests)
- Persistence: EF Core + SQLite (`RpgRoller/Data/RpgRollerDbContext.cs`) with in-memory runtime cache in `GameService`
- OpenAPI source: `openapi/RpgRoller.json`
- Generated client source: `RpgRoller/frontend/generated/api-client.ts`
- Generated client output: `RpgRoller/wwwroot/generated/api-client.js`
- Local CI parity entrypoint: `scripts/ci-local.ps1`
- API endpoint modules: `RpgRoller/Api/*Endpoints.cs` + shared session/auth helpers
- Service boundary model: API request DTOs are mapped to `RpgRoller.Services/*Command` records before workflow execution
- Current backend features: auth/session, campaign/character/skill management, ruleset-aware rolls, filtered campaign logs, and SSE state updates.
- Current frontend features: authenticated campaign workspace with live log updates and full roll workflow controls.