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? ## 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. 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.Tests/`: xUnit integration-heavy test project
- `RpgRoller.sln`: solution used by local CI script - `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: Backend state persistence:
- EF Core with SQLite (`Microsoft.EntityFrameworkCore.Sqlite`) - EF Core with SQLite (`Microsoft.EntityFrameworkCore.Sqlite`)

View File

@@ -1,6 +1,5 @@
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using RpgRoller.Contracts;
using RpgRoller.Data; using RpgRoller.Data;
using RpgRoller.Domain; using RpgRoller.Domain;
using RpgRoller.Services; using RpgRoller.Services;
@@ -15,11 +14,11 @@ public sealed class GameServiceTests
using var harness = CreateHarness(); using var harness = CreateHarness();
var service = harness.Service; var service = harness.Service;
var invalidUsername = service.Register(new RegisterRequest("", "Password123", "Display")); var invalidUsername = service.Register(new RegisterCommand("", "Password123", "Display"));
var invalidDisplay = service.Register(new RegisterRequest("user", "Password123", "")); var invalidDisplay = service.Register(new RegisterCommand("user", "Password123", ""));
var invalidPassword = service.Register(new RegisterRequest("user", "short", "Display")); var invalidPassword = service.Register(new RegisterCommand("user", "short", "Display"));
var valid = service.Register(new RegisterRequest("user", "Password123", "Display")); var valid = service.Register(new RegisterCommand("user", "Password123", "Display"));
var duplicate = service.Register(new RegisterRequest("user", "Password123", "Display 2")); var duplicate = service.Register(new RegisterCommand("user", "Password123", "Display 2"));
Assert.False(invalidUsername.Succeeded); Assert.False(invalidUsername.Succeeded);
Assert.False(invalidDisplay.Succeeded); Assert.False(invalidDisplay.Succeeded);
@@ -33,11 +32,11 @@ public sealed class GameServiceTests
{ {
using var harness = CreateHarness(); using var harness = CreateHarness();
var service = harness.Service; 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 invalidUser = service.Login(new LoginCommand("missing", "Password123"));
var invalidPassword = service.Login(new LoginRequest("user", "bad-password")); var invalidPassword = service.Login(new LoginCommand("user", "bad-password"));
var valid = service.Login(new LoginRequest("user", "Password123")); var valid = service.Login(new LoginCommand("user", "Password123"));
Assert.False(invalidUser.Succeeded); Assert.False(invalidUser.Succeeded);
Assert.False(invalidPassword.Succeeded); Assert.False(invalidPassword.Succeeded);
@@ -57,8 +56,8 @@ public sealed class GameServiceTests
using var harness = CreateHarness(hasher); using var harness = CreateHarness(hasher);
var service = harness.Service; var service = harness.Service;
service.Register(new RegisterRequest("user", "Password123", "Display")); service.Register(new RegisterCommand("user", "Password123", "Display"));
var login = service.Login(new LoginRequest("user", "Password123")); var login = service.Login(new LoginCommand("user", "Password123"));
Assert.True(login.Succeeded); Assert.True(login.Succeeded);
Assert.Equal(2, hasher.HashCalls); Assert.Equal(2, hasher.HashCalls);
@@ -70,20 +69,20 @@ public sealed class GameServiceTests
using var harness = CreateHarness(); using var harness = CreateHarness();
var service = harness.Service; 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); Assert.False(unauthorizedCampaign.Succeeded);
service.Register(new RegisterRequest("gm", "Password123", "GM")); service.Register(new RegisterCommand("gm", "Password123", "GM"));
var gmSession = GetValue(service.Login(new LoginRequest("gm", "Password123"))).SessionToken; var gmSession = GetValue(service.Login(new LoginCommand("gm", "Password123"))).SessionToken;
var campaign = GetValue(service.CreateCampaign(gmSession, new CreateCampaignRequest("Name", "d6"))); 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); 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); 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()); var missingCharacterActivate = service.ActivateCharacter(gmSession, Guid.NewGuid());
Assert.False(missingCharacterActivate.Succeeded); Assert.False(missingCharacterActivate.Succeeded);
@@ -104,54 +103,54 @@ public sealed class GameServiceTests
{ {
using var harness = CreateHarness(3, 4, 5, 6); using var harness = CreateHarness(3, 4, 5, 6);
var service = harness.Service; var service = harness.Service;
service.Register(new RegisterRequest("gm", "Password123", "GM")); service.Register(new RegisterCommand("gm", "Password123", "GM"));
service.Register(new RegisterRequest("owner", "Password123", "Owner")); service.Register(new RegisterCommand("owner", "Password123", "Owner"));
service.Register(new RegisterRequest("other", "Password123", "Other")); service.Register(new RegisterCommand("other", "Password123", "Other"));
var gmSession = GetValue(service.Login(new LoginRequest("gm", "Password123"))).SessionToken; var gmSession = GetValue(service.Login(new LoginCommand("gm", "Password123"))).SessionToken;
var ownerSession = GetValue(service.Login(new LoginRequest("owner", "Password123"))).SessionToken; var ownerSession = GetValue(service.Login(new LoginCommand("owner", "Password123"))).SessionToken;
var otherSession = GetValue(service.Login(new LoginRequest("other", "Password123"))).SessionToken; var otherSession = GetValue(service.Login(new LoginCommand("other", "Password123"))).SessionToken;
var campaign = GetValue(service.CreateCampaign(gmSession, new CreateCampaignRequest("Main", "dnd5e"))); var campaign = GetValue(service.CreateCampaign(gmSession, new CreateCampaignCommand("Main", "dnd5e")));
var character = GetValue(service.CreateCharacter(ownerSession, new CreateCharacterRequest("Owner Char", campaign.Id))); 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); 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); 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); 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); 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); 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); 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); 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); 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); 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); 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); Assert.False(forbiddenRoll.Succeeded);
var privateRoll = service.RollSkill(ownerSession, skill.Id, new RollSkillRequest("private")); var privateRoll = service.RollSkill(ownerSession, skill.Id, new RollSkillCommand("private"));
var publicRoll = service.RollSkill(ownerSession, skill.Id, new RollSkillRequest("public")); var publicRoll = service.RollSkill(ownerSession, skill.Id, new RollSkillCommand("public"));
Assert.True(privateRoll.Succeeded); Assert.True(privateRoll.Succeeded);
Assert.True(publicRoll.Succeeded); Assert.True(publicRoll.Succeeded);
@@ -174,8 +173,8 @@ public sealed class GameServiceTests
{ {
using var harness = CreateHarness(); using var harness = CreateHarness();
var service = harness.Service; var service = harness.Service;
service.Register(new RegisterRequest("user", "Password123", "User")); service.Register(new RegisterCommand("user", "Password123", "User"));
var sessionToken = GetValue(service.Login(new LoginRequest("user", "Password123"))).SessionToken; var sessionToken = GetValue(service.Login(new LoginCommand("user", "Password123"))).SessionToken;
var result = service.GetCurrentCampaignCharacters(sessionToken); var result = service.GetCurrentCampaignCharacters(sessionToken);
Assert.False(result.Succeeded); Assert.False(result.Succeeded);
@@ -186,14 +185,14 @@ public sealed class GameServiceTests
{ {
using var harness = CreateHarness(); using var harness = CreateHarness();
var service = harness.Service; var service = harness.Service;
service.Register(new RegisterRequest("gm", "Password123", "GM")); service.Register(new RegisterCommand("gm", "Password123", "GM"));
service.Register(new RegisterRequest("player", "Password123", "Player")); service.Register(new RegisterCommand("player", "Password123", "Player"));
var gmSession = GetValue(service.Login(new LoginRequest("gm", "Password123"))).SessionToken; var gmSession = GetValue(service.Login(new LoginCommand("gm", "Password123"))).SessionToken;
var playerSession = GetValue(service.Login(new LoginRequest("player", "Password123"))).SessionToken; var playerSession = GetValue(service.Login(new LoginCommand("player", "Password123"))).SessionToken;
var gmCampaign = GetValue(service.CreateCampaign(gmSession, new CreateCampaignRequest("Owned", "d6"))); var gmCampaign = GetValue(service.CreateCampaign(gmSession, new CreateCampaignCommand("Owned", "d6")));
_ = GetValue(service.CreateCampaign(gmSession, new CreateCampaignRequest("Owned 2", "d6"))); _ = GetValue(service.CreateCampaign(gmSession, new CreateCampaignCommand("Owned 2", "d6")));
_ = service.CreateCharacter(playerSession, new CreateCharacterRequest("Joiner", gmCampaign.Id)); _ = service.CreateCharacter(playerSession, new CreateCharacterCommand("Joiner", gmCampaign.Id));
var playerCampaigns = service.GetCampaigns(playerSession); var playerCampaigns = service.GetCampaigns(playerSession);
Assert.True(playerCampaigns.Succeeded); Assert.True(playerCampaigns.Succeeded);
@@ -208,35 +207,35 @@ public sealed class GameServiceTests
using var harness = CreateHarness(2, 3, 4); using var harness = CreateHarness(2, 3, 4);
var service = harness.Service; var service = harness.Service;
var invalidCredentials = service.Login(new LoginRequest("", "")); var invalidCredentials = service.Login(new LoginCommand("", ""));
Assert.False(invalidCredentials.Succeeded); Assert.False(invalidCredentials.Succeeded);
service.Logout("missing-session"); service.Logout("missing-session");
service.Register(new RegisterRequest("gm", "Password123", "GM")); service.Register(new RegisterCommand("gm", "Password123", "GM"));
service.Register(new RegisterRequest("owner", "Password123", "Owner")); service.Register(new RegisterCommand("owner", "Password123", "Owner"));
service.Register(new RegisterRequest("other", "Password123", "Other")); service.Register(new RegisterCommand("other", "Password123", "Other"));
var gmSession = GetValue(service.Login(new LoginRequest("gm", "Password123"))).SessionToken; var gmSession = GetValue(service.Login(new LoginCommand("gm", "Password123"))).SessionToken;
var ownerSession = GetValue(service.Login(new LoginRequest("owner", "Password123"))).SessionToken; var ownerSession = GetValue(service.Login(new LoginCommand("owner", "Password123"))).SessionToken;
var otherSession = GetValue(service.Login(new LoginRequest("other", "Password123"))).SessionToken; var otherSession = GetValue(service.Login(new LoginCommand("other", "Password123"))).SessionToken;
var campaign = GetValue(service.CreateCampaign(gmSession, new CreateCampaignRequest("Main", "d6"))); var campaign = GetValue(service.CreateCampaign(gmSession, new CreateCampaignCommand("Main", "d6")));
var ownerCharacter = GetValue(service.CreateCharacter(ownerSession, new CreateCharacterRequest("Owner Character", campaign.Id))); var ownerCharacter = GetValue(service.CreateCharacter(ownerSession, new CreateCharacterCommand("Owner Character", campaign.Id)));
Assert.False(service.GetMe(string.Empty).Succeeded); 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.GetCampaigns(string.Empty).Succeeded);
Assert.False(service.CreateCharacter(ownerSession, new CreateCharacterRequest("", campaign.Id)).Succeeded); Assert.False(service.CreateCharacter(ownerSession, new CreateCharacterCommand("", campaign.Id)).Succeeded);
Assert.False(service.CreateCharacter(string.Empty, new CreateCharacterRequest("Name", campaign.Id)).Succeeded); Assert.False(service.CreateCharacter(string.Empty, new CreateCharacterCommand("Name", campaign.Id)).Succeeded);
Assert.False(service.UpdateCharacter(string.Empty, ownerCharacter.Id, new UpdateCharacterRequest("Renamed", 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 UpdateCharacterRequest("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(string.Empty, ownerCharacter.Id).Succeeded);
Assert.False(service.ActivateCharacter(gmSession, ownerCharacter.Id).Succeeded); Assert.False(service.ActivateCharacter(gmSession, ownerCharacter.Id).Succeeded);
Assert.False(service.GetCurrentCampaignCharacters(string.Empty).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(string.Empty, ownerCharacter.Id, new CreateSkillCommand("Stealth", "2D+1")).Succeeded);
Assert.False(service.CreateSkill(ownerSession, Guid.NewGuid(), new CreateSkillRequest("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 CreateSkillRequest("Stealth", "2D+1")).Succeeded); Assert.False(service.CreateSkill(otherSession, ownerCharacter.Id, new CreateSkillCommand("Stealth", "2D+1")).Succeeded);
using (var db = harness.CreateDbContext()) using (var db = harness.CreateDbContext())
{ {
@@ -274,11 +273,11 @@ public sealed class GameServiceTests
Assert.Null(db.Users.Single(u => u.UsernameNormalized == "OWNER").ActiveCharacterId); Assert.Null(db.Users.Single(u => u.UsernameNormalized == "OWNER").ActiveCharacterId);
} }
var skill = GetValue(service.CreateSkill(ownerSession, ownerCharacter.Id, new CreateSkillRequest("Stealth", "2D+1"))); var skill = GetValue(service.CreateSkill(ownerSession, ownerCharacter.Id, new CreateSkillCommand("Stealth", "2D+1")));
Assert.False(service.UpdateSkill(ownerSession, skill.Id, new UpdateSkillRequest("", "2D+1")).Succeeded); Assert.False(service.UpdateSkill(ownerSession, skill.Id, new UpdateSkillCommand("", "2D+1")).Succeeded);
Assert.False(service.UpdateSkill(string.Empty, skill.Id, new UpdateSkillRequest("Stealth", "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 UpdateSkillRequest("Stealth", "bad")).Succeeded); Assert.False(service.UpdateSkill(ownerSession, skill.Id, new UpdateSkillCommand("Stealth", "bad")).Succeeded);
Assert.False(service.RollSkill(string.Empty, skill.Id, new RollSkillRequest("public")).Succeeded); Assert.False(service.RollSkill(string.Empty, skill.Id, new RollSkillCommand("public")).Succeeded);
using (var db = harness.CreateDbContext()) using (var db = harness.CreateDbContext())
{ {
@@ -288,7 +287,7 @@ public sealed class GameServiceTests
} }
using var invalidExpressionHarness = CreateHarnessFromPath(harness.DbPath, 2, 3, 4); 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); Assert.False(service.GetCampaignLog(string.Empty, campaign.Id).Succeeded);
} }
@@ -298,20 +297,20 @@ public sealed class GameServiceTests
using var harness = CreateHarness(); using var harness = CreateHarness();
var service = harness.Service; var service = harness.Service;
service.Register(new RegisterRequest("gm", "Password123", "GM")); service.Register(new RegisterCommand("gm", "Password123", "GM"));
service.Register(new RegisterRequest("owner", "Password123", "Owner")); service.Register(new RegisterCommand("owner", "Password123", "Owner"));
service.Register(new RegisterRequest("other", "Password123", "Other")); service.Register(new RegisterCommand("other", "Password123", "Other"));
var gmSession = GetValue(service.Login(new LoginRequest("gm", "Password123"))).SessionToken; var gmSession = GetValue(service.Login(new LoginCommand("gm", "Password123"))).SessionToken;
var ownerSession = GetValue(service.Login(new LoginRequest("owner", "Password123"))).SessionToken; var ownerSession = GetValue(service.Login(new LoginCommand("owner", "Password123"))).SessionToken;
var otherSession = GetValue(service.Login(new LoginRequest("other", "Password123"))).SessionToken; var otherSession = GetValue(service.Login(new LoginCommand("other", "Password123"))).SessionToken;
var campaign = GetValue(service.CreateCampaign(gmSession, new CreateCampaignRequest("Main", "d6"))); var campaign = GetValue(service.CreateCampaign(gmSession, new CreateCampaignCommand("Main", "d6")));
var ownerCharacter = GetValue(service.CreateCharacter(ownerSession, new CreateCharacterRequest("Owner Character", campaign.Id))); var ownerCharacter = GetValue(service.CreateCharacter(ownerSession, new CreateCharacterCommand("Owner Character", campaign.Id)));
var otherCharacter = GetValue(service.CreateCharacter(otherSession, new CreateCharacterRequest("Other 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"))); var ownerSkill = GetValue(service.CreateSkill(ownerSession, ownerCharacter.Id, new CreateSkillCommand("Stealth", "2D+1")));
_ = GetValue(service.CreateSkill(otherSession, otherCharacter.Id, new CreateSkillRequest("Perception", "1D+2"))); _ = GetValue(service.CreateSkill(otherSession, otherCharacter.Id, new CreateSkillCommand("Perception", "1D+2")));
var ownerView = GetValue(service.GetCampaign(ownerSession, campaign.Id)); var ownerView = GetValue(service.GetCampaign(ownerSession, campaign.Id));
Assert.Single(ownerView.Characters); Assert.Single(ownerView.Characters);
@@ -329,17 +328,21 @@ public sealed class GameServiceTests
var d6 = DiceRules.ParseExpression(RulesetKind.D6, "5D+4"); var d6 = DiceRules.ParseExpression(RulesetKind.D6, "5D+4");
var dnd = DiceRules.ParseExpression(RulesetKind.Dnd5e, "2d12+2"); var dnd = DiceRules.ParseExpression(RulesetKind.Dnd5e, "2d12+2");
var emptyExpression = DiceRules.ParseExpression(RulesetKind.Dnd5e, "");
var badFormat = DiceRules.ParseExpression(RulesetKind.Dnd5e, "abc"); var badFormat = DiceRules.ParseExpression(RulesetKind.Dnd5e, "abc");
var tooManyDice = DiceRules.ParseExpression(RulesetKind.D6, "51D+1"); var tooManyDice = DiceRules.ParseExpression(RulesetKind.D6, "51D+1");
var tooManySides = DiceRules.ParseExpression(RulesetKind.Dnd5e, "1d1001"); var tooManySides = DiceRules.ParseExpression(RulesetKind.Dnd5e, "1d1001");
var tooLargeModifier = DiceRules.ParseExpression(RulesetKind.Dnd5e, "1d20+1001"); var tooLargeModifier = DiceRules.ParseExpression(RulesetKind.Dnd5e, "1d20+1001");
var unknownRulesetExpression = DiceRules.ParseExpression((RulesetKind)99, "1d20+1");
Assert.True(d6.Succeeded); Assert.True(d6.Succeeded);
Assert.True(dnd.Succeeded); Assert.True(dnd.Succeeded);
Assert.False(emptyExpression.Succeeded);
Assert.False(badFormat.Succeeded); Assert.False(badFormat.Succeeded);
Assert.False(tooManyDice.Succeeded); Assert.False(tooManyDice.Succeeded);
Assert.False(tooManySides.Succeeded); Assert.False(tooManySides.Succeeded);
Assert.False(tooLargeModifier.Succeeded); Assert.False(tooLargeModifier.Succeeded);
Assert.False(unknownRulesetExpression.Succeeded);
Assert.Equal("d6", DiceRules.ToRulesetId(RulesetKind.D6)); Assert.Equal("d6", DiceRules.ToRulesetId(RulesetKind.D6));
Assert.Equal("dnd5e", DiceRules.ToRulesetId(RulesetKind.Dnd5e)); 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 RpgRoller.Api;
using Microsoft.AspNetCore.Identity; using RpgRoller.Hosting;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using RpgRoller.Contracts;
using RpgRoller.Data;
using RpgRoller.Domain;
using RpgRoller.Services;
const string SessionCookieName = "rpgroller_session";
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
var sqliteConnectionString = builder.Configuration.GetConnectionString("RpgRoller") ?? "Data Source=App_Data/rpgroller.db"; builder.Services.AddRpgRollerCore(builder.Configuration, builder.Environment);
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>();
var app = builder.Build(); var app = builder.Build();
app.InitializeRpgRollerState();
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.UseDefaultFiles(); app.UseDefaultFiles();
app.UseStaticFiles(); app.UseStaticFiles();
app.MapGet("/api/health", () => TypedResults.Ok(new HealthResponse("ok"))); app.MapRpgRollerApi();
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.Run(); 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; public partial class Program;

View File

@@ -35,7 +35,7 @@ public sealed class GameService : IGameService
.ToArray(); .ToArray();
} }
public ServiceResult<UserSummary> Register(RegisterRequest request) public ServiceResult<UserSummary> Register(RegisterCommand request)
{ {
if (string.IsNullOrWhiteSpace(request.Username)) 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)) 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)) 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)) 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)) 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)) 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)) 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) lock (m_Gate)
{ {

View File

@@ -7,25 +7,25 @@ public interface IGameService
{ {
IReadOnlyList<RulesetDefinition> GetRulesets(); IReadOnlyList<RulesetDefinition> GetRulesets();
ServiceResult<UserSummary> Register(RegisterRequest request); ServiceResult<UserSummary> Register(RegisterCommand request);
ServiceResult<(UserSummary User, string SessionToken)> Login(LoginRequest request); ServiceResult<(UserSummary User, string SessionToken)> Login(LoginCommand request);
void Logout(string sessionToken); void Logout(string sessionToken);
UserSummary? GetUserBySession(string sessionToken); UserSummary? GetUserBySession(string sessionToken);
ServiceResult<MeResponse> GetMe(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<IReadOnlyList<CampaignSummary>> GetCampaigns(string sessionToken);
ServiceResult<CampaignDetails> GetCampaign(string sessionToken, Guid campaignId); ServiceResult<CampaignDetails> GetCampaign(string sessionToken, Guid campaignId);
ServiceResult<CharacterSummary> CreateCharacter(string sessionToken, CreateCharacterRequest request); ServiceResult<CharacterSummary> CreateCharacter(string sessionToken, CreateCharacterCommand request);
ServiceResult<CharacterSummary> UpdateCharacter(string sessionToken, Guid characterId, UpdateCharacterRequest request); ServiceResult<CharacterSummary> UpdateCharacter(string sessionToken, Guid characterId, UpdateCharacterCommand request);
ServiceResult<bool> ActivateCharacter(string sessionToken, Guid characterId); ServiceResult<bool> ActivateCharacter(string sessionToken, Guid characterId);
ServiceResult<IReadOnlyList<CharacterSummary>> GetCurrentCampaignCharacters(string sessionToken); ServiceResult<IReadOnlyList<CharacterSummary>> GetCurrentCampaignCharacters(string sessionToken);
ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, CreateSkillRequest request); ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, CreateSkillCommand request);
ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, UpdateSkillRequest 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<IReadOnlyList<CampaignLogEntry>> GetCampaignLog(string sessionToken, Guid campaignId);
ServiceResult<long> GetCampaignVersion(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 { import {
activateCharacter, activateCharacter,
type CampaignDetails,
type CampaignLogEntry,
type CampaignSummary,
createCampaign, createCampaign,
createCharacter, createCharacter,
createSkill, createSkill,
getCampaign,
getCampaignLog,
getCampaigns,
getHealth,
getMe,
getRulesets,
loginUser, loginUser,
logoutUser, logoutUser,
registerUser, registerUser,
rollSkill, rollSkill,
type RulesetDefinition,
type SkillSummary,
type UserSummary,
updateSkill updateSkill
} from "./generated/api-client.js"; } 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 elements = getAppElements();
const messageElement = mustElement("message"); const state = createInitialState();
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"); elements.registerForm.addEventListener("submit", async (event) => {
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) => {
event.preventDefault(); event.preventDefault();
await runAction(async () => { await runAppAction(async () => {
await registerUser({ await registerUser({
username: registerUsername.value.trim(), username: elements.registerUsername.value.trim(),
displayName: registerDisplayName.value.trim(), displayName: elements.registerDisplayName.value.trim(),
password: registerPassword.value password: elements.registerPassword.value
}); });
registerPassword.value = ""; elements.registerPassword.value = "";
setMessage("Registration successful. You can log in now.", false); setStatus("Registration successful. You can log in now.", false);
}); });
}); });
loginForm.addEventListener("submit", async (event) => { elements.loginForm.addEventListener("submit", async (event) => {
event.preventDefault(); event.preventDefault();
await runAction(async () => { await runAppAction(async () => {
await loginUser({ await loginUser({
username: loginUsername.value.trim(), username: elements.loginUsername.value.trim(),
password: loginPassword.value password: elements.loginPassword.value
}); });
loginPassword.value = ""; elements.loginPassword.value = "";
await reloadAll(); await reloadAll();
setMessage("Logged in.", false); setStatus("Logged in.", false);
}); });
}); });
logoutButton.addEventListener("click", async () => { elements.logoutButton.addEventListener("click", async () => {
await runAction(async () => { await runAppAction(async () => {
await logoutUser(); await logoutUser();
resetStateAfterLogout(); resetStateAfterLogout(state);
renderAll(); closeStateEvents(state);
setMessage("Logged out.", false); renderAll(state, elements);
setStatus("Logged out.", false);
}); });
}); });
campaignForm.addEventListener("submit", async (event) => { elements.campaignForm.addEventListener("submit", async (event) => {
event.preventDefault(); event.preventDefault();
await runAction(async () => { await runAppAction(async () => {
const createdCampaign = await createCampaign({ const createdCampaign = await createCampaign({
name: campaignNameInput.value.trim(), name: elements.campaignNameInput.value.trim(),
rulesetId: campaignRulesetSelect.value rulesetId: elements.campaignRulesetSelect.value
}); });
campaignNameInput.value = ""; elements.campaignNameInput.value = "";
await reloadCampaigns(createdCampaign.id); await reloadCampaigns(state, createdCampaign.id);
setMessage("Campaign created.", false); await reloadSelectedCampaign(state);
await reloadCampaignLog(state);
syncEventStream();
renderAll(state, elements);
setStatus("Campaign created.", false);
}); });
}); });
campaignSelect.addEventListener("change", async () => { elements.campaignSelect.addEventListener("change", async () => {
await runAction(async () => { await runAppAction(async () => {
const selected = campaignSelect.value; const selected = elements.campaignSelect.value;
state.selectedCampaignId = selected.length > 0 ? selected : null; state.selectedCampaignId = selected.length > 0 ? selected : null;
await reloadSelectedCampaign(); await reloadSelectedCampaign(state);
syncSelectedCharacter(); syncSelectedCharacter(state);
renderCharacterSelect(); renderCharacterSelect(state, elements);
renderSkillSelect(); renderSkillSelect(state, elements);
connectStateEvents(); syncEventStream();
renderCampaignMeta(); renderCampaignMeta(state, elements);
renderCampaignDetails(); renderCampaignDetails(state, elements);
renderCampaignLog(); renderCampaignLog(state, elements);
}); });
}); });
characterSelect.addEventListener("change", () => { elements.characterSelect.addEventListener("change", () => {
state.selectedCharacterId = characterSelect.value.length > 0 ? characterSelect.value : null; state.selectedCharacterId = elements.characterSelect.value.length > 0 ? elements.characterSelect.value : null;
renderSkillSelect(); renderSkillSelect(state, elements);
}); });
refreshCampaignButton.addEventListener("click", async () => { elements.refreshCampaignButton.addEventListener("click", async () => {
await runAction(async () => { await runAppAction(async () => {
await reloadSelectedCampaign(); await reloadSelectedCampaign(state);
renderAll(); await reloadCampaignLog(state);
setMessage("Campaign refreshed.", false); renderAll(state, elements);
setStatus("Campaign refreshed.", false);
}); });
}); });
characterForm.addEventListener("submit", async (event) => { elements.characterForm.addEventListener("submit", async (event) => {
event.preventDefault(); event.preventDefault();
await runAction(async () => { await runAppAction(async () => {
if (!state.selectedCampaignId) { if (!state.selectedCampaignId) {
throw new Error("Select a campaign first."); throw new Error("Select a campaign first.");
} }
await createCharacter({ await createCharacter({
name: characterNameInput.value.trim(), name: elements.characterNameInput.value.trim(),
campaignId: state.selectedCampaignId campaignId: state.selectedCampaignId
}); });
characterNameInput.value = ""; elements.characterNameInput.value = "";
await reloadSelectedCampaign(); await reloadSelectedCampaign(state);
await reloadCampaignLog(); await reloadCampaignLog(state);
setMessage("Character created.", false); renderAll(state, elements);
setStatus("Character created.", false);
}); });
}); });
activateCharacterButton.addEventListener("click", async () => { elements.activateCharacterButton.addEventListener("click", async () => {
await runAction(async () => { await runAppAction(async () => {
const characterId = characterSelect.value; const characterId = elements.characterSelect.value;
if (characterId.length === 0) { if (characterId.length === 0) {
throw new Error("Select a character to activate."); throw new Error("Select a character to activate.");
} }
@@ -196,426 +146,107 @@ activateCharacterButton.addEventListener("click", async () => {
state.activeCharacterId = characterId; state.activeCharacterId = characterId;
state.selectedCharacterId = characterId; state.selectedCharacterId = characterId;
await reloadAll(); 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(); event.preventDefault();
await runAction(async () => { await runAppAction(async () => {
const characterId = characterSelect.value; const characterId = elements.characterSelect.value;
if (characterId.length === 0) { if (characterId.length === 0) {
throw new Error("Select a character first."); throw new Error("Select a character first.");
} }
await createSkill(characterId, { await createSkill(characterId, {
name: skillNameInput.value.trim(), name: elements.skillNameInput.value.trim(),
diceRollDefinition: skillExpressionInput.value.trim() diceRollDefinition: elements.skillExpressionInput.value.trim()
}); });
skillNameInput.value = ""; elements.skillNameInput.value = "";
skillExpressionInput.value = ""; elements.skillExpressionInput.value = "";
await reloadSelectedCampaign(); await reloadSelectedCampaign(state);
setMessage("Skill created.", false); renderAll(state, elements);
setStatus("Skill created.", false);
}); });
}); });
updateSkillButton.addEventListener("click", async () => { elements.updateSkillButton.addEventListener("click", async () => {
await runAction(async () => { await runAppAction(async () => {
const selectedSkillId = skillSelect.value; const selectedSkillId = elements.skillSelect.value;
if (selectedSkillId.length === 0) { if (selectedSkillId.length === 0) {
throw new Error("Select a skill to update."); throw new Error("Select a skill to update.");
} }
await updateSkill(selectedSkillId, { await updateSkill(selectedSkillId, {
name: skillNameInput.value.trim(), name: elements.skillNameInput.value.trim(),
diceRollDefinition: skillExpressionInput.value.trim() diceRollDefinition: elements.skillExpressionInput.value.trim()
}); });
await reloadSelectedCampaign(); await reloadSelectedCampaign(state);
setMessage("Skill updated.", false); renderAll(state, elements);
setStatus("Skill updated.", false);
}); });
}); });
rollForm.addEventListener("submit", async (event) => { elements.rollForm.addEventListener("submit", async (event) => {
event.preventDefault(); event.preventDefault();
await runAction(async () => { await runAppAction(async () => {
const selectedSkillId = skillSelect.value; const selectedSkillId = elements.skillSelect.value;
if (selectedSkillId.length === 0) { if (selectedSkillId.length === 0) {
throw new Error("Select a skill to roll."); throw new Error("Select a skill to roll.");
} }
const roll = await rollSkill(selectedSkillId, { visibility: rollVisibilitySelect.value }); const roll = await rollSkill(selectedSkillId, { visibility: elements.rollVisibilitySelect.value });
rollResultElement.textContent = `Roll ${roll.rollId}: ${roll.breakdown} (${roll.visibility})`; elements.rollResultElement.textContent = `Roll ${roll.rollId}: ${roll.breakdown} (${roll.visibility})`;
await reloadCampaignLog(); await reloadCampaignLog(state);
setMessage("Roll recorded.", false); renderCampaignLog(state, elements);
setStatus("Roll recorded.", false);
}); });
}); });
await runAction(async () => { await runAppAction(async () => {
await refreshHealth(); await refreshHealth(elements);
await reloadAll(); 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> { async function reloadAll(): Promise<void> {
await ensureRulesets(); await ensureRulesets(state, elements);
await reloadSession(); await reloadSession(state);
if (state.user) { if (state.user) {
await reloadCampaigns(state.selectedCampaignId); await reloadCampaigns(state, state.selectedCampaignId);
await reloadSelectedCampaign(); await reloadSelectedCampaign(state);
await reloadCampaignLog(); await reloadCampaignLog(state);
connectStateEvents(); syncEventStream();
} }
else { else {
resetAuthenticatedState(); resetAuthenticatedState(state);
closeStateEvents(state);
} }
renderAll(); renderAll(state, elements);
} }
async function reloadSession(): Promise<void> { function syncEventStream(): void {
try { connectStateEvents(state, () => {
const me = await getMe(); void runAppAction(async () => {
state.user = me.user; await reloadSelectedCampaign(state);
state.activeCharacterId = me.activeCharacterId ?? null; await reloadCampaignLog(state);
state.selectedCampaignId = me.currentCampaignId ?? state.selectedCampaignId; renderAll(state, elements);
}
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();
}); });
}); });
state.eventSource.onerror = () => {
closeStateEvents();
};
} }
function closeStateEvents(): void { async function runAppAction(action: () => Promise<void>): Promise<void> {
if (state.eventSource) { await runAction(action, (message) => {
state.eventSource.close(); setStatus(message, true);
state.eventSource = null; });
}
} }
function resetStateAfterLogout(): void { function setStatus(message: string, isError: boolean): void {
state.user = null; setMessage(elements.messageElement, message, isError);
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;
} }

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"; import { activateCharacter, createCampaign, createCharacter, createSkill, loginUser, logoutUser, registerUser, rollSkill, updateSkill } from "./generated/api-client.js";
const healthElement = mustElement("health"); import { runAction, setMessage } from "./app/actions.js";
const messageElement = mustElement("message"); import { getAppElements } from "./app/dom.js";
const campaignMetaElement = mustElement("campaign-meta"); import { closeStateEvents, connectStateEvents } from "./app/events.js";
const campaignDetailsElement = mustElement("campaign-details"); import { ensureRulesets, refreshHealth, reloadCampaignLog, reloadCampaigns, reloadSelectedCampaign, reloadSession } from "./app/loaders.js";
const rollResultElement = mustElement("roll-result"); import { renderAll, renderCampaignDetails, renderCampaignLog, renderCampaignMeta, renderCharacterSelect, renderSkillSelect } from "./app/render.js";
const campaignLogElement = mustElement("campaign-log"); import { createInitialState, resetAuthenticatedState, resetStateAfterLogout, syncSelectedCharacter } from "./app/state.js";
const registerForm = mustForm("register-form"); const elements = getAppElements();
const loginForm = mustForm("login-form"); const state = createInitialState();
const logoutButton = mustButton("logout-button"); elements.registerForm.addEventListener("submit", async (event) => {
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) => {
event.preventDefault(); event.preventDefault();
await runAction(async () => { await runAppAction(async () => {
await registerUser({ await registerUser({
username: registerUsername.value.trim(), username: elements.registerUsername.value.trim(),
displayName: registerDisplayName.value.trim(), displayName: elements.registerDisplayName.value.trim(),
password: registerPassword.value password: elements.registerPassword.value
}); });
registerPassword.value = ""; elements.registerPassword.value = "";
setMessage("Registration successful. You can log in now.", false); setStatus("Registration successful. You can log in now.", false);
}); });
}); });
loginForm.addEventListener("submit", async (event) => { elements.loginForm.addEventListener("submit", async (event) => {
event.preventDefault(); event.preventDefault();
await runAction(async () => { await runAppAction(async () => {
await loginUser({ await loginUser({
username: loginUsername.value.trim(), username: elements.loginUsername.value.trim(),
password: loginPassword.value password: elements.loginPassword.value
}); });
loginPassword.value = ""; elements.loginPassword.value = "";
await reloadAll(); await reloadAll();
setMessage("Logged in.", false); setStatus("Logged in.", false);
}); });
}); });
logoutButton.addEventListener("click", async () => { elements.logoutButton.addEventListener("click", async () => {
await runAction(async () => { await runAppAction(async () => {
await logoutUser(); await logoutUser();
resetStateAfterLogout(); resetStateAfterLogout(state);
renderAll(); closeStateEvents(state);
setMessage("Logged out.", false); renderAll(state, elements);
setStatus("Logged out.", false);
}); });
}); });
campaignForm.addEventListener("submit", async (event) => { elements.campaignForm.addEventListener("submit", async (event) => {
event.preventDefault(); event.preventDefault();
await runAction(async () => { await runAppAction(async () => {
const createdCampaign = await createCampaign({ const createdCampaign = await createCampaign({
name: campaignNameInput.value.trim(), name: elements.campaignNameInput.value.trim(),
rulesetId: campaignRulesetSelect.value rulesetId: elements.campaignRulesetSelect.value
}); });
campaignNameInput.value = ""; elements.campaignNameInput.value = "";
await reloadCampaigns(createdCampaign.id); await reloadCampaigns(state, createdCampaign.id);
setMessage("Campaign created.", false); await reloadSelectedCampaign(state);
await reloadCampaignLog(state);
syncEventStream();
renderAll(state, elements);
setStatus("Campaign created.", false);
}); });
}); });
campaignSelect.addEventListener("change", async () => { elements.campaignSelect.addEventListener("change", async () => {
await runAction(async () => { await runAppAction(async () => {
const selected = campaignSelect.value; const selected = elements.campaignSelect.value;
state.selectedCampaignId = selected.length > 0 ? selected : null; state.selectedCampaignId = selected.length > 0 ? selected : null;
await reloadSelectedCampaign(); await reloadSelectedCampaign(state);
syncSelectedCharacter(); syncSelectedCharacter(state);
renderCharacterSelect(); renderCharacterSelect(state, elements);
renderSkillSelect(); renderSkillSelect(state, elements);
connectStateEvents(); syncEventStream();
renderCampaignMeta(); renderCampaignMeta(state, elements);
renderCampaignDetails(); renderCampaignDetails(state, elements);
renderCampaignLog(); renderCampaignLog(state, elements);
}); });
}); });
characterSelect.addEventListener("change", () => { elements.characterSelect.addEventListener("change", () => {
state.selectedCharacterId = characterSelect.value.length > 0 ? characterSelect.value : null; state.selectedCharacterId = elements.characterSelect.value.length > 0 ? elements.characterSelect.value : null;
renderSkillSelect(); renderSkillSelect(state, elements);
}); });
refreshCampaignButton.addEventListener("click", async () => { elements.refreshCampaignButton.addEventListener("click", async () => {
await runAction(async () => { await runAppAction(async () => {
await reloadSelectedCampaign(); await reloadSelectedCampaign(state);
renderAll(); await reloadCampaignLog(state);
setMessage("Campaign refreshed.", false); renderAll(state, elements);
setStatus("Campaign refreshed.", false);
}); });
}); });
characterForm.addEventListener("submit", async (event) => { elements.characterForm.addEventListener("submit", async (event) => {
event.preventDefault(); event.preventDefault();
await runAction(async () => { await runAppAction(async () => {
if (!state.selectedCampaignId) { if (!state.selectedCampaignId) {
throw new Error("Select a campaign first."); throw new Error("Select a campaign first.");
} }
await createCharacter({ await createCharacter({
name: characterNameInput.value.trim(), name: elements.characterNameInput.value.trim(),
campaignId: state.selectedCampaignId campaignId: state.selectedCampaignId
}); });
characterNameInput.value = ""; elements.characterNameInput.value = "";
await reloadSelectedCampaign(); await reloadSelectedCampaign(state);
await reloadCampaignLog(); await reloadCampaignLog(state);
setMessage("Character created.", false); renderAll(state, elements);
setStatus("Character created.", false);
}); });
}); });
activateCharacterButton.addEventListener("click", async () => { elements.activateCharacterButton.addEventListener("click", async () => {
await runAction(async () => { await runAppAction(async () => {
const characterId = characterSelect.value; const characterId = elements.characterSelect.value;
if (characterId.length === 0) { if (characterId.length === 0) {
throw new Error("Select a character to activate."); throw new Error("Select a character to activate.");
} }
@@ -135,349 +109,90 @@ activateCharacterButton.addEventListener("click", async () => {
state.activeCharacterId = characterId; state.activeCharacterId = characterId;
state.selectedCharacterId = characterId; state.selectedCharacterId = characterId;
await reloadAll(); 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(); event.preventDefault();
await runAction(async () => { await runAppAction(async () => {
const characterId = characterSelect.value; const characterId = elements.characterSelect.value;
if (characterId.length === 0) { if (characterId.length === 0) {
throw new Error("Select a character first."); throw new Error("Select a character first.");
} }
await createSkill(characterId, { await createSkill(characterId, {
name: skillNameInput.value.trim(), name: elements.skillNameInput.value.trim(),
diceRollDefinition: skillExpressionInput.value.trim() diceRollDefinition: elements.skillExpressionInput.value.trim()
}); });
skillNameInput.value = ""; elements.skillNameInput.value = "";
skillExpressionInput.value = ""; elements.skillExpressionInput.value = "";
await reloadSelectedCampaign(); await reloadSelectedCampaign(state);
setMessage("Skill created.", false); renderAll(state, elements);
setStatus("Skill created.", false);
}); });
}); });
updateSkillButton.addEventListener("click", async () => { elements.updateSkillButton.addEventListener("click", async () => {
await runAction(async () => { await runAppAction(async () => {
const selectedSkillId = skillSelect.value; const selectedSkillId = elements.skillSelect.value;
if (selectedSkillId.length === 0) { if (selectedSkillId.length === 0) {
throw new Error("Select a skill to update."); throw new Error("Select a skill to update.");
} }
await updateSkill(selectedSkillId, { await updateSkill(selectedSkillId, {
name: skillNameInput.value.trim(), name: elements.skillNameInput.value.trim(),
diceRollDefinition: skillExpressionInput.value.trim() diceRollDefinition: elements.skillExpressionInput.value.trim()
}); });
await reloadSelectedCampaign(); await reloadSelectedCampaign(state);
setMessage("Skill updated.", false); renderAll(state, elements);
setStatus("Skill updated.", false);
}); });
}); });
rollForm.addEventListener("submit", async (event) => { elements.rollForm.addEventListener("submit", async (event) => {
event.preventDefault(); event.preventDefault();
await runAction(async () => { await runAppAction(async () => {
const selectedSkillId = skillSelect.value; const selectedSkillId = elements.skillSelect.value;
if (selectedSkillId.length === 0) { if (selectedSkillId.length === 0) {
throw new Error("Select a skill to roll."); throw new Error("Select a skill to roll.");
} }
const roll = await rollSkill(selectedSkillId, { visibility: rollVisibilitySelect.value }); const roll = await rollSkill(selectedSkillId, { visibility: elements.rollVisibilitySelect.value });
rollResultElement.textContent = `Roll ${roll.rollId}: ${roll.breakdown} (${roll.visibility})`; elements.rollResultElement.textContent = `Roll ${roll.rollId}: ${roll.breakdown} (${roll.visibility})`;
await reloadCampaignLog(); await reloadCampaignLog(state);
setMessage("Roll recorded.", false); renderCampaignLog(state, elements);
setStatus("Roll recorded.", false);
}); });
}); });
await runAction(async () => { await runAppAction(async () => {
await refreshHealth(); await refreshHealth(elements);
await reloadAll(); await reloadAll();
setMessage("Ready.", false); setStatus("Ready.", false);
}); });
async function refreshHealth() {
const health = await getHealth();
healthElement.textContent = `API status: ${health.status}`;
}
async function reloadAll() { async function reloadAll() {
await ensureRulesets(); await ensureRulesets(state, elements);
await reloadSession(); await reloadSession(state);
if (state.user) { if (state.user) {
await reloadCampaigns(state.selectedCampaignId); await reloadCampaigns(state, state.selectedCampaignId);
await reloadSelectedCampaign(); await reloadSelectedCampaign(state);
await reloadCampaignLog(); await reloadCampaignLog(state);
connectStateEvents(); syncEventStream();
} }
else { else {
resetAuthenticatedState(); resetAuthenticatedState(state);
closeStateEvents(state);
} }
renderAll(); renderAll(state, elements);
} }
async function reloadSession() { function syncEventStream() {
try { connectStateEvents(state, () => {
const me = await getMe(); void runAppAction(async () => {
state.user = me.user; await reloadSelectedCampaign(state);
state.activeCharacterId = me.activeCharacterId ?? null; await reloadCampaignLog(state);
state.selectedCampaignId = me.currentCampaignId ?? state.selectedCampaignId; renderAll(state, elements);
}
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();
}); });
}); });
state.eventSource.onerror = () => {
closeStateEvents();
};
} }
function closeStateEvents() { async function runAppAction(action) {
if (state.eventSource) { await runAction(action, (message) => {
state.eventSource.close(); setStatus(message, true);
state.eventSource = null; });
}
} }
function resetStateAfterLogout() { function setStatus(message, isError) {
state.user = null; setMessage(elements.messageElement, message, isError);
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;
} }

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` - Root solution: `RpgRoller.sln`
- Backend/full-stack project: `RpgRoller` (Minimal API + static `wwwroot` frontend) - Backend/full-stack project: `RpgRoller` (Minimal API + static `wwwroot` frontend)
- Frontend source: `RpgRoller/frontend` (TypeScript) - 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) - Test project: `RpgRoller.Tests` (xUnit + `WebApplicationFactory` integration tests)
- Persistence: EF Core + SQLite (`RpgRoller/Data/RpgRollerDbContext.cs`) with in-memory runtime cache in `GameService` - Persistence: EF Core + SQLite (`RpgRoller/Data/RpgRollerDbContext.cs`) with in-memory runtime cache in `GameService`
- OpenAPI source: `openapi/RpgRoller.json` - OpenAPI source: `openapi/RpgRoller.json`
- Generated client source: `RpgRoller/frontend/generated/api-client.ts` - Generated client source: `RpgRoller/frontend/generated/api-client.ts`
- Generated client output: `RpgRoller/wwwroot/generated/api-client.js` - Generated client output: `RpgRoller/wwwroot/generated/api-client.js`
- Local CI parity entrypoint: `scripts/ci-local.ps1` - 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 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. - Current frontend features: authenticated campaign workspace with live log updates and full roll workflow controls.