From c6e95f16e153d2581a23291e1a07e85385ec2d81 Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Tue, 24 Feb 2026 23:33:12 +0100 Subject: [PATCH] Refactor API/service boundaries and modularize frontend --- FAQ.md | 4 + README.md | 16 + RpgRoller.Tests/GameServiceTests.cs | 172 ++--- RpgRoller/Api/ApiEndpointRegistration.cs | 20 + RpgRoller/Api/ApiResultMapper.cs | 28 + RpgRoller/Api/AuthEndpoints.cs | 54 ++ RpgRoller/Api/CampaignEndpoints.cs | 36 + RpgRoller/Api/CharacterEndpoints.cs | 36 + RpgRoller/Api/MeEndpoints.cs | 19 + RpgRoller/Api/RequestMappings.cs | 47 ++ RpgRoller/Api/RequireSessionTokenFilter.cs | 15 + RpgRoller/Api/SessionCookie.cs | 6 + .../Api/SessionTokenHttpContextExtensions.cs | 29 + RpgRoller/Api/SkillEndpoints.cs | 30 + RpgRoller/Api/StateEventEndpoints.cs | 66 ++ RpgRoller/Api/SystemEndpoints.cs | 14 + .../ApplicationInitializationExtensions.cs | 17 + .../Hosting/ServiceCollectionExtensions.cs | 46 ++ RpgRoller/Program.cs | 318 +-------- RpgRoller/Services/GameService.cs | 16 +- RpgRoller/Services/IGameService.cs | 16 +- RpgRoller/Services/ServiceCommands.cs | 13 + RpgRoller/frontend/app.ts | 623 ++++-------------- RpgRoller/frontend/app/actions.ts | 24 + RpgRoller/frontend/app/dom.ts | 112 ++++ RpgRoller/frontend/app/events.ts | 30 + RpgRoller/frontend/app/loaders.ts | 74 +++ RpgRoller/frontend/app/render.ts | 117 ++++ RpgRoller/frontend/app/state.ts | 71 ++ RpgRoller/frontend/app/types.ts | 19 + RpgRoller/wwwroot/app.js | 527 ++++----------- RpgRoller/wwwroot/app/actions.js | 18 + RpgRoller/wwwroot/app/dom.js | 69 ++ RpgRoller/wwwroot/app/events.js | 23 + RpgRoller/wwwroot/app/loaders.js | 58 ++ RpgRoller/wwwroot/app/render.js | 98 +++ RpgRoller/wwwroot/app/state.js | 58 ++ RpgRoller/wwwroot/app/types.js | 1 + TECH.md | 3 + 39 files changed, 1628 insertions(+), 1315 deletions(-) create mode 100644 RpgRoller/Api/ApiEndpointRegistration.cs create mode 100644 RpgRoller/Api/ApiResultMapper.cs create mode 100644 RpgRoller/Api/AuthEndpoints.cs create mode 100644 RpgRoller/Api/CampaignEndpoints.cs create mode 100644 RpgRoller/Api/CharacterEndpoints.cs create mode 100644 RpgRoller/Api/MeEndpoints.cs create mode 100644 RpgRoller/Api/RequestMappings.cs create mode 100644 RpgRoller/Api/RequireSessionTokenFilter.cs create mode 100644 RpgRoller/Api/SessionCookie.cs create mode 100644 RpgRoller/Api/SessionTokenHttpContextExtensions.cs create mode 100644 RpgRoller/Api/SkillEndpoints.cs create mode 100644 RpgRoller/Api/StateEventEndpoints.cs create mode 100644 RpgRoller/Api/SystemEndpoints.cs create mode 100644 RpgRoller/Hosting/ApplicationInitializationExtensions.cs create mode 100644 RpgRoller/Hosting/ServiceCollectionExtensions.cs create mode 100644 RpgRoller/Services/ServiceCommands.cs create mode 100644 RpgRoller/frontend/app/actions.ts create mode 100644 RpgRoller/frontend/app/dom.ts create mode 100644 RpgRoller/frontend/app/events.ts create mode 100644 RpgRoller/frontend/app/loaders.ts create mode 100644 RpgRoller/frontend/app/render.ts create mode 100644 RpgRoller/frontend/app/state.ts create mode 100644 RpgRoller/frontend/app/types.ts create mode 100644 RpgRoller/wwwroot/app/actions.js create mode 100644 RpgRoller/wwwroot/app/dom.js create mode 100644 RpgRoller/wwwroot/app/events.js create mode 100644 RpgRoller/wwwroot/app/loaders.js create mode 100644 RpgRoller/wwwroot/app/render.js create mode 100644 RpgRoller/wwwroot/app/state.js create mode 100644 RpgRoller/wwwroot/app/types.js diff --git a/FAQ.md b/FAQ.md index 5db3ddf..79f1f7e 100644 --- a/FAQ.md +++ b/FAQ.md @@ -27,3 +27,7 @@ To start with a clean backend state, stop the app and remove the corresponding S ## Does the backend read SQLite on every API call? No. The backend loads state from SQLite once during startup into in-memory state and serves requests from memory. Successful state mutations are then written back to SQLite. + +## Why do backend services use `*Command` types instead of API request DTOs? + +Service workflows now consume service-layer command models (for example, `CreateCampaignCommand`) so endpoint transport contracts stay isolated in the API layer. This reduces coupling and keeps service code reusable when input shapes evolve at the HTTP boundary. diff --git a/README.md b/README.md index cf247b0..6119360 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,22 @@ Fresh full-stack starter scaffold: - `RpgRoller.Tests/`: xUnit integration-heavy test project - `RpgRoller.sln`: solution used by local CI script +## Code Organization + +Backend: + +- `RpgRoller/Program.cs`: thin app bootstrap only +- `RpgRoller/Hosting/`: service registration + startup initialization +- `RpgRoller/Api/`: endpoint mapping modules, API-to-service request mapping, auth/session filter helpers +- `RpgRoller/Services/`: game workflows and service-layer command models (`*Command` records) + +Frontend: + +- `RpgRoller/frontend/app.ts`: orchestration entrypoint +- `RpgRoller/frontend/app/`: split modules (`dom`, `state`, `loaders`, `render`, `events`, `actions`) +- `RpgRoller/frontend/generated/`: generated API client source +- `RpgRoller/wwwroot/`: compiled browser assets + Backend state persistence: - EF Core with SQLite (`Microsoft.EntityFrameworkCore.Sqlite`) diff --git a/RpgRoller.Tests/GameServiceTests.cs b/RpgRoller.Tests/GameServiceTests.cs index 4b108a4..336885d 100644 --- a/RpgRoller.Tests/GameServiceTests.cs +++ b/RpgRoller.Tests/GameServiceTests.cs @@ -1,6 +1,5 @@ using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; -using RpgRoller.Contracts; using RpgRoller.Data; using RpgRoller.Domain; using RpgRoller.Services; @@ -15,11 +14,11 @@ public sealed class GameServiceTests using var harness = CreateHarness(); var service = harness.Service; - var invalidUsername = service.Register(new RegisterRequest("", "Password123", "Display")); - var invalidDisplay = service.Register(new RegisterRequest("user", "Password123", "")); - var invalidPassword = service.Register(new RegisterRequest("user", "short", "Display")); - var valid = service.Register(new RegisterRequest("user", "Password123", "Display")); - var duplicate = service.Register(new RegisterRequest("user", "Password123", "Display 2")); + var invalidUsername = service.Register(new RegisterCommand("", "Password123", "Display")); + var invalidDisplay = service.Register(new RegisterCommand("user", "Password123", "")); + var invalidPassword = service.Register(new RegisterCommand("user", "short", "Display")); + var valid = service.Register(new RegisterCommand("user", "Password123", "Display")); + var duplicate = service.Register(new RegisterCommand("user", "Password123", "Display 2")); Assert.False(invalidUsername.Succeeded); Assert.False(invalidDisplay.Succeeded); @@ -33,11 +32,11 @@ public sealed class GameServiceTests { using var harness = CreateHarness(); var service = harness.Service; - service.Register(new RegisterRequest("user", "Password123", "Display")); + service.Register(new RegisterCommand("user", "Password123", "Display")); - var invalidUser = service.Login(new LoginRequest("missing", "Password123")); - var invalidPassword = service.Login(new LoginRequest("user", "bad-password")); - var valid = service.Login(new LoginRequest("user", "Password123")); + var invalidUser = service.Login(new LoginCommand("missing", "Password123")); + var invalidPassword = service.Login(new LoginCommand("user", "bad-password")); + var valid = service.Login(new LoginCommand("user", "Password123")); Assert.False(invalidUser.Succeeded); Assert.False(invalidPassword.Succeeded); @@ -57,8 +56,8 @@ public sealed class GameServiceTests using var harness = CreateHarness(hasher); var service = harness.Service; - service.Register(new RegisterRequest("user", "Password123", "Display")); - var login = service.Login(new LoginRequest("user", "Password123")); + service.Register(new RegisterCommand("user", "Password123", "Display")); + var login = service.Login(new LoginCommand("user", "Password123")); Assert.True(login.Succeeded); Assert.Equal(2, hasher.HashCalls); @@ -70,20 +69,20 @@ public sealed class GameServiceTests using var harness = CreateHarness(); var service = harness.Service; - var unauthorizedCampaign = service.CreateCampaign("missing", new CreateCampaignRequest("Name", "d6")); + var unauthorizedCampaign = service.CreateCampaign("missing", new CreateCampaignCommand("Name", "d6")); Assert.False(unauthorizedCampaign.Succeeded); - service.Register(new RegisterRequest("gm", "Password123", "GM")); - var gmSession = GetValue(service.Login(new LoginRequest("gm", "Password123"))).SessionToken; - var campaign = GetValue(service.CreateCampaign(gmSession, new CreateCampaignRequest("Name", "d6"))); + service.Register(new RegisterCommand("gm", "Password123", "GM")); + var gmSession = GetValue(service.Login(new LoginCommand("gm", "Password123"))).SessionToken; + var campaign = GetValue(service.CreateCampaign(gmSession, new CreateCampaignCommand("Name", "d6"))); - var invalidRuleset = service.CreateCampaign(gmSession, new CreateCampaignRequest("Name 2", "unknown")); + var invalidRuleset = service.CreateCampaign(gmSession, new CreateCampaignCommand("Name 2", "unknown")); Assert.False(invalidRuleset.Succeeded); - var noCampaignCharacter = service.CreateCharacter(gmSession, new CreateCharacterRequest("Hero", Guid.NewGuid())); + var noCampaignCharacter = service.CreateCharacter(gmSession, new CreateCharacterCommand("Hero", Guid.NewGuid())); Assert.False(noCampaignCharacter.Succeeded); - var character = GetValue(service.CreateCharacter(gmSession, new CreateCharacterRequest("Hero", campaign.Id))); + var character = GetValue(service.CreateCharacter(gmSession, new CreateCharacterCommand("Hero", campaign.Id))); var missingCharacterActivate = service.ActivateCharacter(gmSession, Guid.NewGuid()); Assert.False(missingCharacterActivate.Succeeded); @@ -104,54 +103,54 @@ public sealed class GameServiceTests { using var harness = CreateHarness(3, 4, 5, 6); var service = harness.Service; - service.Register(new RegisterRequest("gm", "Password123", "GM")); - service.Register(new RegisterRequest("owner", "Password123", "Owner")); - service.Register(new RegisterRequest("other", "Password123", "Other")); + service.Register(new RegisterCommand("gm", "Password123", "GM")); + service.Register(new RegisterCommand("owner", "Password123", "Owner")); + service.Register(new RegisterCommand("other", "Password123", "Other")); - var gmSession = GetValue(service.Login(new LoginRequest("gm", "Password123"))).SessionToken; - var ownerSession = GetValue(service.Login(new LoginRequest("owner", "Password123"))).SessionToken; - var otherSession = GetValue(service.Login(new LoginRequest("other", "Password123"))).SessionToken; + var gmSession = GetValue(service.Login(new LoginCommand("gm", "Password123"))).SessionToken; + var ownerSession = GetValue(service.Login(new LoginCommand("owner", "Password123"))).SessionToken; + var otherSession = GetValue(service.Login(new LoginCommand("other", "Password123"))).SessionToken; - var campaign = GetValue(service.CreateCampaign(gmSession, new CreateCampaignRequest("Main", "dnd5e"))); - var character = GetValue(service.CreateCharacter(ownerSession, new CreateCharacterRequest("Owner Char", campaign.Id))); + var campaign = GetValue(service.CreateCampaign(gmSession, new CreateCampaignCommand("Main", "dnd5e"))); + var character = GetValue(service.CreateCharacter(ownerSession, new CreateCharacterCommand("Owner Char", campaign.Id))); - var noPermissionUpdate = service.UpdateCharacter(otherSession, character.Id, new UpdateCharacterRequest("Renamed", campaign.Id)); + var noPermissionUpdate = service.UpdateCharacter(otherSession, character.Id, new UpdateCharacterCommand("Renamed", campaign.Id)); Assert.False(noPermissionUpdate.Succeeded); - var invalidCharacterName = service.UpdateCharacter(ownerSession, character.Id, new UpdateCharacterRequest("", campaign.Id)); + var invalidCharacterName = service.UpdateCharacter(ownerSession, character.Id, new UpdateCharacterCommand("", campaign.Id)); Assert.False(invalidCharacterName.Succeeded); - var missingTargetCampaign = service.UpdateCharacter(ownerSession, character.Id, new UpdateCharacterRequest("Renamed", Guid.NewGuid())); + var missingTargetCampaign = service.UpdateCharacter(ownerSession, character.Id, new UpdateCharacterCommand("Renamed", Guid.NewGuid())); Assert.False(missingTargetCampaign.Succeeded); - var noSkillName = service.CreateSkill(ownerSession, character.Id, new CreateSkillRequest("", "1d20")); + var noSkillName = service.CreateSkill(ownerSession, character.Id, new CreateSkillCommand("", "1d20")); Assert.False(noSkillName.Succeeded); - var invalidExpression = service.CreateSkill(ownerSession, character.Id, new CreateSkillRequest("Skill", "5D+4")); + var invalidExpression = service.CreateSkill(ownerSession, character.Id, new CreateSkillCommand("Skill", "5D+4")); Assert.False(invalidExpression.Succeeded); - var skill = GetValue(service.CreateSkill(ownerSession, character.Id, new CreateSkillRequest("Skill", "1d20+2"))); + var skill = GetValue(service.CreateSkill(ownerSession, character.Id, new CreateSkillCommand("Skill", "1d20+2"))); - var missingSkillUpdate = service.UpdateSkill(ownerSession, Guid.NewGuid(), new UpdateSkillRequest("X", "1d20")); + var missingSkillUpdate = service.UpdateSkill(ownerSession, Guid.NewGuid(), new UpdateSkillCommand("X", "1d20")); Assert.False(missingSkillUpdate.Succeeded); - var forbiddenSkillUpdate = service.UpdateSkill(otherSession, skill.Id, new UpdateSkillRequest("X", "1d20")); + var forbiddenSkillUpdate = service.UpdateSkill(otherSession, skill.Id, new UpdateSkillCommand("X", "1d20")); Assert.False(forbiddenSkillUpdate.Succeeded); - var gmSkillUpdate = service.UpdateSkill(gmSession, skill.Id, new UpdateSkillRequest("GM Edit", "2d6+1")); + var gmSkillUpdate = service.UpdateSkill(gmSession, skill.Id, new UpdateSkillCommand("GM Edit", "2d6+1")); Assert.True(gmSkillUpdate.Succeeded); - var missingRoll = service.RollSkill(ownerSession, Guid.NewGuid(), new RollSkillRequest("public")); + var missingRoll = service.RollSkill(ownerSession, Guid.NewGuid(), new RollSkillCommand("public")); Assert.False(missingRoll.Succeeded); - var invalidVisibility = service.RollSkill(ownerSession, skill.Id, new RollSkillRequest("hidden")); + var invalidVisibility = service.RollSkill(ownerSession, skill.Id, new RollSkillCommand("hidden")); Assert.False(invalidVisibility.Succeeded); - var forbiddenRoll = service.RollSkill(otherSession, skill.Id, new RollSkillRequest("public")); + var forbiddenRoll = service.RollSkill(otherSession, skill.Id, new RollSkillCommand("public")); Assert.False(forbiddenRoll.Succeeded); - var privateRoll = service.RollSkill(ownerSession, skill.Id, new RollSkillRequest("private")); - var publicRoll = service.RollSkill(ownerSession, skill.Id, new RollSkillRequest("public")); + var privateRoll = service.RollSkill(ownerSession, skill.Id, new RollSkillCommand("private")); + var publicRoll = service.RollSkill(ownerSession, skill.Id, new RollSkillCommand("public")); Assert.True(privateRoll.Succeeded); Assert.True(publicRoll.Succeeded); @@ -174,8 +173,8 @@ public sealed class GameServiceTests { using var harness = CreateHarness(); var service = harness.Service; - service.Register(new RegisterRequest("user", "Password123", "User")); - var sessionToken = GetValue(service.Login(new LoginRequest("user", "Password123"))).SessionToken; + service.Register(new RegisterCommand("user", "Password123", "User")); + var sessionToken = GetValue(service.Login(new LoginCommand("user", "Password123"))).SessionToken; var result = service.GetCurrentCampaignCharacters(sessionToken); Assert.False(result.Succeeded); @@ -186,14 +185,14 @@ public sealed class GameServiceTests { using var harness = CreateHarness(); var service = harness.Service; - service.Register(new RegisterRequest("gm", "Password123", "GM")); - service.Register(new RegisterRequest("player", "Password123", "Player")); - var gmSession = GetValue(service.Login(new LoginRequest("gm", "Password123"))).SessionToken; - var playerSession = GetValue(service.Login(new LoginRequest("player", "Password123"))).SessionToken; + service.Register(new RegisterCommand("gm", "Password123", "GM")); + service.Register(new RegisterCommand("player", "Password123", "Player")); + var gmSession = GetValue(service.Login(new LoginCommand("gm", "Password123"))).SessionToken; + var playerSession = GetValue(service.Login(new LoginCommand("player", "Password123"))).SessionToken; - var gmCampaign = GetValue(service.CreateCampaign(gmSession, new CreateCampaignRequest("Owned", "d6"))); - _ = GetValue(service.CreateCampaign(gmSession, new CreateCampaignRequest("Owned 2", "d6"))); - _ = service.CreateCharacter(playerSession, new CreateCharacterRequest("Joiner", gmCampaign.Id)); + var gmCampaign = GetValue(service.CreateCampaign(gmSession, new CreateCampaignCommand("Owned", "d6"))); + _ = GetValue(service.CreateCampaign(gmSession, new CreateCampaignCommand("Owned 2", "d6"))); + _ = service.CreateCharacter(playerSession, new CreateCharacterCommand("Joiner", gmCampaign.Id)); var playerCampaigns = service.GetCampaigns(playerSession); Assert.True(playerCampaigns.Succeeded); @@ -208,35 +207,35 @@ public sealed class GameServiceTests using var harness = CreateHarness(2, 3, 4); var service = harness.Service; - var invalidCredentials = service.Login(new LoginRequest("", "")); + var invalidCredentials = service.Login(new LoginCommand("", "")); Assert.False(invalidCredentials.Succeeded); service.Logout("missing-session"); - service.Register(new RegisterRequest("gm", "Password123", "GM")); - service.Register(new RegisterRequest("owner", "Password123", "Owner")); - service.Register(new RegisterRequest("other", "Password123", "Other")); + service.Register(new RegisterCommand("gm", "Password123", "GM")); + service.Register(new RegisterCommand("owner", "Password123", "Owner")); + service.Register(new RegisterCommand("other", "Password123", "Other")); - var gmSession = GetValue(service.Login(new LoginRequest("gm", "Password123"))).SessionToken; - var ownerSession = GetValue(service.Login(new LoginRequest("owner", "Password123"))).SessionToken; - var otherSession = GetValue(service.Login(new LoginRequest("other", "Password123"))).SessionToken; + var gmSession = GetValue(service.Login(new LoginCommand("gm", "Password123"))).SessionToken; + var ownerSession = GetValue(service.Login(new LoginCommand("owner", "Password123"))).SessionToken; + var otherSession = GetValue(service.Login(new LoginCommand("other", "Password123"))).SessionToken; - var campaign = GetValue(service.CreateCampaign(gmSession, new CreateCampaignRequest("Main", "d6"))); - var ownerCharacter = GetValue(service.CreateCharacter(ownerSession, new CreateCharacterRequest("Owner Character", campaign.Id))); + var campaign = GetValue(service.CreateCampaign(gmSession, new CreateCampaignCommand("Main", "d6"))); + var ownerCharacter = GetValue(service.CreateCharacter(ownerSession, new CreateCharacterCommand("Owner Character", campaign.Id))); Assert.False(service.GetMe(string.Empty).Succeeded); - Assert.False(service.CreateCampaign(gmSession, new CreateCampaignRequest("", "d6")).Succeeded); + Assert.False(service.CreateCampaign(gmSession, new CreateCampaignCommand("", "d6")).Succeeded); Assert.False(service.GetCampaigns(string.Empty).Succeeded); - Assert.False(service.CreateCharacter(ownerSession, new CreateCharacterRequest("", campaign.Id)).Succeeded); - Assert.False(service.CreateCharacter(string.Empty, new CreateCharacterRequest("Name", campaign.Id)).Succeeded); - Assert.False(service.UpdateCharacter(string.Empty, ownerCharacter.Id, new UpdateCharacterRequest("Renamed", campaign.Id)).Succeeded); - Assert.False(service.UpdateCharacter(ownerSession, Guid.NewGuid(), new UpdateCharacterRequest("Renamed", campaign.Id)).Succeeded); + Assert.False(service.CreateCharacter(ownerSession, new CreateCharacterCommand("", campaign.Id)).Succeeded); + Assert.False(service.CreateCharacter(string.Empty, new CreateCharacterCommand("Name", campaign.Id)).Succeeded); + Assert.False(service.UpdateCharacter(string.Empty, ownerCharacter.Id, new UpdateCharacterCommand("Renamed", campaign.Id)).Succeeded); + Assert.False(service.UpdateCharacter(ownerSession, Guid.NewGuid(), new UpdateCharacterCommand("Renamed", campaign.Id)).Succeeded); Assert.False(service.ActivateCharacter(string.Empty, ownerCharacter.Id).Succeeded); Assert.False(service.ActivateCharacter(gmSession, ownerCharacter.Id).Succeeded); Assert.False(service.GetCurrentCampaignCharacters(string.Empty).Succeeded); - Assert.False(service.CreateSkill(string.Empty, ownerCharacter.Id, new CreateSkillRequest("Stealth", "2D+1")).Succeeded); - Assert.False(service.CreateSkill(ownerSession, Guid.NewGuid(), new CreateSkillRequest("Stealth", "2D+1")).Succeeded); - Assert.False(service.CreateSkill(otherSession, ownerCharacter.Id, new CreateSkillRequest("Stealth", "2D+1")).Succeeded); + Assert.False(service.CreateSkill(string.Empty, ownerCharacter.Id, new CreateSkillCommand("Stealth", "2D+1")).Succeeded); + Assert.False(service.CreateSkill(ownerSession, Guid.NewGuid(), new CreateSkillCommand("Stealth", "2D+1")).Succeeded); + Assert.False(service.CreateSkill(otherSession, ownerCharacter.Id, new CreateSkillCommand("Stealth", "2D+1")).Succeeded); using (var db = harness.CreateDbContext()) { @@ -274,11 +273,11 @@ public sealed class GameServiceTests Assert.Null(db.Users.Single(u => u.UsernameNormalized == "OWNER").ActiveCharacterId); } - var skill = GetValue(service.CreateSkill(ownerSession, ownerCharacter.Id, new CreateSkillRequest("Stealth", "2D+1"))); - Assert.False(service.UpdateSkill(ownerSession, skill.Id, new UpdateSkillRequest("", "2D+1")).Succeeded); - Assert.False(service.UpdateSkill(string.Empty, skill.Id, new UpdateSkillRequest("Stealth", "2D+1")).Succeeded); - Assert.False(service.UpdateSkill(ownerSession, skill.Id, new UpdateSkillRequest("Stealth", "bad")).Succeeded); - Assert.False(service.RollSkill(string.Empty, skill.Id, new RollSkillRequest("public")).Succeeded); + var skill = GetValue(service.CreateSkill(ownerSession, ownerCharacter.Id, new CreateSkillCommand("Stealth", "2D+1"))); + Assert.False(service.UpdateSkill(ownerSession, skill.Id, new UpdateSkillCommand("", "2D+1")).Succeeded); + Assert.False(service.UpdateSkill(string.Empty, skill.Id, new UpdateSkillCommand("Stealth", "2D+1")).Succeeded); + Assert.False(service.UpdateSkill(ownerSession, skill.Id, new UpdateSkillCommand("Stealth", "bad")).Succeeded); + Assert.False(service.RollSkill(string.Empty, skill.Id, new RollSkillCommand("public")).Succeeded); using (var db = harness.CreateDbContext()) { @@ -288,7 +287,7 @@ public sealed class GameServiceTests } using var invalidExpressionHarness = CreateHarnessFromPath(harness.DbPath, 2, 3, 4); - Assert.False(invalidExpressionHarness.Service.RollSkill(ownerSession, skill.Id, new RollSkillRequest("public")).Succeeded); + Assert.False(invalidExpressionHarness.Service.RollSkill(ownerSession, skill.Id, new RollSkillCommand("public")).Succeeded); Assert.False(service.GetCampaignLog(string.Empty, campaign.Id).Succeeded); } @@ -298,20 +297,20 @@ public sealed class GameServiceTests using var harness = CreateHarness(); var service = harness.Service; - service.Register(new RegisterRequest("gm", "Password123", "GM")); - service.Register(new RegisterRequest("owner", "Password123", "Owner")); - service.Register(new RegisterRequest("other", "Password123", "Other")); + service.Register(new RegisterCommand("gm", "Password123", "GM")); + service.Register(new RegisterCommand("owner", "Password123", "Owner")); + service.Register(new RegisterCommand("other", "Password123", "Other")); - var gmSession = GetValue(service.Login(new LoginRequest("gm", "Password123"))).SessionToken; - var ownerSession = GetValue(service.Login(new LoginRequest("owner", "Password123"))).SessionToken; - var otherSession = GetValue(service.Login(new LoginRequest("other", "Password123"))).SessionToken; + var gmSession = GetValue(service.Login(new LoginCommand("gm", "Password123"))).SessionToken; + var ownerSession = GetValue(service.Login(new LoginCommand("owner", "Password123"))).SessionToken; + var otherSession = GetValue(service.Login(new LoginCommand("other", "Password123"))).SessionToken; - var campaign = GetValue(service.CreateCampaign(gmSession, new CreateCampaignRequest("Main", "d6"))); - var ownerCharacter = GetValue(service.CreateCharacter(ownerSession, new CreateCharacterRequest("Owner Character", campaign.Id))); - var otherCharacter = GetValue(service.CreateCharacter(otherSession, new CreateCharacterRequest("Other Character", campaign.Id))); + var campaign = GetValue(service.CreateCampaign(gmSession, new CreateCampaignCommand("Main", "d6"))); + var ownerCharacter = GetValue(service.CreateCharacter(ownerSession, new CreateCharacterCommand("Owner Character", campaign.Id))); + var otherCharacter = GetValue(service.CreateCharacter(otherSession, new CreateCharacterCommand("Other Character", campaign.Id))); - var ownerSkill = GetValue(service.CreateSkill(ownerSession, ownerCharacter.Id, new CreateSkillRequest("Stealth", "2D+1"))); - _ = GetValue(service.CreateSkill(otherSession, otherCharacter.Id, new CreateSkillRequest("Perception", "1D+2"))); + var ownerSkill = GetValue(service.CreateSkill(ownerSession, ownerCharacter.Id, new CreateSkillCommand("Stealth", "2D+1"))); + _ = GetValue(service.CreateSkill(otherSession, otherCharacter.Id, new CreateSkillCommand("Perception", "1D+2"))); var ownerView = GetValue(service.GetCampaign(ownerSession, campaign.Id)); Assert.Single(ownerView.Characters); @@ -329,17 +328,21 @@ public sealed class GameServiceTests var d6 = DiceRules.ParseExpression(RulesetKind.D6, "5D+4"); var dnd = DiceRules.ParseExpression(RulesetKind.Dnd5e, "2d12+2"); + var emptyExpression = DiceRules.ParseExpression(RulesetKind.Dnd5e, ""); var badFormat = DiceRules.ParseExpression(RulesetKind.Dnd5e, "abc"); var tooManyDice = DiceRules.ParseExpression(RulesetKind.D6, "51D+1"); var tooManySides = DiceRules.ParseExpression(RulesetKind.Dnd5e, "1d1001"); var tooLargeModifier = DiceRules.ParseExpression(RulesetKind.Dnd5e, "1d20+1001"); + var unknownRulesetExpression = DiceRules.ParseExpression((RulesetKind)99, "1d20+1"); Assert.True(d6.Succeeded); Assert.True(dnd.Succeeded); + Assert.False(emptyExpression.Succeeded); Assert.False(badFormat.Succeeded); Assert.False(tooManyDice.Succeeded); Assert.False(tooManySides.Succeeded); Assert.False(tooLargeModifier.Succeeded); + Assert.False(unknownRulesetExpression.Succeeded); Assert.Equal("d6", DiceRules.ToRulesetId(RulesetKind.D6)); Assert.Equal("dnd5e", DiceRules.ToRulesetId(RulesetKind.Dnd5e)); @@ -476,3 +479,4 @@ public sealed class GameServiceTests } } } + diff --git a/RpgRoller/Api/ApiEndpointRegistration.cs b/RpgRoller/Api/ApiEndpointRegistration.cs new file mode 100644 index 0000000..1660028 --- /dev/null +++ b/RpgRoller/Api/ApiEndpointRegistration.cs @@ -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(); + + authenticatedApi.MapMeEndpoints(); + authenticatedApi.MapCampaignEndpoints(); + authenticatedApi.MapCharacterEndpoints(); + authenticatedApi.MapSkillEndpoints(); + authenticatedApi.MapStateEventEndpoints(); + } +} diff --git a/RpgRoller/Api/ApiResultMapper.cs b/RpgRoller/Api/ApiResultMapper.cs new file mode 100644 index 0000000..482d7aa --- /dev/null +++ b/RpgRoller/Api/ApiResultMapper.cs @@ -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, BadRequest, UnauthorizedHttpResult> ToApiResult(ServiceResult 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 ToBadRequest(ServiceError error) + { + return TypedResults.BadRequest(new ApiError(error.Message)); + } +} diff --git a/RpgRoller/Api/AuthEndpoints.cs b/RpgRoller/Api/AuthEndpoints.cs new file mode 100644 index 0000000..5f02b47 --- /dev/null +++ b/RpgRoller/Api/AuthEndpoints.cs @@ -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, BadRequest> (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, BadRequest> (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; + } +} diff --git a/RpgRoller/Api/CampaignEndpoints.cs b/RpgRoller/Api/CampaignEndpoints.cs new file mode 100644 index 0000000..3835376 --- /dev/null +++ b/RpgRoller/Api/CampaignEndpoints.cs @@ -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; + } +} diff --git a/RpgRoller/Api/CharacterEndpoints.cs b/RpgRoller/Api/CharacterEndpoints.cs new file mode 100644 index 0000000..c605579 --- /dev/null +++ b/RpgRoller/Api/CharacterEndpoints.cs @@ -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; + } +} diff --git a/RpgRoller/Api/MeEndpoints.cs b/RpgRoller/Api/MeEndpoints.cs new file mode 100644 index 0000000..2f239f0 --- /dev/null +++ b/RpgRoller/Api/MeEndpoints.cs @@ -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, BadRequest, UnauthorizedHttpResult> (HttpContext context, IGameService game) => + { + var result = game.GetMe(context.GetRequiredSessionToken()); + return ApiResultMapper.ToApiResult(result); + }); + + return group; + } +} diff --git a/RpgRoller/Api/RequestMappings.cs b/RpgRoller/Api/RequestMappings.cs new file mode 100644 index 0000000..7fa96f5 --- /dev/null +++ b/RpgRoller/Api/RequestMappings.cs @@ -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); + } +} diff --git a/RpgRoller/Api/RequireSessionTokenFilter.cs b/RpgRoller/Api/RequireSessionTokenFilter.cs new file mode 100644 index 0000000..52d3e0a --- /dev/null +++ b/RpgRoller/Api/RequireSessionTokenFilter.cs @@ -0,0 +1,15 @@ +namespace RpgRoller.Api; + +internal sealed class RequireSessionTokenFilter : IEndpointFilter +{ + public ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + { + if (!context.HttpContext.TryReadSessionTokenFromCookie(out var sessionToken)) + { + return ValueTask.FromResult(TypedResults.Unauthorized()); + } + + context.HttpContext.StoreSessionToken(sessionToken); + return next(context); + } +} diff --git a/RpgRoller/Api/SessionCookie.cs b/RpgRoller/Api/SessionCookie.cs new file mode 100644 index 0000000..9d1a0f6 --- /dev/null +++ b/RpgRoller/Api/SessionCookie.cs @@ -0,0 +1,6 @@ +namespace RpgRoller.Api; + +internal static class SessionCookie +{ + public const string Name = "rpgroller_session"; +} diff --git a/RpgRoller/Api/SessionTokenHttpContextExtensions.cs b/RpgRoller/Api/SessionTokenHttpContextExtensions.cs new file mode 100644 index 0000000..af92a69 --- /dev/null +++ b/RpgRoller/Api/SessionTokenHttpContextExtensions.cs @@ -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."); + } +} diff --git a/RpgRoller/Api/SkillEndpoints.cs b/RpgRoller/Api/SkillEndpoints.cs new file mode 100644 index 0000000..9c3ed97 --- /dev/null +++ b/RpgRoller/Api/SkillEndpoints.cs @@ -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; + } +} diff --git a/RpgRoller/Api/StateEventEndpoints.cs b/RpgRoller/Api/StateEventEndpoints.cs new file mode 100644 index 0000000..a9c30e4 --- /dev/null +++ b/RpgRoller/Api/StateEventEndpoints.cs @@ -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 ( + 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; + } +} diff --git a/RpgRoller/Api/SystemEndpoints.cs b/RpgRoller/Api/SystemEndpoints.cs new file mode 100644 index 0000000..16517c2 --- /dev/null +++ b/RpgRoller/Api/SystemEndpoints.cs @@ -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; + } +} diff --git a/RpgRoller/Hosting/ApplicationInitializationExtensions.cs b/RpgRoller/Hosting/ApplicationInitializationExtensions.cs new file mode 100644 index 0000000..c8e9259 --- /dev/null +++ b/RpgRoller/Hosting/ApplicationInitializationExtensions.cs @@ -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>(); + using var db = dbFactory.CreateDbContext(); + db.Database.EnsureCreated(); + _ = scope.ServiceProvider.GetRequiredService(); + } +} diff --git a/RpgRoller/Hosting/ServiceCollectionExtensions.cs b/RpgRoller/Hosting/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..3f7142d --- /dev/null +++ b/RpgRoller/Hosting/ServiceCollectionExtensions.cs @@ -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, PasswordHasher>(); + services.AddDbContextFactory(options => options.UseSqlite(sqliteConnectionString)); + services.AddSingleton(); + services.AddSingleton(); + + 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); + } + } +} diff --git a/RpgRoller/Program.cs b/RpgRoller/Program.cs index 15b5bf3..bc2ee7c 100644 --- a/RpgRoller/Program.cs +++ b/RpgRoller/Program.cs @@ -1,324 +1,16 @@ -using Microsoft.AspNetCore.Http.HttpResults; -using Microsoft.AspNetCore.Identity; -using Microsoft.Data.Sqlite; -using Microsoft.EntityFrameworkCore; -using RpgRoller.Contracts; -using RpgRoller.Data; -using RpgRoller.Domain; -using RpgRoller.Services; - -const string SessionCookieName = "rpgroller_session"; +using RpgRoller.Api; +using RpgRoller.Hosting; var builder = WebApplication.CreateBuilder(args); -var sqliteConnectionString = builder.Configuration.GetConnectionString("RpgRoller") ?? "Data Source=App_Data/rpgroller.db"; -EnsureSqliteDataDirectory(sqliteConnectionString, builder.Environment.ContentRootPath); - -builder.Services.AddSingleton, PasswordHasher>(); -builder.Services.AddDbContextFactory(options => options.UseSqlite(sqliteConnectionString)); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); +builder.Services.AddRpgRollerCore(builder.Configuration, builder.Environment); var app = builder.Build(); - -using (var scope = app.Services.CreateScope()) -{ - var dbFactory = scope.ServiceProvider.GetRequiredService>(); - using var db = dbFactory.CreateDbContext(); - db.Database.EnsureCreated(); - _ = scope.ServiceProvider.GetRequiredService(); -} +app.InitializeRpgRollerState(); app.UseDefaultFiles(); app.UseStaticFiles(); -app.MapGet("/api/health", () => TypedResults.Ok(new HealthResponse("ok"))); -app.MapGet("/api/rulesets", (IGameService game) => TypedResults.Ok(game.GetRulesets())); - -app.MapPost("/api/auth/register", Results, BadRequest> (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, BadRequest> (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, BadRequest, 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 ( - Guid campaignId, - HttpContext context, - IGameService game) => -{ - if (!TryGetSessionToken(context, out var sessionToken)) - { - return TypedResults.Unauthorized(); - } - - var versionResult = game.GetCampaignVersion(sessionToken, campaignId); - if (!versionResult.Succeeded) - { - return versionResult.Error!.Code == "unauthorized" - ? TypedResults.Unauthorized() - : TypedResults.BadRequest(new ApiError(versionResult.Error.Message)); - } - - context.Response.Headers.CacheControl = "no-cache"; - context.Response.Headers.Connection = "keep-alive"; - context.Response.ContentType = "text/event-stream"; - - var lastVersion = versionResult.Value; - await context.Response.WriteAsync($"event: state\ndata: {{\"campaignId\":\"{campaignId}\",\"version\":{lastVersion}}}\n\n"); - await context.Response.Body.FlushAsync(); - - try - { - while (!context.RequestAborted.IsCancellationRequested) - { - await Task.Delay(TimeSpan.FromSeconds(1), context.RequestAborted); - - var currentVersionResult = game.GetCampaignVersion(sessionToken, campaignId); - if (!currentVersionResult.Succeeded) - { - break; - } - - if (currentVersionResult.Value != lastVersion) - { - lastVersion = currentVersionResult.Value; - await context.Response.WriteAsync($"event: state\ndata: {{\"campaignId\":\"{campaignId}\",\"version\":{lastVersion}}}\n\n"); - } - else - { - await context.Response.WriteAsync(": heartbeat\n\n"); - } - - await context.Response.Body.FlushAsync(); - } - } - catch (OperationCanceledException) - { - } - - return TypedResults.Empty; -}); - +app.MapRpgRollerApi(); app.Run(); -return; - -static bool TryGetSessionToken(HttpContext context, out string sessionToken) -{ - sessionToken = context.Request.Cookies[SessionCookieName] ?? string.Empty; - return !string.IsNullOrWhiteSpace(sessionToken); -} - -static Results, BadRequest, UnauthorizedHttpResult> ToApiResult(ServiceResult 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 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; diff --git a/RpgRoller/Services/GameService.cs b/RpgRoller/Services/GameService.cs index 3811df6..184ea71 100644 --- a/RpgRoller/Services/GameService.cs +++ b/RpgRoller/Services/GameService.cs @@ -35,7 +35,7 @@ public sealed class GameService : IGameService .ToArray(); } - public ServiceResult Register(RegisterRequest request) + public ServiceResult Register(RegisterCommand request) { if (string.IsNullOrWhiteSpace(request.Username)) { @@ -81,7 +81,7 @@ public sealed class GameService : IGameService } } - public ServiceResult<(UserSummary User, string SessionToken)> Login(LoginRequest request) + public ServiceResult<(UserSummary User, string SessionToken)> Login(LoginCommand request) { if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password)) { @@ -162,7 +162,7 @@ public sealed class GameService : IGameService } } - public ServiceResult CreateCampaign(string sessionToken, CreateCampaignRequest request) + public ServiceResult CreateCampaign(string sessionToken, CreateCampaignCommand request) { if (string.IsNullOrWhiteSpace(request.Name)) { @@ -263,7 +263,7 @@ public sealed class GameService : IGameService } } - public ServiceResult CreateCharacter(string sessionToken, CreateCharacterRequest request) + public ServiceResult CreateCharacter(string sessionToken, CreateCharacterCommand request) { if (string.IsNullOrWhiteSpace(request.Name)) { @@ -299,7 +299,7 @@ public sealed class GameService : IGameService } } - public ServiceResult UpdateCharacter(string sessionToken, Guid characterId, UpdateCharacterRequest request) + public ServiceResult UpdateCharacter(string sessionToken, Guid characterId, UpdateCharacterCommand request) { if (string.IsNullOrWhiteSpace(request.Name)) { @@ -399,7 +399,7 @@ public sealed class GameService : IGameService } } - public ServiceResult CreateSkill(string sessionToken, Guid characterId, CreateSkillRequest request) + public ServiceResult CreateSkill(string sessionToken, Guid characterId, CreateSkillCommand request) { if (string.IsNullOrWhiteSpace(request.Name)) { @@ -447,7 +447,7 @@ public sealed class GameService : IGameService } } - public ServiceResult UpdateSkill(string sessionToken, Guid skillId, UpdateSkillRequest request) + public ServiceResult UpdateSkill(string sessionToken, Guid skillId, UpdateSkillCommand request) { if (string.IsNullOrWhiteSpace(request.Name)) { @@ -489,7 +489,7 @@ public sealed class GameService : IGameService } } - public ServiceResult RollSkill(string sessionToken, Guid skillId, RollSkillRequest request) + public ServiceResult RollSkill(string sessionToken, Guid skillId, RollSkillCommand request) { lock (m_Gate) { diff --git a/RpgRoller/Services/IGameService.cs b/RpgRoller/Services/IGameService.cs index 473ddef..23fc29c 100644 --- a/RpgRoller/Services/IGameService.cs +++ b/RpgRoller/Services/IGameService.cs @@ -7,25 +7,25 @@ public interface IGameService { IReadOnlyList GetRulesets(); - ServiceResult Register(RegisterRequest request); - ServiceResult<(UserSummary User, string SessionToken)> Login(LoginRequest request); + ServiceResult Register(RegisterCommand request); + ServiceResult<(UserSummary User, string SessionToken)> Login(LoginCommand request); void Logout(string sessionToken); UserSummary? GetUserBySession(string sessionToken); ServiceResult GetMe(string sessionToken); - ServiceResult CreateCampaign(string sessionToken, CreateCampaignRequest request); + ServiceResult CreateCampaign(string sessionToken, CreateCampaignCommand request); ServiceResult> GetCampaigns(string sessionToken); ServiceResult GetCampaign(string sessionToken, Guid campaignId); - ServiceResult CreateCharacter(string sessionToken, CreateCharacterRequest request); - ServiceResult UpdateCharacter(string sessionToken, Guid characterId, UpdateCharacterRequest request); + ServiceResult CreateCharacter(string sessionToken, CreateCharacterCommand request); + ServiceResult UpdateCharacter(string sessionToken, Guid characterId, UpdateCharacterCommand request); ServiceResult ActivateCharacter(string sessionToken, Guid characterId); ServiceResult> GetCurrentCampaignCharacters(string sessionToken); - ServiceResult CreateSkill(string sessionToken, Guid characterId, CreateSkillRequest request); - ServiceResult UpdateSkill(string sessionToken, Guid skillId, UpdateSkillRequest request); + ServiceResult CreateSkill(string sessionToken, Guid characterId, CreateSkillCommand request); + ServiceResult UpdateSkill(string sessionToken, Guid skillId, UpdateSkillCommand request); - ServiceResult RollSkill(string sessionToken, Guid skillId, RollSkillRequest request); + ServiceResult RollSkill(string sessionToken, Guid skillId, RollSkillCommand request); ServiceResult> GetCampaignLog(string sessionToken, Guid campaignId); ServiceResult GetCampaignVersion(string sessionToken, Guid campaignId); diff --git a/RpgRoller/Services/ServiceCommands.cs b/RpgRoller/Services/ServiceCommands.cs new file mode 100644 index 0000000..e9d5f9c --- /dev/null +++ b/RpgRoller/Services/ServiceCommands.cs @@ -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); diff --git a/RpgRoller/frontend/app.ts b/RpgRoller/frontend/app.ts index ba23372..97faa86 100644 --- a/RpgRoller/frontend/app.ts +++ b/RpgRoller/frontend/app.ts @@ -1,193 +1,143 @@ import { activateCharacter, - type CampaignDetails, - type CampaignLogEntry, - type CampaignSummary, createCampaign, createCharacter, createSkill, - getCampaign, - getCampaignLog, - getCampaigns, - getHealth, - getMe, - getRulesets, loginUser, logoutUser, registerUser, rollSkill, - type RulesetDefinition, - type SkillSummary, - type UserSummary, updateSkill } from "./generated/api-client.js"; +import { runAction, setMessage } from "./app/actions.js"; +import { getAppElements } from "./app/dom.js"; +import { closeStateEvents, connectStateEvents } from "./app/events.js"; +import { + ensureRulesets, + refreshHealth, + reloadCampaignLog, + reloadCampaigns, + reloadSelectedCampaign, + reloadSession +} from "./app/loaders.js"; +import { renderAll, renderCampaignDetails, renderCampaignLog, renderCampaignMeta, renderCharacterSelect, renderSkillSelect } from "./app/render.js"; +import { createInitialState, resetAuthenticatedState, resetStateAfterLogout, syncSelectedCharacter } from "./app/state.js"; -const healthElement = mustElement("health"); -const messageElement = mustElement("message"); -const campaignMetaElement = mustElement("campaign-meta"); -const campaignDetailsElement = mustElement("campaign-details"); -const rollResultElement = mustElement("roll-result"); -const campaignLogElement = mustElement("campaign-log"); +const elements = getAppElements(); +const state = createInitialState(); -const registerForm = mustForm("register-form"); -const loginForm = mustForm("login-form"); -const logoutButton = mustButton("logout-button"); - -const registerUsername = mustInput("register-username"); -const registerDisplayName = mustInput("register-display-name"); -const registerPassword = mustInput("register-password"); -const loginUsername = mustInput("login-username"); -const loginPassword = mustInput("login-password"); - -const campaignForm = mustForm("campaign-form"); -const campaignNameInput = mustInput("campaign-name"); -const campaignRulesetSelect = mustSelect("campaign-ruleset"); -const campaignSelect = mustSelect("campaign-select"); -const refreshCampaignButton = mustButton("refresh-campaign-button"); - -const characterForm = mustForm("character-form"); -const characterNameInput = mustInput("character-name"); -const characterSelect = mustSelect("character-select"); -const activateCharacterButton = mustButton("activate-character-button"); - -const skillForm = mustForm("skill-form"); -const skillNameInput = mustInput("skill-name"); -const skillExpressionInput = mustInput("skill-expression"); -const updateSkillButton = mustButton("update-skill-button"); -const skillSelect = mustSelect("skill-select"); - -const rollForm = mustForm("roll-form"); -const rollVisibilitySelect = mustSelect("roll-visibility"); - -type AppState = { - user: UserSummary | null; - activeCharacterId: string | null; - selectedCharacterId: string | null; - campaigns: CampaignSummary[]; - selectedCampaignId: string | null; - selectedCampaign: CampaignDetails | null; - campaignLog: CampaignLogEntry[]; - rulesets: RulesetDefinition[]; - eventSource: EventSource | null; -}; - -const state: AppState = { - user: null, - activeCharacterId: null, - selectedCharacterId: null, - campaigns: [], - selectedCampaignId: null, - selectedCampaign: null, - campaignLog: [], - rulesets: [], - eventSource: null -}; - -registerForm.addEventListener("submit", async (event) => { +elements.registerForm.addEventListener("submit", async (event) => { event.preventDefault(); - await runAction(async () => { + await runAppAction(async () => { await registerUser({ - username: registerUsername.value.trim(), - displayName: registerDisplayName.value.trim(), - password: registerPassword.value + username: elements.registerUsername.value.trim(), + displayName: elements.registerDisplayName.value.trim(), + password: elements.registerPassword.value }); - registerPassword.value = ""; - setMessage("Registration successful. You can log in now.", false); + elements.registerPassword.value = ""; + setStatus("Registration successful. You can log in now.", false); }); }); -loginForm.addEventListener("submit", async (event) => { +elements.loginForm.addEventListener("submit", async (event) => { event.preventDefault(); - await runAction(async () => { + await runAppAction(async () => { await loginUser({ - username: loginUsername.value.trim(), - password: loginPassword.value + username: elements.loginUsername.value.trim(), + password: elements.loginPassword.value }); - loginPassword.value = ""; + elements.loginPassword.value = ""; await reloadAll(); - setMessage("Logged in.", false); + setStatus("Logged in.", false); }); }); -logoutButton.addEventListener("click", async () => { - await runAction(async () => { +elements.logoutButton.addEventListener("click", async () => { + await runAppAction(async () => { await logoutUser(); - resetStateAfterLogout(); - renderAll(); - setMessage("Logged out.", false); + resetStateAfterLogout(state); + closeStateEvents(state); + renderAll(state, elements); + setStatus("Logged out.", false); }); }); -campaignForm.addEventListener("submit", async (event) => { +elements.campaignForm.addEventListener("submit", async (event) => { event.preventDefault(); - await runAction(async () => { + await runAppAction(async () => { const createdCampaign = await createCampaign({ - name: campaignNameInput.value.trim(), - rulesetId: campaignRulesetSelect.value + name: elements.campaignNameInput.value.trim(), + rulesetId: elements.campaignRulesetSelect.value }); - campaignNameInput.value = ""; - await reloadCampaigns(createdCampaign.id); - setMessage("Campaign created.", false); + elements.campaignNameInput.value = ""; + await reloadCampaigns(state, createdCampaign.id); + await reloadSelectedCampaign(state); + await reloadCampaignLog(state); + syncEventStream(); + renderAll(state, elements); + setStatus("Campaign created.", false); }); }); -campaignSelect.addEventListener("change", async () => { - await runAction(async () => { - const selected = campaignSelect.value; +elements.campaignSelect.addEventListener("change", async () => { + await runAppAction(async () => { + const selected = elements.campaignSelect.value; state.selectedCampaignId = selected.length > 0 ? selected : null; - await reloadSelectedCampaign(); - syncSelectedCharacter(); - renderCharacterSelect(); - renderSkillSelect(); - connectStateEvents(); - renderCampaignMeta(); - renderCampaignDetails(); - renderCampaignLog(); + await reloadSelectedCampaign(state); + syncSelectedCharacter(state); + renderCharacterSelect(state, elements); + renderSkillSelect(state, elements); + syncEventStream(); + renderCampaignMeta(state, elements); + renderCampaignDetails(state, elements); + renderCampaignLog(state, elements); }); }); -characterSelect.addEventListener("change", () => { - state.selectedCharacterId = characterSelect.value.length > 0 ? characterSelect.value : null; - renderSkillSelect(); +elements.characterSelect.addEventListener("change", () => { + state.selectedCharacterId = elements.characterSelect.value.length > 0 ? elements.characterSelect.value : null; + renderSkillSelect(state, elements); }); -refreshCampaignButton.addEventListener("click", async () => { - await runAction(async () => { - await reloadSelectedCampaign(); - renderAll(); - setMessage("Campaign refreshed.", false); +elements.refreshCampaignButton.addEventListener("click", async () => { + await runAppAction(async () => { + await reloadSelectedCampaign(state); + await reloadCampaignLog(state); + renderAll(state, elements); + setStatus("Campaign refreshed.", false); }); }); -characterForm.addEventListener("submit", async (event) => { +elements.characterForm.addEventListener("submit", async (event) => { event.preventDefault(); - await runAction(async () => { + await runAppAction(async () => { if (!state.selectedCampaignId) { throw new Error("Select a campaign first."); } await createCharacter({ - name: characterNameInput.value.trim(), + name: elements.characterNameInput.value.trim(), campaignId: state.selectedCampaignId }); - characterNameInput.value = ""; - await reloadSelectedCampaign(); - await reloadCampaignLog(); - setMessage("Character created.", false); + elements.characterNameInput.value = ""; + await reloadSelectedCampaign(state); + await reloadCampaignLog(state); + renderAll(state, elements); + setStatus("Character created.", false); }); }); -activateCharacterButton.addEventListener("click", async () => { - await runAction(async () => { - const characterId = characterSelect.value; +elements.activateCharacterButton.addEventListener("click", async () => { + await runAppAction(async () => { + const characterId = elements.characterSelect.value; if (characterId.length === 0) { throw new Error("Select a character to activate."); } @@ -196,426 +146,107 @@ activateCharacterButton.addEventListener("click", async () => { state.activeCharacterId = characterId; state.selectedCharacterId = characterId; await reloadAll(); - setMessage("Active character updated.", false); + setStatus("Active character updated.", false); }); }); -skillForm.addEventListener("submit", async (event) => { +elements.skillForm.addEventListener("submit", async (event) => { event.preventDefault(); - await runAction(async () => { - const characterId = characterSelect.value; + await runAppAction(async () => { + const characterId = elements.characterSelect.value; if (characterId.length === 0) { throw new Error("Select a character first."); } await createSkill(characterId, { - name: skillNameInput.value.trim(), - diceRollDefinition: skillExpressionInput.value.trim() + name: elements.skillNameInput.value.trim(), + diceRollDefinition: elements.skillExpressionInput.value.trim() }); - skillNameInput.value = ""; - skillExpressionInput.value = ""; - await reloadSelectedCampaign(); - setMessage("Skill created.", false); + elements.skillNameInput.value = ""; + elements.skillExpressionInput.value = ""; + await reloadSelectedCampaign(state); + renderAll(state, elements); + setStatus("Skill created.", false); }); }); -updateSkillButton.addEventListener("click", async () => { - await runAction(async () => { - const selectedSkillId = skillSelect.value; +elements.updateSkillButton.addEventListener("click", async () => { + await runAppAction(async () => { + const selectedSkillId = elements.skillSelect.value; if (selectedSkillId.length === 0) { throw new Error("Select a skill to update."); } await updateSkill(selectedSkillId, { - name: skillNameInput.value.trim(), - diceRollDefinition: skillExpressionInput.value.trim() + name: elements.skillNameInput.value.trim(), + diceRollDefinition: elements.skillExpressionInput.value.trim() }); - await reloadSelectedCampaign(); - setMessage("Skill updated.", false); + await reloadSelectedCampaign(state); + renderAll(state, elements); + setStatus("Skill updated.", false); }); }); -rollForm.addEventListener("submit", async (event) => { +elements.rollForm.addEventListener("submit", async (event) => { event.preventDefault(); - await runAction(async () => { - const selectedSkillId = skillSelect.value; + await runAppAction(async () => { + const selectedSkillId = elements.skillSelect.value; if (selectedSkillId.length === 0) { throw new Error("Select a skill to roll."); } - const roll = await rollSkill(selectedSkillId, { visibility: rollVisibilitySelect.value }); - rollResultElement.textContent = `Roll ${roll.rollId}: ${roll.breakdown} (${roll.visibility})`; + const roll = await rollSkill(selectedSkillId, { visibility: elements.rollVisibilitySelect.value }); + elements.rollResultElement.textContent = `Roll ${roll.rollId}: ${roll.breakdown} (${roll.visibility})`; - await reloadCampaignLog(); - setMessage("Roll recorded.", false); + await reloadCampaignLog(state); + renderCampaignLog(state, elements); + setStatus("Roll recorded.", false); }); }); -await runAction(async () => { - await refreshHealth(); +await runAppAction(async () => { + await refreshHealth(elements); await reloadAll(); - setMessage("Ready.", false); + setStatus("Ready.", false); }); -async function refreshHealth(): Promise { - const health = await getHealth(); - healthElement.textContent = `API status: ${health.status}`; -} - async function reloadAll(): Promise { - await ensureRulesets(); - await reloadSession(); + await ensureRulesets(state, elements); + await reloadSession(state); if (state.user) { - await reloadCampaigns(state.selectedCampaignId); - await reloadSelectedCampaign(); - await reloadCampaignLog(); - connectStateEvents(); + await reloadCampaigns(state, state.selectedCampaignId); + await reloadSelectedCampaign(state); + await reloadCampaignLog(state); + syncEventStream(); } else { - resetAuthenticatedState(); + resetAuthenticatedState(state); + closeStateEvents(state); } - renderAll(); + renderAll(state, elements); } -async function reloadSession(): Promise { - try { - const me = await getMe(); - state.user = me.user; - state.activeCharacterId = me.activeCharacterId ?? null; - state.selectedCampaignId = me.currentCampaignId ?? state.selectedCampaignId; - } - catch { - state.user = null; - state.activeCharacterId = null; - } -} - -async function ensureRulesets(): Promise { - if (state.rulesets.length > 0) { - return; - } - - state.rulesets = await getRulesets(); - campaignRulesetSelect.innerHTML = state.rulesets - .map((ruleset) => ``) - .join(""); -} - -async function reloadCampaigns(preferredCampaignId: string | null): Promise { - 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 { - if (!state.selectedCampaignId) { - state.selectedCampaign = null; - return; - } - - state.selectedCampaign = await getCampaign(state.selectedCampaignId); - syncSelectedCharacter(); -} - -async function reloadCampaignLog(): Promise { - if (!state.selectedCampaignId) { - state.campaignLog = []; - return; - } - - state.campaignLog = await getCampaignLog(state.selectedCampaignId); -} - -function connectStateEvents(): void { - if (!state.selectedCampaignId || !state.user) { - closeStateEvents(); - return; - } - - if (state.eventSource && state.eventSource.url.endsWith(`campaignId=${state.selectedCampaignId}`)) { - return; - } - - closeStateEvents(); - state.eventSource = new EventSource(`/api/events/state?campaignId=${state.selectedCampaignId}`); - - state.eventSource.addEventListener("state", () => { - void runAction(async () => { - await reloadSelectedCampaign(); - await reloadCampaignLog(); - renderAll(); +function syncEventStream(): void { + connectStateEvents(state, () => { + void runAppAction(async () => { + await reloadSelectedCampaign(state); + await reloadCampaignLog(state); + renderAll(state, elements); }); }); - - state.eventSource.onerror = () => { - closeStateEvents(); - }; } -function closeStateEvents(): void { - if (state.eventSource) { - state.eventSource.close(); - state.eventSource = null; - } +async function runAppAction(action: () => Promise): Promise { + await runAction(action, (message) => { + setStatus(message, true); + }); } -function resetStateAfterLogout(): void { - state.user = null; - resetAuthenticatedState(); - closeStateEvents(); -} - -function resetAuthenticatedState(): void { - state.activeCharacterId = null; - state.selectedCharacterId = null; - state.campaigns = []; - state.selectedCampaignId = null; - state.selectedCampaign = null; - state.campaignLog = []; -} - -function renderAll(): void { - renderCampaignSelect(); - renderCampaignMeta(); - renderCampaignDetails(); - renderCharacterSelect(); - renderSkillSelect(); - renderCampaignLog(); -} - -function renderCampaignSelect(): void { - campaignSelect.innerHTML = state.campaigns - .map((campaign) => { - const selected = campaign.id === state.selectedCampaignId ? " selected" : ""; - return ``; - }) - .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) => `
  • ${character.name} (${character.id})
  • `) - .join(""); - - const skills = state.selectedCampaign.skills - .map((skill) => `
  • ${skill.name} [${skill.diceRollDefinition}]
  • `) - .join(""); - - campaignDetailsElement.innerHTML = ` -

    GM: ${state.selectedCampaign.gm.displayName}

    -

    Characters visible to you: ${state.selectedCampaign.characters.length}

    -
      ${characters}
    -

    Skills visible to you: ${state.selectedCampaign.skills.length}

    -
      ${skills}
    - `; -} - -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 ``; - }) - .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) => ``) - .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 = "
  • No rolls yet.
  • "; - return; - } - - campaignLogElement.innerHTML = state.campaignLog - .map((entry) => ` -
  • - ${entry.visibility.toUpperCase()} - ${entry.breakdown} - ${new Date(entry.timestampUtc).toLocaleString()} -
  • - `) - .join(""); -} - -async function runAction(action: () => Promise): Promise { - try { - await action(); - } - catch (error: unknown) { - setMessage(formatError(error), true); - } -} - -function setMessage(message: string, isError: boolean): void { - messageElement.textContent = message; - messageElement.style.color = isError ? "#b91c1c" : "#1d4ed8"; -} - -function formatError(error: unknown): string { - if (error instanceof Error) { - return error.message; - } - - return String(error); -} - -function mustElement(id: string): HTMLElement { - const element = document.getElementById(id); - if (!(element instanceof HTMLElement)) { - throw new Error(`Missing HTMLElement: ${id}`); - } - - return element; -} - -function mustInput(id: string): HTMLInputElement { - const element = document.getElementById(id); - if (!(element instanceof HTMLInputElement)) { - throw new Error(`Missing HTMLInputElement: ${id}`); - } - - return element; -} - -function mustSelect(id: string): HTMLSelectElement { - const element = document.getElementById(id); - if (!(element instanceof HTMLSelectElement)) { - throw new Error(`Missing HTMLSelectElement: ${id}`); - } - - return element; -} - -function mustForm(id: string): HTMLFormElement { - const element = document.getElementById(id); - if (!(element instanceof HTMLFormElement)) { - throw new Error(`Missing HTMLFormElement: ${id}`); - } - - return element; -} - -function mustButton(id: string): HTMLButtonElement { - const element = document.getElementById(id); - if (!(element instanceof HTMLButtonElement)) { - throw new Error(`Missing HTMLButtonElement: ${id}`); - } - - return element; -} - -function syncSelectedCharacter(): void { - if (!state.selectedCampaign) { - state.selectedCharacterId = null; - return; - } - - const availableIds = new Set(state.selectedCampaign.characters.map((character) => character.id)); - if (state.selectedCharacterId && availableIds.has(state.selectedCharacterId)) { - return; - } - - if (state.activeCharacterId && availableIds.has(state.activeCharacterId)) { - state.selectedCharacterId = state.activeCharacterId; - return; - } - - state.selectedCharacterId = state.selectedCampaign.characters.length > 0 - ? state.selectedCampaign.characters[0].id - : null; -} - -function resolveSelectedCharacterId(): string | null { - if (!state.selectedCampaign) { - return null; - } - - syncSelectedCharacter(); - return state.selectedCharacterId; +function setStatus(message: string, isError: boolean): void { + setMessage(elements.messageElement, message, isError); } diff --git a/RpgRoller/frontend/app/actions.ts b/RpgRoller/frontend/app/actions.ts new file mode 100644 index 0000000..7fde671 --- /dev/null +++ b/RpgRoller/frontend/app/actions.ts @@ -0,0 +1,24 @@ +export async function runAction( + action: () => Promise, + onError: (message: string) => void +): Promise { + 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); +} diff --git a/RpgRoller/frontend/app/dom.ts b/RpgRoller/frontend/app/dom.ts new file mode 100644 index 0000000..55090d7 --- /dev/null +++ b/RpgRoller/frontend/app/dom.ts @@ -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; +} diff --git a/RpgRoller/frontend/app/events.ts b/RpgRoller/frontend/app/events.ts new file mode 100644 index 0000000..75a6962 --- /dev/null +++ b/RpgRoller/frontend/app/events.ts @@ -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; + } +} diff --git a/RpgRoller/frontend/app/loaders.ts b/RpgRoller/frontend/app/loaders.ts new file mode 100644 index 0000000..8816ac2 --- /dev/null +++ b/RpgRoller/frontend/app/loaders.ts @@ -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 { + const health = await getHealth(); + elements.healthElement.textContent = `API status: ${health.status}`; +} + +export async function ensureRulesets(state: AppState, elements: AppElements): Promise { + if (state.rulesets.length > 0) { + return; + } + + state.rulesets = await getRulesets(); + elements.campaignRulesetSelect.innerHTML = state.rulesets + .map((ruleset) => ``) + .join(""); +} + +export async function reloadSession(state: AppState): Promise { + 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 { + 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 { + if (!state.selectedCampaignId) { + state.selectedCampaign = null; + return; + } + + state.selectedCampaign = await getCampaign(state.selectedCampaignId); + syncSelectedCharacter(state); +} + +export async function reloadCampaignLog(state: AppState): Promise { + if (!state.selectedCampaignId) { + state.campaignLog = []; + return; + } + + state.campaignLog = await getCampaignLog(state.selectedCampaignId); +} diff --git a/RpgRoller/frontend/app/render.ts b/RpgRoller/frontend/app/render.ts new file mode 100644 index 0000000..a121ceb --- /dev/null +++ b/RpgRoller/frontend/app/render.ts @@ -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) => `
  • ${character.name} (${character.id})
  • `) + .join(""); + + const skills = state.selectedCampaign.skills + .map((skill) => `
  • ${skill.name} [${skill.diceRollDefinition}]
  • `) + .join(""); + + elements.campaignDetailsElement.innerHTML = ` +

    GM: ${state.selectedCampaign.gm.displayName}

    +

    Characters visible to you: ${state.selectedCampaign.characters.length}

    +
      ${characters}
    +

    Skills visible to you: ${state.selectedCampaign.skills.length}

    +
      ${skills}
    + `; +} + +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 ``; + }) + .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) => ``) + .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 = "
  • No rolls yet.
  • "; + return; + } + + elements.campaignLogElement.innerHTML = state.campaignLog + .map((entry) => ` +
  • + ${entry.visibility.toUpperCase()} + ${entry.breakdown} + ${new Date(entry.timestampUtc).toLocaleString()} +
  • + `) + .join(""); +} + +function renderCampaignSelect(state: AppState, elements: AppElements): void { + elements.campaignSelect.innerHTML = state.campaigns + .map((campaign) => { + const selected = campaign.id === state.selectedCampaignId ? " selected" : ""; + return ``; + }) + .join(""); +} diff --git a/RpgRoller/frontend/app/state.ts b/RpgRoller/frontend/app/state.ts new file mode 100644 index 0000000..25905e3 --- /dev/null +++ b/RpgRoller/frontend/app/state.ts @@ -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; +} diff --git a/RpgRoller/frontend/app/types.ts b/RpgRoller/frontend/app/types.ts new file mode 100644 index 0000000..0f2077f --- /dev/null +++ b/RpgRoller/frontend/app/types.ts @@ -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; +}; diff --git a/RpgRoller/wwwroot/app.js b/RpgRoller/wwwroot/app.js index 8440021..eca8351 100644 --- a/RpgRoller/wwwroot/app.js +++ b/RpgRoller/wwwroot/app.js @@ -1,133 +1,107 @@ -import { activateCharacter, createCampaign, createCharacter, createSkill, getCampaign, getCampaignLog, getCampaigns, getHealth, getMe, getRulesets, loginUser, logoutUser, registerUser, rollSkill, updateSkill } from "./generated/api-client.js"; -const healthElement = mustElement("health"); -const messageElement = mustElement("message"); -const campaignMetaElement = mustElement("campaign-meta"); -const campaignDetailsElement = mustElement("campaign-details"); -const rollResultElement = mustElement("roll-result"); -const campaignLogElement = mustElement("campaign-log"); -const registerForm = mustForm("register-form"); -const loginForm = mustForm("login-form"); -const logoutButton = mustButton("logout-button"); -const registerUsername = mustInput("register-username"); -const registerDisplayName = mustInput("register-display-name"); -const registerPassword = mustInput("register-password"); -const loginUsername = mustInput("login-username"); -const loginPassword = mustInput("login-password"); -const campaignForm = mustForm("campaign-form"); -const campaignNameInput = mustInput("campaign-name"); -const campaignRulesetSelect = mustSelect("campaign-ruleset"); -const campaignSelect = mustSelect("campaign-select"); -const refreshCampaignButton = mustButton("refresh-campaign-button"); -const characterForm = mustForm("character-form"); -const characterNameInput = mustInput("character-name"); -const characterSelect = mustSelect("character-select"); -const activateCharacterButton = mustButton("activate-character-button"); -const skillForm = mustForm("skill-form"); -const skillNameInput = mustInput("skill-name"); -const skillExpressionInput = mustInput("skill-expression"); -const updateSkillButton = mustButton("update-skill-button"); -const skillSelect = mustSelect("skill-select"); -const rollForm = mustForm("roll-form"); -const rollVisibilitySelect = mustSelect("roll-visibility"); -const state = { - user: null, - activeCharacterId: null, - selectedCharacterId: null, - campaigns: [], - selectedCampaignId: null, - selectedCampaign: null, - campaignLog: [], - rulesets: [], - eventSource: null -}; -registerForm.addEventListener("submit", async (event) => { +import { activateCharacter, createCampaign, createCharacter, createSkill, loginUser, logoutUser, registerUser, rollSkill, updateSkill } from "./generated/api-client.js"; +import { runAction, setMessage } from "./app/actions.js"; +import { getAppElements } from "./app/dom.js"; +import { closeStateEvents, connectStateEvents } from "./app/events.js"; +import { ensureRulesets, refreshHealth, reloadCampaignLog, reloadCampaigns, reloadSelectedCampaign, reloadSession } from "./app/loaders.js"; +import { renderAll, renderCampaignDetails, renderCampaignLog, renderCampaignMeta, renderCharacterSelect, renderSkillSelect } from "./app/render.js"; +import { createInitialState, resetAuthenticatedState, resetStateAfterLogout, syncSelectedCharacter } from "./app/state.js"; +const elements = getAppElements(); +const state = createInitialState(); +elements.registerForm.addEventListener("submit", async (event) => { event.preventDefault(); - await runAction(async () => { + await runAppAction(async () => { await registerUser({ - username: registerUsername.value.trim(), - displayName: registerDisplayName.value.trim(), - password: registerPassword.value + username: elements.registerUsername.value.trim(), + displayName: elements.registerDisplayName.value.trim(), + password: elements.registerPassword.value }); - registerPassword.value = ""; - setMessage("Registration successful. You can log in now.", false); + elements.registerPassword.value = ""; + setStatus("Registration successful. You can log in now.", false); }); }); -loginForm.addEventListener("submit", async (event) => { +elements.loginForm.addEventListener("submit", async (event) => { event.preventDefault(); - await runAction(async () => { + await runAppAction(async () => { await loginUser({ - username: loginUsername.value.trim(), - password: loginPassword.value + username: elements.loginUsername.value.trim(), + password: elements.loginPassword.value }); - loginPassword.value = ""; + elements.loginPassword.value = ""; await reloadAll(); - setMessage("Logged in.", false); + setStatus("Logged in.", false); }); }); -logoutButton.addEventListener("click", async () => { - await runAction(async () => { +elements.logoutButton.addEventListener("click", async () => { + await runAppAction(async () => { await logoutUser(); - resetStateAfterLogout(); - renderAll(); - setMessage("Logged out.", false); + resetStateAfterLogout(state); + closeStateEvents(state); + renderAll(state, elements); + setStatus("Logged out.", false); }); }); -campaignForm.addEventListener("submit", async (event) => { +elements.campaignForm.addEventListener("submit", async (event) => { event.preventDefault(); - await runAction(async () => { + await runAppAction(async () => { const createdCampaign = await createCampaign({ - name: campaignNameInput.value.trim(), - rulesetId: campaignRulesetSelect.value + name: elements.campaignNameInput.value.trim(), + rulesetId: elements.campaignRulesetSelect.value }); - campaignNameInput.value = ""; - await reloadCampaigns(createdCampaign.id); - setMessage("Campaign created.", false); + elements.campaignNameInput.value = ""; + await reloadCampaigns(state, createdCampaign.id); + await reloadSelectedCampaign(state); + await reloadCampaignLog(state); + syncEventStream(); + renderAll(state, elements); + setStatus("Campaign created.", false); }); }); -campaignSelect.addEventListener("change", async () => { - await runAction(async () => { - const selected = campaignSelect.value; +elements.campaignSelect.addEventListener("change", async () => { + await runAppAction(async () => { + const selected = elements.campaignSelect.value; state.selectedCampaignId = selected.length > 0 ? selected : null; - await reloadSelectedCampaign(); - syncSelectedCharacter(); - renderCharacterSelect(); - renderSkillSelect(); - connectStateEvents(); - renderCampaignMeta(); - renderCampaignDetails(); - renderCampaignLog(); + await reloadSelectedCampaign(state); + syncSelectedCharacter(state); + renderCharacterSelect(state, elements); + renderSkillSelect(state, elements); + syncEventStream(); + renderCampaignMeta(state, elements); + renderCampaignDetails(state, elements); + renderCampaignLog(state, elements); }); }); -characterSelect.addEventListener("change", () => { - state.selectedCharacterId = characterSelect.value.length > 0 ? characterSelect.value : null; - renderSkillSelect(); +elements.characterSelect.addEventListener("change", () => { + state.selectedCharacterId = elements.characterSelect.value.length > 0 ? elements.characterSelect.value : null; + renderSkillSelect(state, elements); }); -refreshCampaignButton.addEventListener("click", async () => { - await runAction(async () => { - await reloadSelectedCampaign(); - renderAll(); - setMessage("Campaign refreshed.", false); +elements.refreshCampaignButton.addEventListener("click", async () => { + await runAppAction(async () => { + await reloadSelectedCampaign(state); + await reloadCampaignLog(state); + renderAll(state, elements); + setStatus("Campaign refreshed.", false); }); }); -characterForm.addEventListener("submit", async (event) => { +elements.characterForm.addEventListener("submit", async (event) => { event.preventDefault(); - await runAction(async () => { + await runAppAction(async () => { if (!state.selectedCampaignId) { throw new Error("Select a campaign first."); } await createCharacter({ - name: characterNameInput.value.trim(), + name: elements.characterNameInput.value.trim(), campaignId: state.selectedCampaignId }); - characterNameInput.value = ""; - await reloadSelectedCampaign(); - await reloadCampaignLog(); - setMessage("Character created.", false); + elements.characterNameInput.value = ""; + await reloadSelectedCampaign(state); + await reloadCampaignLog(state); + renderAll(state, elements); + setStatus("Character created.", false); }); }); -activateCharacterButton.addEventListener("click", async () => { - await runAction(async () => { - const characterId = characterSelect.value; +elements.activateCharacterButton.addEventListener("click", async () => { + await runAppAction(async () => { + const characterId = elements.characterSelect.value; if (characterId.length === 0) { throw new Error("Select a character to activate."); } @@ -135,349 +109,90 @@ activateCharacterButton.addEventListener("click", async () => { state.activeCharacterId = characterId; state.selectedCharacterId = characterId; await reloadAll(); - setMessage("Active character updated.", false); + setStatus("Active character updated.", false); }); }); -skillForm.addEventListener("submit", async (event) => { +elements.skillForm.addEventListener("submit", async (event) => { event.preventDefault(); - await runAction(async () => { - const characterId = characterSelect.value; + await runAppAction(async () => { + const characterId = elements.characterSelect.value; if (characterId.length === 0) { throw new Error("Select a character first."); } await createSkill(characterId, { - name: skillNameInput.value.trim(), - diceRollDefinition: skillExpressionInput.value.trim() + name: elements.skillNameInput.value.trim(), + diceRollDefinition: elements.skillExpressionInput.value.trim() }); - skillNameInput.value = ""; - skillExpressionInput.value = ""; - await reloadSelectedCampaign(); - setMessage("Skill created.", false); + elements.skillNameInput.value = ""; + elements.skillExpressionInput.value = ""; + await reloadSelectedCampaign(state); + renderAll(state, elements); + setStatus("Skill created.", false); }); }); -updateSkillButton.addEventListener("click", async () => { - await runAction(async () => { - const selectedSkillId = skillSelect.value; +elements.updateSkillButton.addEventListener("click", async () => { + await runAppAction(async () => { + const selectedSkillId = elements.skillSelect.value; if (selectedSkillId.length === 0) { throw new Error("Select a skill to update."); } await updateSkill(selectedSkillId, { - name: skillNameInput.value.trim(), - diceRollDefinition: skillExpressionInput.value.trim() + name: elements.skillNameInput.value.trim(), + diceRollDefinition: elements.skillExpressionInput.value.trim() }); - await reloadSelectedCampaign(); - setMessage("Skill updated.", false); + await reloadSelectedCampaign(state); + renderAll(state, elements); + setStatus("Skill updated.", false); }); }); -rollForm.addEventListener("submit", async (event) => { +elements.rollForm.addEventListener("submit", async (event) => { event.preventDefault(); - await runAction(async () => { - const selectedSkillId = skillSelect.value; + await runAppAction(async () => { + const selectedSkillId = elements.skillSelect.value; if (selectedSkillId.length === 0) { throw new Error("Select a skill to roll."); } - const roll = await rollSkill(selectedSkillId, { visibility: rollVisibilitySelect.value }); - rollResultElement.textContent = `Roll ${roll.rollId}: ${roll.breakdown} (${roll.visibility})`; - await reloadCampaignLog(); - setMessage("Roll recorded.", false); + const roll = await rollSkill(selectedSkillId, { visibility: elements.rollVisibilitySelect.value }); + elements.rollResultElement.textContent = `Roll ${roll.rollId}: ${roll.breakdown} (${roll.visibility})`; + await reloadCampaignLog(state); + renderCampaignLog(state, elements); + setStatus("Roll recorded.", false); }); }); -await runAction(async () => { - await refreshHealth(); +await runAppAction(async () => { + await refreshHealth(elements); await reloadAll(); - setMessage("Ready.", false); + setStatus("Ready.", false); }); -async function refreshHealth() { - const health = await getHealth(); - healthElement.textContent = `API status: ${health.status}`; -} async function reloadAll() { - await ensureRulesets(); - await reloadSession(); + await ensureRulesets(state, elements); + await reloadSession(state); if (state.user) { - await reloadCampaigns(state.selectedCampaignId); - await reloadSelectedCampaign(); - await reloadCampaignLog(); - connectStateEvents(); + await reloadCampaigns(state, state.selectedCampaignId); + await reloadSelectedCampaign(state); + await reloadCampaignLog(state); + syncEventStream(); } else { - resetAuthenticatedState(); + resetAuthenticatedState(state); + closeStateEvents(state); } - renderAll(); + renderAll(state, elements); } -async function reloadSession() { - try { - const me = await getMe(); - state.user = me.user; - state.activeCharacterId = me.activeCharacterId ?? null; - state.selectedCampaignId = me.currentCampaignId ?? state.selectedCampaignId; - } - catch { - state.user = null; - state.activeCharacterId = null; - } -} -async function ensureRulesets() { - if (state.rulesets.length > 0) { - return; - } - state.rulesets = await getRulesets(); - campaignRulesetSelect.innerHTML = state.rulesets - .map((ruleset) => ``) - .join(""); -} -async function reloadCampaigns(preferredCampaignId) { - state.campaigns = await getCampaigns(); - if (state.campaigns.length === 0) { - state.selectedCampaignId = null; - return; - } - const availableIds = new Set(state.campaigns.map((campaign) => campaign.id)); - if (preferredCampaignId && availableIds.has(preferredCampaignId)) { - state.selectedCampaignId = preferredCampaignId; - return; - } - if (state.selectedCampaignId && availableIds.has(state.selectedCampaignId)) { - return; - } - state.selectedCampaignId = state.campaigns[0].id; -} -async function reloadSelectedCampaign() { - if (!state.selectedCampaignId) { - state.selectedCampaign = null; - return; - } - state.selectedCampaign = await getCampaign(state.selectedCampaignId); - syncSelectedCharacter(); -} -async function reloadCampaignLog() { - if (!state.selectedCampaignId) { - state.campaignLog = []; - return; - } - state.campaignLog = await getCampaignLog(state.selectedCampaignId); -} -function connectStateEvents() { - if (!state.selectedCampaignId || !state.user) { - closeStateEvents(); - return; - } - if (state.eventSource && state.eventSource.url.endsWith(`campaignId=${state.selectedCampaignId}`)) { - return; - } - closeStateEvents(); - state.eventSource = new EventSource(`/api/events/state?campaignId=${state.selectedCampaignId}`); - state.eventSource.addEventListener("state", () => { - void runAction(async () => { - await reloadSelectedCampaign(); - await reloadCampaignLog(); - renderAll(); +function syncEventStream() { + connectStateEvents(state, () => { + void runAppAction(async () => { + await reloadSelectedCampaign(state); + await reloadCampaignLog(state); + renderAll(state, elements); }); }); - state.eventSource.onerror = () => { - closeStateEvents(); - }; } -function closeStateEvents() { - if (state.eventSource) { - state.eventSource.close(); - state.eventSource = null; - } +async function runAppAction(action) { + await runAction(action, (message) => { + setStatus(message, true); + }); } -function resetStateAfterLogout() { - state.user = null; - resetAuthenticatedState(); - closeStateEvents(); -} -function resetAuthenticatedState() { - state.activeCharacterId = null; - state.selectedCharacterId = null; - state.campaigns = []; - state.selectedCampaignId = null; - state.selectedCampaign = null; - state.campaignLog = []; -} -function renderAll() { - renderCampaignSelect(); - renderCampaignMeta(); - renderCampaignDetails(); - renderCharacterSelect(); - renderSkillSelect(); - renderCampaignLog(); -} -function renderCampaignSelect() { - campaignSelect.innerHTML = state.campaigns - .map((campaign) => { - const selected = campaign.id === state.selectedCampaignId ? " selected" : ""; - return ``; - }) - .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) => `
  • ${character.name} (${character.id})
  • `) - .join(""); - const skills = state.selectedCampaign.skills - .map((skill) => `
  • ${skill.name} [${skill.diceRollDefinition}]
  • `) - .join(""); - campaignDetailsElement.innerHTML = ` -

    GM: ${state.selectedCampaign.gm.displayName}

    -

    Characters visible to you: ${state.selectedCampaign.characters.length}

    -
      ${characters}
    -

    Skills visible to you: ${state.selectedCampaign.skills.length}

    -
      ${skills}
    - `; -} -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 ``; - }) - .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) => ``) - .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 = "
  • No rolls yet.
  • "; - return; - } - campaignLogElement.innerHTML = state.campaignLog - .map((entry) => ` -
  • - ${entry.visibility.toUpperCase()} - ${entry.breakdown} - ${new Date(entry.timestampUtc).toLocaleString()} -
  • - `) - .join(""); -} -async function runAction(action) { - try { - await action(); - } - catch (error) { - setMessage(formatError(error), true); - } -} -function setMessage(message, isError) { - messageElement.textContent = message; - messageElement.style.color = isError ? "#b91c1c" : "#1d4ed8"; -} -function formatError(error) { - if (error instanceof Error) { - return error.message; - } - return String(error); -} -function mustElement(id) { - const element = document.getElementById(id); - if (!(element instanceof HTMLElement)) { - throw new Error(`Missing HTMLElement: ${id}`); - } - return element; -} -function mustInput(id) { - const element = document.getElementById(id); - if (!(element instanceof HTMLInputElement)) { - throw new Error(`Missing HTMLInputElement: ${id}`); - } - return element; -} -function mustSelect(id) { - const element = document.getElementById(id); - if (!(element instanceof HTMLSelectElement)) { - throw new Error(`Missing HTMLSelectElement: ${id}`); - } - return element; -} -function mustForm(id) { - const element = document.getElementById(id); - if (!(element instanceof HTMLFormElement)) { - throw new Error(`Missing HTMLFormElement: ${id}`); - } - return element; -} -function mustButton(id) { - const element = document.getElementById(id); - if (!(element instanceof HTMLButtonElement)) { - throw new Error(`Missing HTMLButtonElement: ${id}`); - } - return element; -} -function syncSelectedCharacter() { - if (!state.selectedCampaign) { - state.selectedCharacterId = null; - return; - } - const availableIds = new Set(state.selectedCampaign.characters.map((character) => character.id)); - if (state.selectedCharacterId && availableIds.has(state.selectedCharacterId)) { - return; - } - if (state.activeCharacterId && availableIds.has(state.activeCharacterId)) { - state.selectedCharacterId = state.activeCharacterId; - return; - } - state.selectedCharacterId = state.selectedCampaign.characters.length > 0 - ? state.selectedCampaign.characters[0].id - : null; -} -function resolveSelectedCharacterId() { - if (!state.selectedCampaign) { - return null; - } - syncSelectedCharacter(); - return state.selectedCharacterId; +function setStatus(message, isError) { + setMessage(elements.messageElement, message, isError); } diff --git a/RpgRoller/wwwroot/app/actions.js b/RpgRoller/wwwroot/app/actions.js new file mode 100644 index 0000000..bcc4e59 --- /dev/null +++ b/RpgRoller/wwwroot/app/actions.js @@ -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); +} diff --git a/RpgRoller/wwwroot/app/dom.js b/RpgRoller/wwwroot/app/dom.js new file mode 100644 index 0000000..7e6f8ae --- /dev/null +++ b/RpgRoller/wwwroot/app/dom.js @@ -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; +} diff --git a/RpgRoller/wwwroot/app/events.js b/RpgRoller/wwwroot/app/events.js new file mode 100644 index 0000000..ced884d --- /dev/null +++ b/RpgRoller/wwwroot/app/events.js @@ -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; + } +} diff --git a/RpgRoller/wwwroot/app/loaders.js b/RpgRoller/wwwroot/app/loaders.js new file mode 100644 index 0000000..a07f08a --- /dev/null +++ b/RpgRoller/wwwroot/app/loaders.js @@ -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) => ``) + .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); +} diff --git a/RpgRoller/wwwroot/app/render.js b/RpgRoller/wwwroot/app/render.js new file mode 100644 index 0000000..cb4a9c7 --- /dev/null +++ b/RpgRoller/wwwroot/app/render.js @@ -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) => `
  • ${character.name} (${character.id})
  • `) + .join(""); + const skills = state.selectedCampaign.skills + .map((skill) => `
  • ${skill.name} [${skill.diceRollDefinition}]
  • `) + .join(""); + elements.campaignDetailsElement.innerHTML = ` +

    GM: ${state.selectedCampaign.gm.displayName}

    +

    Characters visible to you: ${state.selectedCampaign.characters.length}

    +
      ${characters}
    +

    Skills visible to you: ${state.selectedCampaign.skills.length}

    +
      ${skills}
    + `; +} +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 ``; + }) + .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) => ``) + .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 = "
  • No rolls yet.
  • "; + return; + } + elements.campaignLogElement.innerHTML = state.campaignLog + .map((entry) => ` +
  • + ${entry.visibility.toUpperCase()} + ${entry.breakdown} + ${new Date(entry.timestampUtc).toLocaleString()} +
  • + `) + .join(""); +} +function renderCampaignSelect(state, elements) { + elements.campaignSelect.innerHTML = state.campaigns + .map((campaign) => { + const selected = campaign.id === state.selectedCampaignId ? " selected" : ""; + return ``; + }) + .join(""); +} diff --git a/RpgRoller/wwwroot/app/state.js b/RpgRoller/wwwroot/app/state.js new file mode 100644 index 0000000..5cf0162 --- /dev/null +++ b/RpgRoller/wwwroot/app/state.js @@ -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; +} diff --git a/RpgRoller/wwwroot/app/types.js b/RpgRoller/wwwroot/app/types.js new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/RpgRoller/wwwroot/app/types.js @@ -0,0 +1 @@ +export {}; diff --git a/TECH.md b/TECH.md index 523b5aa..43028f3 100644 --- a/TECH.md +++ b/TECH.md @@ -5,12 +5,15 @@ - Root solution: `RpgRoller.sln` - Backend/full-stack project: `RpgRoller` (Minimal API + static `wwwroot` frontend) - Frontend source: `RpgRoller/frontend` (TypeScript) +- Frontend module split: `RpgRoller/frontend/app/*` (dom/state/loaders/render/events/actions) - Test project: `RpgRoller.Tests` (xUnit + `WebApplicationFactory` integration tests) - Persistence: EF Core + SQLite (`RpgRoller/Data/RpgRollerDbContext.cs`) with in-memory runtime cache in `GameService` - OpenAPI source: `openapi/RpgRoller.json` - Generated client source: `RpgRoller/frontend/generated/api-client.ts` - Generated client output: `RpgRoller/wwwroot/generated/api-client.js` - Local CI parity entrypoint: `scripts/ci-local.ps1` +- API endpoint modules: `RpgRoller/Api/*Endpoints.cs` + shared session/auth helpers +- Service boundary model: API request DTOs are mapped to `RpgRoller.Services/*Command` records before workflow execution - Current backend features: auth/session, campaign/character/skill management, ruleset-aware rolls, filtered campaign logs, and SSE state updates. - Current frontend features: authenticated campaign workspace with live log updates and full roll workflow controls.