Refactor API/service boundaries and modularize frontend
This commit is contained in:
4
FAQ.md
4
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.
|
||||
|
||||
16
README.md
16
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`)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
20
RpgRoller/Api/ApiEndpointRegistration.cs
Normal file
20
RpgRoller/Api/ApiEndpointRegistration.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
namespace RpgRoller.Api;
|
||||
|
||||
public static class ApiEndpointRegistration
|
||||
{
|
||||
public static void MapRpgRollerApi(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var api = app.MapGroup("/api");
|
||||
api.MapSystemEndpoints();
|
||||
api.MapAuthEndpoints();
|
||||
|
||||
var authenticatedApi = api.MapGroup(string.Empty)
|
||||
.AddEndpointFilter<RequireSessionTokenFilter>();
|
||||
|
||||
authenticatedApi.MapMeEndpoints();
|
||||
authenticatedApi.MapCampaignEndpoints();
|
||||
authenticatedApi.MapCharacterEndpoints();
|
||||
authenticatedApi.MapSkillEndpoints();
|
||||
authenticatedApi.MapStateEventEndpoints();
|
||||
}
|
||||
}
|
||||
28
RpgRoller/Api/ApiResultMapper.cs
Normal file
28
RpgRoller/Api/ApiResultMapper.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using RpgRoller.Contracts;
|
||||
using RpgRoller.Services;
|
||||
|
||||
namespace RpgRoller.Api;
|
||||
|
||||
internal static class ApiResultMapper
|
||||
{
|
||||
public static Results<Ok<T>, BadRequest<ApiError>, UnauthorizedHttpResult> ToApiResult<T>(ServiceResult<T> result)
|
||||
{
|
||||
if (result.Succeeded)
|
||||
{
|
||||
return TypedResults.Ok(result.Value!);
|
||||
}
|
||||
|
||||
if (result.Error!.Code == "unauthorized")
|
||||
{
|
||||
return TypedResults.Unauthorized();
|
||||
}
|
||||
|
||||
return TypedResults.BadRequest(new ApiError(result.Error.Message));
|
||||
}
|
||||
|
||||
public static BadRequest<ApiError> ToBadRequest(ServiceError error)
|
||||
{
|
||||
return TypedResults.BadRequest(new ApiError(error.Message));
|
||||
}
|
||||
}
|
||||
54
RpgRoller/Api/AuthEndpoints.cs
Normal file
54
RpgRoller/Api/AuthEndpoints.cs
Normal file
@@ -0,0 +1,54 @@
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using RpgRoller.Contracts;
|
||||
using RpgRoller.Services;
|
||||
|
||||
namespace RpgRoller.Api;
|
||||
|
||||
internal static class AuthEndpoints
|
||||
{
|
||||
public static RouteGroupBuilder MapAuthEndpoints(this RouteGroupBuilder group)
|
||||
{
|
||||
group.MapPost("/auth/register", Results<Ok<UserSummary>, BadRequest<ApiError>> (RegisterRequest request, IGameService game) =>
|
||||
{
|
||||
var result = game.Register(request.ToCommand());
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
return ApiResultMapper.ToBadRequest(result.Error!);
|
||||
}
|
||||
|
||||
return TypedResults.Ok(result.Value!);
|
||||
});
|
||||
|
||||
group.MapPost("/auth/login", Results<Ok<UserSummary>, BadRequest<ApiError>> (LoginRequest request, HttpContext context, IGameService game) =>
|
||||
{
|
||||
var result = game.Login(request.ToCommand());
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
return ApiResultMapper.ToBadRequest(result.Error!);
|
||||
}
|
||||
|
||||
context.Response.Cookies.Append(SessionCookie.Name, result.Value.SessionToken, new CookieOptions
|
||||
{
|
||||
HttpOnly = true,
|
||||
SameSite = SameSiteMode.Strict,
|
||||
IsEssential = true,
|
||||
Secure = context.Request.IsHttps
|
||||
});
|
||||
|
||||
return TypedResults.Ok(result.Value.User);
|
||||
});
|
||||
|
||||
group.MapPost("/auth/logout", (HttpContext context, IGameService game) =>
|
||||
{
|
||||
if (context.TryReadSessionTokenFromCookie(out var sessionToken))
|
||||
{
|
||||
game.Logout(sessionToken);
|
||||
}
|
||||
|
||||
context.Response.Cookies.Delete(SessionCookie.Name);
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
return group;
|
||||
}
|
||||
}
|
||||
36
RpgRoller/Api/CampaignEndpoints.cs
Normal file
36
RpgRoller/Api/CampaignEndpoints.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
using RpgRoller.Contracts;
|
||||
using RpgRoller.Services;
|
||||
|
||||
namespace RpgRoller.Api;
|
||||
|
||||
internal static class CampaignEndpoints
|
||||
{
|
||||
public static RouteGroupBuilder MapCampaignEndpoints(this RouteGroupBuilder group)
|
||||
{
|
||||
group.MapPost("/campaigns", (CreateCampaignRequest request, HttpContext context, IGameService game) =>
|
||||
{
|
||||
var result = game.CreateCampaign(context.GetRequiredSessionToken(), request.ToCommand());
|
||||
return ApiResultMapper.ToApiResult(result);
|
||||
});
|
||||
|
||||
group.MapGet("/campaigns", (HttpContext context, IGameService game) =>
|
||||
{
|
||||
var result = game.GetCampaigns(context.GetRequiredSessionToken());
|
||||
return ApiResultMapper.ToApiResult(result);
|
||||
});
|
||||
|
||||
group.MapGet("/campaigns/{campaignId:guid}", (Guid campaignId, HttpContext context, IGameService game) =>
|
||||
{
|
||||
var result = game.GetCampaign(context.GetRequiredSessionToken(), campaignId);
|
||||
return ApiResultMapper.ToApiResult(result);
|
||||
});
|
||||
|
||||
group.MapGet("/campaigns/{campaignId:guid}/log", (Guid campaignId, HttpContext context, IGameService game) =>
|
||||
{
|
||||
var result = game.GetCampaignLog(context.GetRequiredSessionToken(), campaignId);
|
||||
return ApiResultMapper.ToApiResult(result);
|
||||
});
|
||||
|
||||
return group;
|
||||
}
|
||||
}
|
||||
36
RpgRoller/Api/CharacterEndpoints.cs
Normal file
36
RpgRoller/Api/CharacterEndpoints.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
using RpgRoller.Contracts;
|
||||
using RpgRoller.Services;
|
||||
|
||||
namespace RpgRoller.Api;
|
||||
|
||||
internal static class CharacterEndpoints
|
||||
{
|
||||
public static RouteGroupBuilder MapCharacterEndpoints(this RouteGroupBuilder group)
|
||||
{
|
||||
group.MapPost("/characters", (CreateCharacterRequest request, HttpContext context, IGameService game) =>
|
||||
{
|
||||
var result = game.CreateCharacter(context.GetRequiredSessionToken(), request.ToCommand());
|
||||
return ApiResultMapper.ToApiResult(result);
|
||||
});
|
||||
|
||||
group.MapPut("/characters/{characterId:guid}", (Guid characterId, UpdateCharacterRequest request, HttpContext context, IGameService game) =>
|
||||
{
|
||||
var result = game.UpdateCharacter(context.GetRequiredSessionToken(), characterId, request.ToCommand());
|
||||
return ApiResultMapper.ToApiResult(result);
|
||||
});
|
||||
|
||||
group.MapPost("/characters/{characterId:guid}/activate", (Guid characterId, HttpContext context, IGameService game) =>
|
||||
{
|
||||
var result = game.ActivateCharacter(context.GetRequiredSessionToken(), characterId);
|
||||
return ApiResultMapper.ToApiResult(result);
|
||||
});
|
||||
|
||||
group.MapGet("/characters/current-campaign", (HttpContext context, IGameService game) =>
|
||||
{
|
||||
var result = game.GetCurrentCampaignCharacters(context.GetRequiredSessionToken());
|
||||
return ApiResultMapper.ToApiResult(result);
|
||||
});
|
||||
|
||||
return group;
|
||||
}
|
||||
}
|
||||
19
RpgRoller/Api/MeEndpoints.cs
Normal file
19
RpgRoller/Api/MeEndpoints.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using RpgRoller.Contracts;
|
||||
using RpgRoller.Services;
|
||||
|
||||
namespace RpgRoller.Api;
|
||||
|
||||
internal static class MeEndpoints
|
||||
{
|
||||
public static RouteGroupBuilder MapMeEndpoints(this RouteGroupBuilder group)
|
||||
{
|
||||
group.MapGet("/me", Results<Ok<MeResponse>, BadRequest<ApiError>, UnauthorizedHttpResult> (HttpContext context, IGameService game) =>
|
||||
{
|
||||
var result = game.GetMe(context.GetRequiredSessionToken());
|
||||
return ApiResultMapper.ToApiResult(result);
|
||||
});
|
||||
|
||||
return group;
|
||||
}
|
||||
}
|
||||
47
RpgRoller/Api/RequestMappings.cs
Normal file
47
RpgRoller/Api/RequestMappings.cs
Normal file
@@ -0,0 +1,47 @@
|
||||
using RpgRoller.Contracts;
|
||||
using RpgRoller.Services;
|
||||
|
||||
namespace RpgRoller.Api;
|
||||
|
||||
internal static class RequestMappings
|
||||
{
|
||||
public static RegisterCommand ToCommand(this RegisterRequest request)
|
||||
{
|
||||
return new RegisterCommand(request.Username, request.Password, request.DisplayName);
|
||||
}
|
||||
|
||||
public static LoginCommand ToCommand(this LoginRequest request)
|
||||
{
|
||||
return new LoginCommand(request.Username, request.Password);
|
||||
}
|
||||
|
||||
public static CreateCampaignCommand ToCommand(this CreateCampaignRequest request)
|
||||
{
|
||||
return new CreateCampaignCommand(request.Name, request.RulesetId);
|
||||
}
|
||||
|
||||
public static CreateCharacterCommand ToCommand(this CreateCharacterRequest request)
|
||||
{
|
||||
return new CreateCharacterCommand(request.Name, request.CampaignId);
|
||||
}
|
||||
|
||||
public static UpdateCharacterCommand ToCommand(this UpdateCharacterRequest request)
|
||||
{
|
||||
return new UpdateCharacterCommand(request.Name, request.CampaignId);
|
||||
}
|
||||
|
||||
public static CreateSkillCommand ToCommand(this CreateSkillRequest request)
|
||||
{
|
||||
return new CreateSkillCommand(request.Name, request.DiceRollDefinition);
|
||||
}
|
||||
|
||||
public static UpdateSkillCommand ToCommand(this UpdateSkillRequest request)
|
||||
{
|
||||
return new UpdateSkillCommand(request.Name, request.DiceRollDefinition);
|
||||
}
|
||||
|
||||
public static RollSkillCommand ToCommand(this RollSkillRequest request)
|
||||
{
|
||||
return new RollSkillCommand(request.Visibility);
|
||||
}
|
||||
}
|
||||
15
RpgRoller/Api/RequireSessionTokenFilter.cs
Normal file
15
RpgRoller/Api/RequireSessionTokenFilter.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
namespace RpgRoller.Api;
|
||||
|
||||
internal sealed class RequireSessionTokenFilter : IEndpointFilter
|
||||
{
|
||||
public ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
|
||||
{
|
||||
if (!context.HttpContext.TryReadSessionTokenFromCookie(out var sessionToken))
|
||||
{
|
||||
return ValueTask.FromResult<object?>(TypedResults.Unauthorized());
|
||||
}
|
||||
|
||||
context.HttpContext.StoreSessionToken(sessionToken);
|
||||
return next(context);
|
||||
}
|
||||
}
|
||||
6
RpgRoller/Api/SessionCookie.cs
Normal file
6
RpgRoller/Api/SessionCookie.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace RpgRoller.Api;
|
||||
|
||||
internal static class SessionCookie
|
||||
{
|
||||
public const string Name = "rpgroller_session";
|
||||
}
|
||||
29
RpgRoller/Api/SessionTokenHttpContextExtensions.cs
Normal file
29
RpgRoller/Api/SessionTokenHttpContextExtensions.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
namespace RpgRoller.Api;
|
||||
|
||||
internal static class SessionTokenHttpContextExtensions
|
||||
{
|
||||
private const string SessionTokenItemKey = "__rpgroller.session-token";
|
||||
|
||||
public static bool TryReadSessionTokenFromCookie(this HttpContext context, out string sessionToken)
|
||||
{
|
||||
sessionToken = context.Request.Cookies[SessionCookie.Name] ?? string.Empty;
|
||||
return !string.IsNullOrWhiteSpace(sessionToken);
|
||||
}
|
||||
|
||||
public static void StoreSessionToken(this HttpContext context, string sessionToken)
|
||||
{
|
||||
context.Items[SessionTokenItemKey] = sessionToken;
|
||||
}
|
||||
|
||||
public static string GetRequiredSessionToken(this HttpContext context)
|
||||
{
|
||||
if (context.Items.TryGetValue(SessionTokenItemKey, out var token)
|
||||
&& token is string sessionToken
|
||||
&& !string.IsNullOrWhiteSpace(sessionToken))
|
||||
{
|
||||
return sessionToken;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Session token is not available in this request.");
|
||||
}
|
||||
}
|
||||
30
RpgRoller/Api/SkillEndpoints.cs
Normal file
30
RpgRoller/Api/SkillEndpoints.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
using RpgRoller.Contracts;
|
||||
using RpgRoller.Services;
|
||||
|
||||
namespace RpgRoller.Api;
|
||||
|
||||
internal static class SkillEndpoints
|
||||
{
|
||||
public static RouteGroupBuilder MapSkillEndpoints(this RouteGroupBuilder group)
|
||||
{
|
||||
group.MapPost("/characters/{characterId:guid}/skills", (Guid characterId, CreateSkillRequest request, HttpContext context, IGameService game) =>
|
||||
{
|
||||
var result = game.CreateSkill(context.GetRequiredSessionToken(), characterId, request.ToCommand());
|
||||
return ApiResultMapper.ToApiResult(result);
|
||||
});
|
||||
|
||||
group.MapPut("/skills/{skillId:guid}", (Guid skillId, UpdateSkillRequest request, HttpContext context, IGameService game) =>
|
||||
{
|
||||
var result = game.UpdateSkill(context.GetRequiredSessionToken(), skillId, request.ToCommand());
|
||||
return ApiResultMapper.ToApiResult(result);
|
||||
});
|
||||
|
||||
group.MapPost("/skills/{skillId:guid}/roll", (Guid skillId, RollSkillRequest request, HttpContext context, IGameService game) =>
|
||||
{
|
||||
var result = game.RollSkill(context.GetRequiredSessionToken(), skillId, request.ToCommand());
|
||||
return ApiResultMapper.ToApiResult(result);
|
||||
});
|
||||
|
||||
return group;
|
||||
}
|
||||
}
|
||||
66
RpgRoller/Api/StateEventEndpoints.cs
Normal file
66
RpgRoller/Api/StateEventEndpoints.cs
Normal file
@@ -0,0 +1,66 @@
|
||||
using RpgRoller.Contracts;
|
||||
using RpgRoller.Services;
|
||||
|
||||
namespace RpgRoller.Api;
|
||||
|
||||
internal static class StateEventEndpoints
|
||||
{
|
||||
public static RouteGroupBuilder MapStateEventEndpoints(this RouteGroupBuilder group)
|
||||
{
|
||||
group.MapGet("/events/state", async Task<IResult> (
|
||||
Guid campaignId,
|
||||
HttpContext context,
|
||||
IGameService game) =>
|
||||
{
|
||||
var sessionToken = context.GetRequiredSessionToken();
|
||||
var versionResult = game.GetCampaignVersion(sessionToken, campaignId);
|
||||
if (!versionResult.Succeeded)
|
||||
{
|
||||
return versionResult.Error!.Code == "unauthorized"
|
||||
? TypedResults.Unauthorized()
|
||||
: TypedResults.BadRequest(new ApiError(versionResult.Error.Message));
|
||||
}
|
||||
|
||||
context.Response.Headers.CacheControl = "no-cache";
|
||||
context.Response.Headers.Connection = "keep-alive";
|
||||
context.Response.ContentType = "text/event-stream";
|
||||
|
||||
var lastVersion = versionResult.Value;
|
||||
await context.Response.WriteAsync($"event: state\ndata: {{\"campaignId\":\"{campaignId}\",\"version\":{lastVersion}}}\n\n");
|
||||
await context.Response.Body.FlushAsync();
|
||||
|
||||
try
|
||||
{
|
||||
while (!context.RequestAborted.IsCancellationRequested)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(1), context.RequestAborted);
|
||||
|
||||
var currentVersionResult = game.GetCampaignVersion(sessionToken, campaignId);
|
||||
if (!currentVersionResult.Succeeded)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (currentVersionResult.Value != lastVersion)
|
||||
{
|
||||
lastVersion = currentVersionResult.Value;
|
||||
await context.Response.WriteAsync($"event: state\ndata: {{\"campaignId\":\"{campaignId}\",\"version\":{lastVersion}}}\n\n");
|
||||
}
|
||||
else
|
||||
{
|
||||
await context.Response.WriteAsync(": heartbeat\n\n");
|
||||
}
|
||||
|
||||
await context.Response.Body.FlushAsync();
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
|
||||
return TypedResults.Empty;
|
||||
});
|
||||
|
||||
return group;
|
||||
}
|
||||
}
|
||||
14
RpgRoller/Api/SystemEndpoints.cs
Normal file
14
RpgRoller/Api/SystemEndpoints.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
using RpgRoller.Contracts;
|
||||
using RpgRoller.Services;
|
||||
|
||||
namespace RpgRoller.Api;
|
||||
|
||||
internal static class SystemEndpoints
|
||||
{
|
||||
public static RouteGroupBuilder MapSystemEndpoints(this RouteGroupBuilder group)
|
||||
{
|
||||
group.MapGet("/health", () => TypedResults.Ok(new HealthResponse("ok")));
|
||||
group.MapGet("/rulesets", (IGameService game) => TypedResults.Ok(game.GetRulesets()));
|
||||
return group;
|
||||
}
|
||||
}
|
||||
17
RpgRoller/Hosting/ApplicationInitializationExtensions.cs
Normal file
17
RpgRoller/Hosting/ApplicationInitializationExtensions.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using RpgRoller.Data;
|
||||
using RpgRoller.Services;
|
||||
|
||||
namespace RpgRoller.Hosting;
|
||||
|
||||
public static class ApplicationInitializationExtensions
|
||||
{
|
||||
public static void InitializeRpgRollerState(this WebApplication app)
|
||||
{
|
||||
using var scope = app.Services.CreateScope();
|
||||
var dbFactory = scope.ServiceProvider.GetRequiredService<IDbContextFactory<RpgRollerDbContext>>();
|
||||
using var db = dbFactory.CreateDbContext();
|
||||
db.Database.EnsureCreated();
|
||||
_ = scope.ServiceProvider.GetRequiredService<IGameService>();
|
||||
}
|
||||
}
|
||||
46
RpgRoller/Hosting/ServiceCollectionExtensions.cs
Normal file
46
RpgRoller/Hosting/ServiceCollectionExtensions.cs
Normal file
@@ -0,0 +1,46 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using RpgRoller.Data;
|
||||
using RpgRoller.Domain;
|
||||
using RpgRoller.Services;
|
||||
|
||||
namespace RpgRoller.Hosting;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddRpgRollerCore(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration,
|
||||
IWebHostEnvironment environment)
|
||||
{
|
||||
var sqliteConnectionString = configuration.GetConnectionString("RpgRoller") ?? "Data Source=App_Data/rpgroller.db";
|
||||
EnsureSqliteDataDirectory(sqliteConnectionString, environment.ContentRootPath);
|
||||
|
||||
services.AddSingleton<IPasswordHasher<UserAccount>, PasswordHasher<UserAccount>>();
|
||||
services.AddDbContextFactory<RpgRollerDbContext>(options => options.UseSqlite(sqliteConnectionString));
|
||||
services.AddSingleton<IDiceRoller, RandomDiceRoller>();
|
||||
services.AddSingleton<IGameService, GameService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
private static void EnsureSqliteDataDirectory(string connectionString, string contentRootPath)
|
||||
{
|
||||
var builder = new SqliteConnectionStringBuilder(connectionString);
|
||||
if (string.IsNullOrWhiteSpace(builder.DataSource) || builder.DataSource == ":memory:")
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var fullPath = Path.IsPathRooted(builder.DataSource)
|
||||
? builder.DataSource
|
||||
: Path.Combine(contentRootPath, builder.DataSource);
|
||||
|
||||
var directory = Path.GetDirectoryName(fullPath);
|
||||
if (!string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,324 +1,16 @@
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using RpgRoller.Contracts;
|
||||
using RpgRoller.Data;
|
||||
using RpgRoller.Domain;
|
||||
using RpgRoller.Services;
|
||||
|
||||
const string SessionCookieName = "rpgroller_session";
|
||||
using RpgRoller.Api;
|
||||
using RpgRoller.Hosting;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
var sqliteConnectionString = builder.Configuration.GetConnectionString("RpgRoller") ?? "Data Source=App_Data/rpgroller.db";
|
||||
EnsureSqliteDataDirectory(sqliteConnectionString, builder.Environment.ContentRootPath);
|
||||
|
||||
builder.Services.AddSingleton<IPasswordHasher<UserAccount>, PasswordHasher<UserAccount>>();
|
||||
builder.Services.AddDbContextFactory<RpgRollerDbContext>(options => options.UseSqlite(sqliteConnectionString));
|
||||
builder.Services.AddSingleton<IDiceRoller, RandomDiceRoller>();
|
||||
builder.Services.AddSingleton<IGameService, GameService>();
|
||||
builder.Services.AddRpgRollerCore(builder.Configuration, builder.Environment);
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var dbFactory = scope.ServiceProvider.GetRequiredService<IDbContextFactory<RpgRollerDbContext>>();
|
||||
using var db = dbFactory.CreateDbContext();
|
||||
db.Database.EnsureCreated();
|
||||
_ = scope.ServiceProvider.GetRequiredService<IGameService>();
|
||||
}
|
||||
app.InitializeRpgRollerState();
|
||||
|
||||
app.UseDefaultFiles();
|
||||
app.UseStaticFiles();
|
||||
|
||||
app.MapGet("/api/health", () => TypedResults.Ok(new HealthResponse("ok")));
|
||||
app.MapGet("/api/rulesets", (IGameService game) => TypedResults.Ok(game.GetRulesets()));
|
||||
|
||||
app.MapPost("/api/auth/register", Results<Ok<UserSummary>, BadRequest<ApiError>> (RegisterRequest request, IGameService game) =>
|
||||
{
|
||||
var result = game.Register(request);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
return ToBadRequest(result.Error!);
|
||||
}
|
||||
|
||||
return TypedResults.Ok(result.Value!);
|
||||
});
|
||||
|
||||
app.MapPost("/api/auth/login", Results<Ok<UserSummary>, BadRequest<ApiError>> (LoginRequest request, HttpContext context, IGameService game) =>
|
||||
{
|
||||
var result = game.Login(request);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
return ToBadRequest(result.Error!);
|
||||
}
|
||||
|
||||
context.Response.Cookies.Append(SessionCookieName, result.Value.SessionToken, new CookieOptions
|
||||
{
|
||||
HttpOnly = true,
|
||||
SameSite = SameSiteMode.Strict,
|
||||
IsEssential = true,
|
||||
Secure = context.Request.IsHttps
|
||||
});
|
||||
|
||||
return TypedResults.Ok(result.Value.User);
|
||||
});
|
||||
|
||||
app.MapPost("/api/auth/logout", (HttpContext context, IGameService game) =>
|
||||
{
|
||||
if (TryGetSessionToken(context, out var sessionToken))
|
||||
{
|
||||
game.Logout(sessionToken);
|
||||
}
|
||||
|
||||
context.Response.Cookies.Delete(SessionCookieName);
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
app.MapGet("/api/me", Results<Ok<MeResponse>, BadRequest<ApiError>, UnauthorizedHttpResult> (HttpContext context, IGameService game) =>
|
||||
{
|
||||
if (!TryGetSessionToken(context, out var sessionToken))
|
||||
{
|
||||
return TypedResults.Unauthorized();
|
||||
}
|
||||
|
||||
var result = game.GetMe(sessionToken);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
return result.Error!.Code == "unauthorized"
|
||||
? TypedResults.Unauthorized()
|
||||
: TypedResults.BadRequest(new ApiError(result.Error.Message));
|
||||
}
|
||||
|
||||
return TypedResults.Ok(result.Value!);
|
||||
});
|
||||
|
||||
app.MapPost("/api/campaigns", (CreateCampaignRequest request, HttpContext context, IGameService game) =>
|
||||
{
|
||||
if (!TryGetSessionToken(context, out var sessionToken))
|
||||
{
|
||||
return TypedResults.Unauthorized();
|
||||
}
|
||||
|
||||
var result = game.CreateCampaign(sessionToken, request);
|
||||
return ToApiResult(result);
|
||||
});
|
||||
|
||||
app.MapGet("/api/campaigns", (HttpContext context, IGameService game) =>
|
||||
{
|
||||
if (!TryGetSessionToken(context, out var sessionToken))
|
||||
{
|
||||
return TypedResults.Unauthorized();
|
||||
}
|
||||
|
||||
var result = game.GetCampaigns(sessionToken);
|
||||
return ToApiResult(result);
|
||||
});
|
||||
|
||||
app.MapGet("/api/campaigns/{campaignId:guid}", (Guid campaignId, HttpContext context, IGameService game) =>
|
||||
{
|
||||
if (!TryGetSessionToken(context, out var sessionToken))
|
||||
{
|
||||
return TypedResults.Unauthorized();
|
||||
}
|
||||
|
||||
var result = game.GetCampaign(sessionToken, campaignId);
|
||||
return ToApiResult(result);
|
||||
});
|
||||
|
||||
app.MapGet("/api/campaigns/{campaignId:guid}/log", (Guid campaignId, HttpContext context, IGameService game) =>
|
||||
{
|
||||
if (!TryGetSessionToken(context, out var sessionToken))
|
||||
{
|
||||
return TypedResults.Unauthorized();
|
||||
}
|
||||
|
||||
var result = game.GetCampaignLog(sessionToken, campaignId);
|
||||
return ToApiResult(result);
|
||||
});
|
||||
|
||||
app.MapPost("/api/characters", (CreateCharacterRequest request, HttpContext context, IGameService game) =>
|
||||
{
|
||||
if (!TryGetSessionToken(context, out var sessionToken))
|
||||
{
|
||||
return TypedResults.Unauthorized();
|
||||
}
|
||||
|
||||
var result = game.CreateCharacter(sessionToken, request);
|
||||
return ToApiResult(result);
|
||||
});
|
||||
|
||||
app.MapPut("/api/characters/{characterId:guid}", (Guid characterId, UpdateCharacterRequest request, HttpContext context, IGameService game) =>
|
||||
{
|
||||
if (!TryGetSessionToken(context, out var sessionToken))
|
||||
{
|
||||
return TypedResults.Unauthorized();
|
||||
}
|
||||
|
||||
var result = game.UpdateCharacter(sessionToken, characterId, request);
|
||||
return ToApiResult(result);
|
||||
});
|
||||
|
||||
app.MapPost("/api/characters/{characterId:guid}/activate", (Guid characterId, HttpContext context, IGameService game) =>
|
||||
{
|
||||
if (!TryGetSessionToken(context, out var sessionToken))
|
||||
{
|
||||
return TypedResults.Unauthorized();
|
||||
}
|
||||
|
||||
var result = game.ActivateCharacter(sessionToken, characterId);
|
||||
return ToApiResult(result);
|
||||
});
|
||||
|
||||
app.MapGet("/api/characters/current-campaign", (HttpContext context, IGameService game) =>
|
||||
{
|
||||
if (!TryGetSessionToken(context, out var sessionToken))
|
||||
{
|
||||
return TypedResults.Unauthorized();
|
||||
}
|
||||
|
||||
var result = game.GetCurrentCampaignCharacters(sessionToken);
|
||||
return ToApiResult(result);
|
||||
});
|
||||
|
||||
app.MapPost("/api/characters/{characterId:guid}/skills", (Guid characterId, CreateSkillRequest request, HttpContext context, IGameService game) =>
|
||||
{
|
||||
if (!TryGetSessionToken(context, out var sessionToken))
|
||||
{
|
||||
return TypedResults.Unauthorized();
|
||||
}
|
||||
|
||||
var result = game.CreateSkill(sessionToken, characterId, request);
|
||||
return ToApiResult(result);
|
||||
});
|
||||
|
||||
app.MapPut("/api/skills/{skillId:guid}", (Guid skillId, UpdateSkillRequest request, HttpContext context, IGameService game) =>
|
||||
{
|
||||
if (!TryGetSessionToken(context, out var sessionToken))
|
||||
{
|
||||
return TypedResults.Unauthorized();
|
||||
}
|
||||
|
||||
var result = game.UpdateSkill(sessionToken, skillId, request);
|
||||
return ToApiResult(result);
|
||||
});
|
||||
|
||||
app.MapPost("/api/skills/{skillId:guid}/roll", (Guid skillId, RollSkillRequest request, HttpContext context, IGameService game) =>
|
||||
{
|
||||
if (!TryGetSessionToken(context, out var sessionToken))
|
||||
{
|
||||
return TypedResults.Unauthorized();
|
||||
}
|
||||
|
||||
var result = game.RollSkill(sessionToken, skillId, request);
|
||||
return ToApiResult(result);
|
||||
});
|
||||
|
||||
app.MapGet("/api/events/state", async Task<IResult> (
|
||||
Guid campaignId,
|
||||
HttpContext context,
|
||||
IGameService game) =>
|
||||
{
|
||||
if (!TryGetSessionToken(context, out var sessionToken))
|
||||
{
|
||||
return TypedResults.Unauthorized();
|
||||
}
|
||||
|
||||
var versionResult = game.GetCampaignVersion(sessionToken, campaignId);
|
||||
if (!versionResult.Succeeded)
|
||||
{
|
||||
return versionResult.Error!.Code == "unauthorized"
|
||||
? TypedResults.Unauthorized()
|
||||
: TypedResults.BadRequest(new ApiError(versionResult.Error.Message));
|
||||
}
|
||||
|
||||
context.Response.Headers.CacheControl = "no-cache";
|
||||
context.Response.Headers.Connection = "keep-alive";
|
||||
context.Response.ContentType = "text/event-stream";
|
||||
|
||||
var lastVersion = versionResult.Value;
|
||||
await context.Response.WriteAsync($"event: state\ndata: {{\"campaignId\":\"{campaignId}\",\"version\":{lastVersion}}}\n\n");
|
||||
await context.Response.Body.FlushAsync();
|
||||
|
||||
try
|
||||
{
|
||||
while (!context.RequestAborted.IsCancellationRequested)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(1), context.RequestAborted);
|
||||
|
||||
var currentVersionResult = game.GetCampaignVersion(sessionToken, campaignId);
|
||||
if (!currentVersionResult.Succeeded)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (currentVersionResult.Value != lastVersion)
|
||||
{
|
||||
lastVersion = currentVersionResult.Value;
|
||||
await context.Response.WriteAsync($"event: state\ndata: {{\"campaignId\":\"{campaignId}\",\"version\":{lastVersion}}}\n\n");
|
||||
}
|
||||
else
|
||||
{
|
||||
await context.Response.WriteAsync(": heartbeat\n\n");
|
||||
}
|
||||
|
||||
await context.Response.Body.FlushAsync();
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
|
||||
return TypedResults.Empty;
|
||||
});
|
||||
|
||||
app.MapRpgRollerApi();
|
||||
app.Run();
|
||||
|
||||
return;
|
||||
|
||||
static bool TryGetSessionToken(HttpContext context, out string sessionToken)
|
||||
{
|
||||
sessionToken = context.Request.Cookies[SessionCookieName] ?? string.Empty;
|
||||
return !string.IsNullOrWhiteSpace(sessionToken);
|
||||
}
|
||||
|
||||
static Results<Ok<T>, BadRequest<ApiError>, UnauthorizedHttpResult> ToApiResult<T>(ServiceResult<T> result)
|
||||
{
|
||||
if (result.Succeeded)
|
||||
{
|
||||
return TypedResults.Ok(result.Value!);
|
||||
}
|
||||
|
||||
if (result.Error!.Code == "unauthorized")
|
||||
{
|
||||
return TypedResults.Unauthorized();
|
||||
}
|
||||
|
||||
return TypedResults.BadRequest(new ApiError(result.Error.Message));
|
||||
}
|
||||
|
||||
static BadRequest<ApiError> ToBadRequest(ServiceError error)
|
||||
{
|
||||
return TypedResults.BadRequest(new ApiError(error.Message));
|
||||
}
|
||||
|
||||
static void EnsureSqliteDataDirectory(string connectionString, string contentRootPath)
|
||||
{
|
||||
var builder = new SqliteConnectionStringBuilder(connectionString);
|
||||
if (string.IsNullOrWhiteSpace(builder.DataSource) || builder.DataSource == ":memory:")
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var fullPath = Path.IsPathRooted(builder.DataSource)
|
||||
? builder.DataSource
|
||||
: Path.Combine(contentRootPath, builder.DataSource);
|
||||
|
||||
var directory = Path.GetDirectoryName(fullPath);
|
||||
if (!string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
}
|
||||
|
||||
public partial class Program;
|
||||
|
||||
@@ -35,7 +35,7 @@ public sealed class GameService : IGameService
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public ServiceResult<UserSummary> Register(RegisterRequest request)
|
||||
public ServiceResult<UserSummary> Register(RegisterCommand request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Username))
|
||||
{
|
||||
@@ -81,7 +81,7 @@ public sealed class GameService : IGameService
|
||||
}
|
||||
}
|
||||
|
||||
public ServiceResult<(UserSummary User, string SessionToken)> Login(LoginRequest request)
|
||||
public ServiceResult<(UserSummary User, string SessionToken)> Login(LoginCommand request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password))
|
||||
{
|
||||
@@ -162,7 +162,7 @@ public sealed class GameService : IGameService
|
||||
}
|
||||
}
|
||||
|
||||
public ServiceResult<CampaignSummary> CreateCampaign(string sessionToken, CreateCampaignRequest request)
|
||||
public ServiceResult<CampaignSummary> CreateCampaign(string sessionToken, CreateCampaignCommand request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Name))
|
||||
{
|
||||
@@ -263,7 +263,7 @@ public sealed class GameService : IGameService
|
||||
}
|
||||
}
|
||||
|
||||
public ServiceResult<CharacterSummary> CreateCharacter(string sessionToken, CreateCharacterRequest request)
|
||||
public ServiceResult<CharacterSummary> CreateCharacter(string sessionToken, CreateCharacterCommand request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Name))
|
||||
{
|
||||
@@ -299,7 +299,7 @@ public sealed class GameService : IGameService
|
||||
}
|
||||
}
|
||||
|
||||
public ServiceResult<CharacterSummary> UpdateCharacter(string sessionToken, Guid characterId, UpdateCharacterRequest request)
|
||||
public ServiceResult<CharacterSummary> UpdateCharacter(string sessionToken, Guid characterId, UpdateCharacterCommand request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Name))
|
||||
{
|
||||
@@ -399,7 +399,7 @@ public sealed class GameService : IGameService
|
||||
}
|
||||
}
|
||||
|
||||
public ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, CreateSkillRequest request)
|
||||
public ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, CreateSkillCommand request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Name))
|
||||
{
|
||||
@@ -447,7 +447,7 @@ public sealed class GameService : IGameService
|
||||
}
|
||||
}
|
||||
|
||||
public ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, UpdateSkillRequest request)
|
||||
public ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, UpdateSkillCommand request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Name))
|
||||
{
|
||||
@@ -489,7 +489,7 @@ public sealed class GameService : IGameService
|
||||
}
|
||||
}
|
||||
|
||||
public ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, RollSkillRequest request)
|
||||
public ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, RollSkillCommand request)
|
||||
{
|
||||
lock (m_Gate)
|
||||
{
|
||||
|
||||
@@ -7,25 +7,25 @@ public interface IGameService
|
||||
{
|
||||
IReadOnlyList<RulesetDefinition> GetRulesets();
|
||||
|
||||
ServiceResult<UserSummary> Register(RegisterRequest request);
|
||||
ServiceResult<(UserSummary User, string SessionToken)> Login(LoginRequest request);
|
||||
ServiceResult<UserSummary> Register(RegisterCommand request);
|
||||
ServiceResult<(UserSummary User, string SessionToken)> Login(LoginCommand request);
|
||||
void Logout(string sessionToken);
|
||||
UserSummary? GetUserBySession(string sessionToken);
|
||||
ServiceResult<MeResponse> GetMe(string sessionToken);
|
||||
|
||||
ServiceResult<CampaignSummary> CreateCampaign(string sessionToken, CreateCampaignRequest request);
|
||||
ServiceResult<CampaignSummary> CreateCampaign(string sessionToken, CreateCampaignCommand request);
|
||||
ServiceResult<IReadOnlyList<CampaignSummary>> GetCampaigns(string sessionToken);
|
||||
ServiceResult<CampaignDetails> GetCampaign(string sessionToken, Guid campaignId);
|
||||
|
||||
ServiceResult<CharacterSummary> CreateCharacter(string sessionToken, CreateCharacterRequest request);
|
||||
ServiceResult<CharacterSummary> UpdateCharacter(string sessionToken, Guid characterId, UpdateCharacterRequest request);
|
||||
ServiceResult<CharacterSummary> CreateCharacter(string sessionToken, CreateCharacterCommand request);
|
||||
ServiceResult<CharacterSummary> UpdateCharacter(string sessionToken, Guid characterId, UpdateCharacterCommand request);
|
||||
ServiceResult<bool> ActivateCharacter(string sessionToken, Guid characterId);
|
||||
ServiceResult<IReadOnlyList<CharacterSummary>> GetCurrentCampaignCharacters(string sessionToken);
|
||||
|
||||
ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, CreateSkillRequest request);
|
||||
ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, UpdateSkillRequest request);
|
||||
ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, CreateSkillCommand request);
|
||||
ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, UpdateSkillCommand request);
|
||||
|
||||
ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, RollSkillRequest request);
|
||||
ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, RollSkillCommand request);
|
||||
ServiceResult<IReadOnlyList<CampaignLogEntry>> GetCampaignLog(string sessionToken, Guid campaignId);
|
||||
|
||||
ServiceResult<long> GetCampaignVersion(string sessionToken, Guid campaignId);
|
||||
|
||||
13
RpgRoller/Services/ServiceCommands.cs
Normal file
13
RpgRoller/Services/ServiceCommands.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace RpgRoller.Services;
|
||||
|
||||
public sealed record RegisterCommand(string Username, string Password, string DisplayName);
|
||||
public sealed record LoginCommand(string Username, string Password);
|
||||
|
||||
public sealed record CreateCampaignCommand(string Name, string RulesetId);
|
||||
public sealed record CreateCharacterCommand(string Name, Guid CampaignId);
|
||||
public sealed record UpdateCharacterCommand(string Name, Guid CampaignId);
|
||||
|
||||
public sealed record CreateSkillCommand(string Name, string DiceRollDefinition);
|
||||
public sealed record UpdateSkillCommand(string Name, string DiceRollDefinition);
|
||||
|
||||
public sealed record RollSkillCommand(string Visibility);
|
||||
@@ -1,193 +1,143 @@
|
||||
import {
|
||||
activateCharacter,
|
||||
type CampaignDetails,
|
||||
type CampaignLogEntry,
|
||||
type CampaignSummary,
|
||||
createCampaign,
|
||||
createCharacter,
|
||||
createSkill,
|
||||
getCampaign,
|
||||
getCampaignLog,
|
||||
getCampaigns,
|
||||
getHealth,
|
||||
getMe,
|
||||
getRulesets,
|
||||
loginUser,
|
||||
logoutUser,
|
||||
registerUser,
|
||||
rollSkill,
|
||||
type RulesetDefinition,
|
||||
type SkillSummary,
|
||||
type UserSummary,
|
||||
updateSkill
|
||||
} from "./generated/api-client.js";
|
||||
import { runAction, setMessage } from "./app/actions.js";
|
||||
import { getAppElements } from "./app/dom.js";
|
||||
import { closeStateEvents, connectStateEvents } from "./app/events.js";
|
||||
import {
|
||||
ensureRulesets,
|
||||
refreshHealth,
|
||||
reloadCampaignLog,
|
||||
reloadCampaigns,
|
||||
reloadSelectedCampaign,
|
||||
reloadSession
|
||||
} from "./app/loaders.js";
|
||||
import { renderAll, renderCampaignDetails, renderCampaignLog, renderCampaignMeta, renderCharacterSelect, renderSkillSelect } from "./app/render.js";
|
||||
import { createInitialState, resetAuthenticatedState, resetStateAfterLogout, syncSelectedCharacter } from "./app/state.js";
|
||||
|
||||
const healthElement = mustElement("health");
|
||||
const messageElement = mustElement("message");
|
||||
const campaignMetaElement = mustElement("campaign-meta");
|
||||
const campaignDetailsElement = mustElement("campaign-details");
|
||||
const rollResultElement = mustElement("roll-result");
|
||||
const campaignLogElement = mustElement("campaign-log");
|
||||
const elements = getAppElements();
|
||||
const state = createInitialState();
|
||||
|
||||
const registerForm = mustForm("register-form");
|
||||
const loginForm = mustForm("login-form");
|
||||
const logoutButton = mustButton("logout-button");
|
||||
|
||||
const registerUsername = mustInput("register-username");
|
||||
const registerDisplayName = mustInput("register-display-name");
|
||||
const registerPassword = mustInput("register-password");
|
||||
const loginUsername = mustInput("login-username");
|
||||
const loginPassword = mustInput("login-password");
|
||||
|
||||
const campaignForm = mustForm("campaign-form");
|
||||
const campaignNameInput = mustInput("campaign-name");
|
||||
const campaignRulesetSelect = mustSelect("campaign-ruleset");
|
||||
const campaignSelect = mustSelect("campaign-select");
|
||||
const refreshCampaignButton = mustButton("refresh-campaign-button");
|
||||
|
||||
const characterForm = mustForm("character-form");
|
||||
const characterNameInput = mustInput("character-name");
|
||||
const characterSelect = mustSelect("character-select");
|
||||
const activateCharacterButton = mustButton("activate-character-button");
|
||||
|
||||
const skillForm = mustForm("skill-form");
|
||||
const skillNameInput = mustInput("skill-name");
|
||||
const skillExpressionInput = mustInput("skill-expression");
|
||||
const updateSkillButton = mustButton("update-skill-button");
|
||||
const skillSelect = mustSelect("skill-select");
|
||||
|
||||
const rollForm = mustForm("roll-form");
|
||||
const rollVisibilitySelect = mustSelect("roll-visibility");
|
||||
|
||||
type AppState = {
|
||||
user: UserSummary | null;
|
||||
activeCharacterId: string | null;
|
||||
selectedCharacterId: string | null;
|
||||
campaigns: CampaignSummary[];
|
||||
selectedCampaignId: string | null;
|
||||
selectedCampaign: CampaignDetails | null;
|
||||
campaignLog: CampaignLogEntry[];
|
||||
rulesets: RulesetDefinition[];
|
||||
eventSource: EventSource | null;
|
||||
};
|
||||
|
||||
const state: AppState = {
|
||||
user: null,
|
||||
activeCharacterId: null,
|
||||
selectedCharacterId: null,
|
||||
campaigns: [],
|
||||
selectedCampaignId: null,
|
||||
selectedCampaign: null,
|
||||
campaignLog: [],
|
||||
rulesets: [],
|
||||
eventSource: null
|
||||
};
|
||||
|
||||
registerForm.addEventListener("submit", async (event) => {
|
||||
elements.registerForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
await runAction(async () => {
|
||||
await runAppAction(async () => {
|
||||
await registerUser({
|
||||
username: registerUsername.value.trim(),
|
||||
displayName: registerDisplayName.value.trim(),
|
||||
password: registerPassword.value
|
||||
username: elements.registerUsername.value.trim(),
|
||||
displayName: elements.registerDisplayName.value.trim(),
|
||||
password: elements.registerPassword.value
|
||||
});
|
||||
|
||||
registerPassword.value = "";
|
||||
setMessage("Registration successful. You can log in now.", false);
|
||||
elements.registerPassword.value = "";
|
||||
setStatus("Registration successful. You can log in now.", false);
|
||||
});
|
||||
});
|
||||
|
||||
loginForm.addEventListener("submit", async (event) => {
|
||||
elements.loginForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
await runAction(async () => {
|
||||
await runAppAction(async () => {
|
||||
await loginUser({
|
||||
username: loginUsername.value.trim(),
|
||||
password: loginPassword.value
|
||||
username: elements.loginUsername.value.trim(),
|
||||
password: elements.loginPassword.value
|
||||
});
|
||||
|
||||
loginPassword.value = "";
|
||||
elements.loginPassword.value = "";
|
||||
await reloadAll();
|
||||
setMessage("Logged in.", false);
|
||||
setStatus("Logged in.", false);
|
||||
});
|
||||
});
|
||||
|
||||
logoutButton.addEventListener("click", async () => {
|
||||
await runAction(async () => {
|
||||
elements.logoutButton.addEventListener("click", async () => {
|
||||
await runAppAction(async () => {
|
||||
await logoutUser();
|
||||
resetStateAfterLogout();
|
||||
renderAll();
|
||||
setMessage("Logged out.", false);
|
||||
resetStateAfterLogout(state);
|
||||
closeStateEvents(state);
|
||||
renderAll(state, elements);
|
||||
setStatus("Logged out.", false);
|
||||
});
|
||||
});
|
||||
|
||||
campaignForm.addEventListener("submit", async (event) => {
|
||||
elements.campaignForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
await runAction(async () => {
|
||||
await runAppAction(async () => {
|
||||
const createdCampaign = await createCampaign({
|
||||
name: campaignNameInput.value.trim(),
|
||||
rulesetId: campaignRulesetSelect.value
|
||||
name: elements.campaignNameInput.value.trim(),
|
||||
rulesetId: elements.campaignRulesetSelect.value
|
||||
});
|
||||
|
||||
campaignNameInput.value = "";
|
||||
await reloadCampaigns(createdCampaign.id);
|
||||
setMessage("Campaign created.", false);
|
||||
elements.campaignNameInput.value = "";
|
||||
await reloadCampaigns(state, createdCampaign.id);
|
||||
await reloadSelectedCampaign(state);
|
||||
await reloadCampaignLog(state);
|
||||
syncEventStream();
|
||||
renderAll(state, elements);
|
||||
setStatus("Campaign created.", false);
|
||||
});
|
||||
});
|
||||
|
||||
campaignSelect.addEventListener("change", async () => {
|
||||
await runAction(async () => {
|
||||
const selected = campaignSelect.value;
|
||||
elements.campaignSelect.addEventListener("change", async () => {
|
||||
await runAppAction(async () => {
|
||||
const selected = elements.campaignSelect.value;
|
||||
state.selectedCampaignId = selected.length > 0 ? selected : null;
|
||||
await reloadSelectedCampaign();
|
||||
syncSelectedCharacter();
|
||||
renderCharacterSelect();
|
||||
renderSkillSelect();
|
||||
connectStateEvents();
|
||||
renderCampaignMeta();
|
||||
renderCampaignDetails();
|
||||
renderCampaignLog();
|
||||
await reloadSelectedCampaign(state);
|
||||
syncSelectedCharacter(state);
|
||||
renderCharacterSelect(state, elements);
|
||||
renderSkillSelect(state, elements);
|
||||
syncEventStream();
|
||||
renderCampaignMeta(state, elements);
|
||||
renderCampaignDetails(state, elements);
|
||||
renderCampaignLog(state, elements);
|
||||
});
|
||||
});
|
||||
|
||||
characterSelect.addEventListener("change", () => {
|
||||
state.selectedCharacterId = characterSelect.value.length > 0 ? characterSelect.value : null;
|
||||
renderSkillSelect();
|
||||
elements.characterSelect.addEventListener("change", () => {
|
||||
state.selectedCharacterId = elements.characterSelect.value.length > 0 ? elements.characterSelect.value : null;
|
||||
renderSkillSelect(state, elements);
|
||||
});
|
||||
|
||||
refreshCampaignButton.addEventListener("click", async () => {
|
||||
await runAction(async () => {
|
||||
await reloadSelectedCampaign();
|
||||
renderAll();
|
||||
setMessage("Campaign refreshed.", false);
|
||||
elements.refreshCampaignButton.addEventListener("click", async () => {
|
||||
await runAppAction(async () => {
|
||||
await reloadSelectedCampaign(state);
|
||||
await reloadCampaignLog(state);
|
||||
renderAll(state, elements);
|
||||
setStatus("Campaign refreshed.", false);
|
||||
});
|
||||
});
|
||||
|
||||
characterForm.addEventListener("submit", async (event) => {
|
||||
elements.characterForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
await runAction(async () => {
|
||||
await runAppAction(async () => {
|
||||
if (!state.selectedCampaignId) {
|
||||
throw new Error("Select a campaign first.");
|
||||
}
|
||||
|
||||
await createCharacter({
|
||||
name: characterNameInput.value.trim(),
|
||||
name: elements.characterNameInput.value.trim(),
|
||||
campaignId: state.selectedCampaignId
|
||||
});
|
||||
|
||||
characterNameInput.value = "";
|
||||
await reloadSelectedCampaign();
|
||||
await reloadCampaignLog();
|
||||
setMessage("Character created.", false);
|
||||
elements.characterNameInput.value = "";
|
||||
await reloadSelectedCampaign(state);
|
||||
await reloadCampaignLog(state);
|
||||
renderAll(state, elements);
|
||||
setStatus("Character created.", false);
|
||||
});
|
||||
});
|
||||
|
||||
activateCharacterButton.addEventListener("click", async () => {
|
||||
await runAction(async () => {
|
||||
const characterId = characterSelect.value;
|
||||
elements.activateCharacterButton.addEventListener("click", async () => {
|
||||
await runAppAction(async () => {
|
||||
const characterId = elements.characterSelect.value;
|
||||
if (characterId.length === 0) {
|
||||
throw new Error("Select a character to activate.");
|
||||
}
|
||||
@@ -196,426 +146,107 @@ activateCharacterButton.addEventListener("click", async () => {
|
||||
state.activeCharacterId = characterId;
|
||||
state.selectedCharacterId = characterId;
|
||||
await reloadAll();
|
||||
setMessage("Active character updated.", false);
|
||||
setStatus("Active character updated.", false);
|
||||
});
|
||||
});
|
||||
|
||||
skillForm.addEventListener("submit", async (event) => {
|
||||
elements.skillForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
await runAction(async () => {
|
||||
const characterId = characterSelect.value;
|
||||
await runAppAction(async () => {
|
||||
const characterId = elements.characterSelect.value;
|
||||
if (characterId.length === 0) {
|
||||
throw new Error("Select a character first.");
|
||||
}
|
||||
|
||||
await createSkill(characterId, {
|
||||
name: skillNameInput.value.trim(),
|
||||
diceRollDefinition: skillExpressionInput.value.trim()
|
||||
name: elements.skillNameInput.value.trim(),
|
||||
diceRollDefinition: elements.skillExpressionInput.value.trim()
|
||||
});
|
||||
|
||||
skillNameInput.value = "";
|
||||
skillExpressionInput.value = "";
|
||||
await reloadSelectedCampaign();
|
||||
setMessage("Skill created.", false);
|
||||
elements.skillNameInput.value = "";
|
||||
elements.skillExpressionInput.value = "";
|
||||
await reloadSelectedCampaign(state);
|
||||
renderAll(state, elements);
|
||||
setStatus("Skill created.", false);
|
||||
});
|
||||
});
|
||||
|
||||
updateSkillButton.addEventListener("click", async () => {
|
||||
await runAction(async () => {
|
||||
const selectedSkillId = skillSelect.value;
|
||||
elements.updateSkillButton.addEventListener("click", async () => {
|
||||
await runAppAction(async () => {
|
||||
const selectedSkillId = elements.skillSelect.value;
|
||||
if (selectedSkillId.length === 0) {
|
||||
throw new Error("Select a skill to update.");
|
||||
}
|
||||
|
||||
await updateSkill(selectedSkillId, {
|
||||
name: skillNameInput.value.trim(),
|
||||
diceRollDefinition: skillExpressionInput.value.trim()
|
||||
name: elements.skillNameInput.value.trim(),
|
||||
diceRollDefinition: elements.skillExpressionInput.value.trim()
|
||||
});
|
||||
|
||||
await reloadSelectedCampaign();
|
||||
setMessage("Skill updated.", false);
|
||||
await reloadSelectedCampaign(state);
|
||||
renderAll(state, elements);
|
||||
setStatus("Skill updated.", false);
|
||||
});
|
||||
});
|
||||
|
||||
rollForm.addEventListener("submit", async (event) => {
|
||||
elements.rollForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
await runAction(async () => {
|
||||
const selectedSkillId = skillSelect.value;
|
||||
await runAppAction(async () => {
|
||||
const selectedSkillId = elements.skillSelect.value;
|
||||
if (selectedSkillId.length === 0) {
|
||||
throw new Error("Select a skill to roll.");
|
||||
}
|
||||
|
||||
const roll = await rollSkill(selectedSkillId, { visibility: rollVisibilitySelect.value });
|
||||
rollResultElement.textContent = `Roll ${roll.rollId}: ${roll.breakdown} (${roll.visibility})`;
|
||||
const roll = await rollSkill(selectedSkillId, { visibility: elements.rollVisibilitySelect.value });
|
||||
elements.rollResultElement.textContent = `Roll ${roll.rollId}: ${roll.breakdown} (${roll.visibility})`;
|
||||
|
||||
await reloadCampaignLog();
|
||||
setMessage("Roll recorded.", false);
|
||||
await reloadCampaignLog(state);
|
||||
renderCampaignLog(state, elements);
|
||||
setStatus("Roll recorded.", false);
|
||||
});
|
||||
});
|
||||
|
||||
await runAction(async () => {
|
||||
await refreshHealth();
|
||||
await runAppAction(async () => {
|
||||
await refreshHealth(elements);
|
||||
await reloadAll();
|
||||
setMessage("Ready.", false);
|
||||
setStatus("Ready.", false);
|
||||
});
|
||||
|
||||
async function refreshHealth(): Promise<void> {
|
||||
const health = await getHealth();
|
||||
healthElement.textContent = `API status: ${health.status}`;
|
||||
}
|
||||
|
||||
async function reloadAll(): Promise<void> {
|
||||
await ensureRulesets();
|
||||
await reloadSession();
|
||||
await ensureRulesets(state, elements);
|
||||
await reloadSession(state);
|
||||
if (state.user) {
|
||||
await reloadCampaigns(state.selectedCampaignId);
|
||||
await reloadSelectedCampaign();
|
||||
await reloadCampaignLog();
|
||||
connectStateEvents();
|
||||
await reloadCampaigns(state, state.selectedCampaignId);
|
||||
await reloadSelectedCampaign(state);
|
||||
await reloadCampaignLog(state);
|
||||
syncEventStream();
|
||||
}
|
||||
else {
|
||||
resetAuthenticatedState();
|
||||
resetAuthenticatedState(state);
|
||||
closeStateEvents(state);
|
||||
}
|
||||
|
||||
renderAll();
|
||||
renderAll(state, elements);
|
||||
}
|
||||
|
||||
async function reloadSession(): Promise<void> {
|
||||
try {
|
||||
const me = await getMe();
|
||||
state.user = me.user;
|
||||
state.activeCharacterId = me.activeCharacterId ?? null;
|
||||
state.selectedCampaignId = me.currentCampaignId ?? state.selectedCampaignId;
|
||||
}
|
||||
catch {
|
||||
state.user = null;
|
||||
state.activeCharacterId = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureRulesets(): Promise<void> {
|
||||
if (state.rulesets.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.rulesets = await getRulesets();
|
||||
campaignRulesetSelect.innerHTML = state.rulesets
|
||||
.map((ruleset) => `<option value="${ruleset.id}">${ruleset.name}</option>`)
|
||||
.join("");
|
||||
}
|
||||
|
||||
async function reloadCampaigns(preferredCampaignId: string | null): Promise<void> {
|
||||
state.campaigns = await getCampaigns();
|
||||
|
||||
if (state.campaigns.length === 0) {
|
||||
state.selectedCampaignId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const availableIds = new Set(state.campaigns.map((campaign) => campaign.id));
|
||||
|
||||
if (preferredCampaignId && availableIds.has(preferredCampaignId)) {
|
||||
state.selectedCampaignId = preferredCampaignId;
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.selectedCampaignId && availableIds.has(state.selectedCampaignId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.selectedCampaignId = state.campaigns[0].id;
|
||||
}
|
||||
|
||||
async function reloadSelectedCampaign(): Promise<void> {
|
||||
if (!state.selectedCampaignId) {
|
||||
state.selectedCampaign = null;
|
||||
return;
|
||||
}
|
||||
|
||||
state.selectedCampaign = await getCampaign(state.selectedCampaignId);
|
||||
syncSelectedCharacter();
|
||||
}
|
||||
|
||||
async function reloadCampaignLog(): Promise<void> {
|
||||
if (!state.selectedCampaignId) {
|
||||
state.campaignLog = [];
|
||||
return;
|
||||
}
|
||||
|
||||
state.campaignLog = await getCampaignLog(state.selectedCampaignId);
|
||||
}
|
||||
|
||||
function connectStateEvents(): void {
|
||||
if (!state.selectedCampaignId || !state.user) {
|
||||
closeStateEvents();
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.eventSource && state.eventSource.url.endsWith(`campaignId=${state.selectedCampaignId}`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
closeStateEvents();
|
||||
state.eventSource = new EventSource(`/api/events/state?campaignId=${state.selectedCampaignId}`);
|
||||
|
||||
state.eventSource.addEventListener("state", () => {
|
||||
void runAction(async () => {
|
||||
await reloadSelectedCampaign();
|
||||
await reloadCampaignLog();
|
||||
renderAll();
|
||||
function syncEventStream(): void {
|
||||
connectStateEvents(state, () => {
|
||||
void runAppAction(async () => {
|
||||
await reloadSelectedCampaign(state);
|
||||
await reloadCampaignLog(state);
|
||||
renderAll(state, elements);
|
||||
});
|
||||
});
|
||||
|
||||
state.eventSource.onerror = () => {
|
||||
closeStateEvents();
|
||||
};
|
||||
}
|
||||
|
||||
function closeStateEvents(): void {
|
||||
if (state.eventSource) {
|
||||
state.eventSource.close();
|
||||
state.eventSource = null;
|
||||
}
|
||||
async function runAppAction(action: () => Promise<void>): Promise<void> {
|
||||
await runAction(action, (message) => {
|
||||
setStatus(message, true);
|
||||
});
|
||||
}
|
||||
|
||||
function resetStateAfterLogout(): void {
|
||||
state.user = null;
|
||||
resetAuthenticatedState();
|
||||
closeStateEvents();
|
||||
}
|
||||
|
||||
function resetAuthenticatedState(): void {
|
||||
state.activeCharacterId = null;
|
||||
state.selectedCharacterId = null;
|
||||
state.campaigns = [];
|
||||
state.selectedCampaignId = null;
|
||||
state.selectedCampaign = null;
|
||||
state.campaignLog = [];
|
||||
}
|
||||
|
||||
function renderAll(): void {
|
||||
renderCampaignSelect();
|
||||
renderCampaignMeta();
|
||||
renderCampaignDetails();
|
||||
renderCharacterSelect();
|
||||
renderSkillSelect();
|
||||
renderCampaignLog();
|
||||
}
|
||||
|
||||
function renderCampaignSelect(): void {
|
||||
campaignSelect.innerHTML = state.campaigns
|
||||
.map((campaign) => {
|
||||
const selected = campaign.id === state.selectedCampaignId ? " selected" : "";
|
||||
return `<option value="${campaign.id}"${selected}>${campaign.name} (${campaign.rulesetId})</option>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function renderCampaignMeta(): void {
|
||||
if (!state.user) {
|
||||
campaignMetaElement.textContent = "Log in to manage campaigns.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!state.selectedCampaign) {
|
||||
campaignMetaElement.textContent = "No campaign selected.";
|
||||
return;
|
||||
}
|
||||
|
||||
const isGm = state.selectedCampaign.gm.id === state.user.id;
|
||||
const activeCharacter = state.selectedCampaign.characters.find((character) => character.id === state.activeCharacterId);
|
||||
const activeLabel = activeCharacter ? ` | Active: ${activeCharacter.name}` : "";
|
||||
campaignMetaElement.textContent = `Selected: ${state.selectedCampaign.name} (${state.selectedCampaign.rulesetId}) - ${isGm ? "You are GM" : "Player context"}${activeLabel}`;
|
||||
}
|
||||
|
||||
function renderCampaignDetails(): void {
|
||||
if (!state.selectedCampaign) {
|
||||
campaignDetailsElement.textContent = "No details available.";
|
||||
return;
|
||||
}
|
||||
|
||||
const characters = state.selectedCampaign.characters
|
||||
.map((character) => `<li>${character.name} (${character.id})</li>`)
|
||||
.join("");
|
||||
|
||||
const skills = state.selectedCampaign.skills
|
||||
.map((skill) => `<li>${skill.name} [${skill.diceRollDefinition}]</li>`)
|
||||
.join("");
|
||||
|
||||
campaignDetailsElement.innerHTML = `
|
||||
<p>GM: ${state.selectedCampaign.gm.displayName}</p>
|
||||
<p>Characters visible to you: ${state.selectedCampaign.characters.length}</p>
|
||||
<ul>${characters}</ul>
|
||||
<p>Skills visible to you: ${state.selectedCampaign.skills.length}</p>
|
||||
<ul>${skills}</ul>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderCharacterSelect(): void {
|
||||
if (!state.selectedCampaign) {
|
||||
characterSelect.innerHTML = "";
|
||||
state.selectedCharacterId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedCharacterId = resolveSelectedCharacterId();
|
||||
|
||||
const options = state.selectedCampaign.characters
|
||||
.map((character) => {
|
||||
const selected = character.id === selectedCharacterId ? " selected" : "";
|
||||
return `<option value="${character.id}"${selected}>${character.name}</option>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
characterSelect.innerHTML = options;
|
||||
state.selectedCharacterId = selectedCharacterId;
|
||||
}
|
||||
|
||||
function renderSkillSelect(): void {
|
||||
if (!state.selectedCampaign) {
|
||||
skillSelect.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedCharacterId = resolveSelectedCharacterId();
|
||||
const characterSkills = state.selectedCampaign.skills.filter((skill) => skill.characterId === selectedCharacterId);
|
||||
const options = characterSkills
|
||||
.map((skill) => `<option value="${skill.id}">${skill.name} (${skill.diceRollDefinition})</option>`)
|
||||
.join("");
|
||||
|
||||
skillSelect.innerHTML = options;
|
||||
|
||||
const selectedSkill = selectedSkillFromCampaign();
|
||||
if (selectedSkill) {
|
||||
skillNameInput.value = selectedSkill.name;
|
||||
skillExpressionInput.value = selectedSkill.diceRollDefinition;
|
||||
}
|
||||
}
|
||||
|
||||
function selectedSkillFromCampaign(): SkillSummary | null {
|
||||
if (!state.selectedCampaign) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const selectedCharacterId = resolveSelectedCharacterId();
|
||||
const selectedSkillId = skillSelect.value;
|
||||
return state.selectedCampaign.skills
|
||||
.filter((skill) => skill.characterId === selectedCharacterId)
|
||||
.find((skill) => skill.id === selectedSkillId) ?? null;
|
||||
}
|
||||
|
||||
function renderCampaignLog(): void {
|
||||
if (state.campaignLog.length === 0) {
|
||||
campaignLogElement.innerHTML = "<li class=\"log-item\">No rolls yet.</li>";
|
||||
return;
|
||||
}
|
||||
|
||||
campaignLogElement.innerHTML = state.campaignLog
|
||||
.map((entry) => `
|
||||
<li class="log-item">
|
||||
<strong>${entry.visibility.toUpperCase()}</strong>
|
||||
<span> ${entry.breakdown}</span>
|
||||
<small> ${new Date(entry.timestampUtc).toLocaleString()}</small>
|
||||
</li>
|
||||
`)
|
||||
.join("");
|
||||
}
|
||||
|
||||
async function runAction(action: () => Promise<void>): Promise<void> {
|
||||
try {
|
||||
await action();
|
||||
}
|
||||
catch (error: unknown) {
|
||||
setMessage(formatError(error), true);
|
||||
}
|
||||
}
|
||||
|
||||
function setMessage(message: string, isError: boolean): void {
|
||||
messageElement.textContent = message;
|
||||
messageElement.style.color = isError ? "#b91c1c" : "#1d4ed8";
|
||||
}
|
||||
|
||||
function formatError(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function mustElement(id: string): HTMLElement {
|
||||
const element = document.getElementById(id);
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
throw new Error(`Missing HTMLElement: ${id}`);
|
||||
}
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
function mustInput(id: string): HTMLInputElement {
|
||||
const element = document.getElementById(id);
|
||||
if (!(element instanceof HTMLInputElement)) {
|
||||
throw new Error(`Missing HTMLInputElement: ${id}`);
|
||||
}
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
function mustSelect(id: string): HTMLSelectElement {
|
||||
const element = document.getElementById(id);
|
||||
if (!(element instanceof HTMLSelectElement)) {
|
||||
throw new Error(`Missing HTMLSelectElement: ${id}`);
|
||||
}
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
function mustForm(id: string): HTMLFormElement {
|
||||
const element = document.getElementById(id);
|
||||
if (!(element instanceof HTMLFormElement)) {
|
||||
throw new Error(`Missing HTMLFormElement: ${id}`);
|
||||
}
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
function mustButton(id: string): HTMLButtonElement {
|
||||
const element = document.getElementById(id);
|
||||
if (!(element instanceof HTMLButtonElement)) {
|
||||
throw new Error(`Missing HTMLButtonElement: ${id}`);
|
||||
}
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
function syncSelectedCharacter(): void {
|
||||
if (!state.selectedCampaign) {
|
||||
state.selectedCharacterId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const availableIds = new Set(state.selectedCampaign.characters.map((character) => character.id));
|
||||
if (state.selectedCharacterId && availableIds.has(state.selectedCharacterId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.activeCharacterId && availableIds.has(state.activeCharacterId)) {
|
||||
state.selectedCharacterId = state.activeCharacterId;
|
||||
return;
|
||||
}
|
||||
|
||||
state.selectedCharacterId = state.selectedCampaign.characters.length > 0
|
||||
? state.selectedCampaign.characters[0].id
|
||||
: null;
|
||||
}
|
||||
|
||||
function resolveSelectedCharacterId(): string | null {
|
||||
if (!state.selectedCampaign) {
|
||||
return null;
|
||||
}
|
||||
|
||||
syncSelectedCharacter();
|
||||
return state.selectedCharacterId;
|
||||
function setStatus(message: string, isError: boolean): void {
|
||||
setMessage(elements.messageElement, message, isError);
|
||||
}
|
||||
|
||||
24
RpgRoller/frontend/app/actions.ts
Normal file
24
RpgRoller/frontend/app/actions.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export async function runAction(
|
||||
action: () => Promise<void>,
|
||||
onError: (message: string) => void
|
||||
): Promise<void> {
|
||||
try {
|
||||
await action();
|
||||
}
|
||||
catch (error: unknown) {
|
||||
onError(formatError(error));
|
||||
}
|
||||
}
|
||||
|
||||
export function setMessage(messageElement: HTMLElement, message: string, isError: boolean): void {
|
||||
messageElement.textContent = message;
|
||||
messageElement.style.color = isError ? "#b91c1c" : "#1d4ed8";
|
||||
}
|
||||
|
||||
function formatError(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return String(error);
|
||||
}
|
||||
112
RpgRoller/frontend/app/dom.ts
Normal file
112
RpgRoller/frontend/app/dom.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
export type AppElements = {
|
||||
healthElement: HTMLElement;
|
||||
messageElement: HTMLElement;
|
||||
campaignMetaElement: HTMLElement;
|
||||
campaignDetailsElement: HTMLElement;
|
||||
rollResultElement: HTMLElement;
|
||||
campaignLogElement: HTMLElement;
|
||||
registerForm: HTMLFormElement;
|
||||
loginForm: HTMLFormElement;
|
||||
logoutButton: HTMLButtonElement;
|
||||
registerUsername: HTMLInputElement;
|
||||
registerDisplayName: HTMLInputElement;
|
||||
registerPassword: HTMLInputElement;
|
||||
loginUsername: HTMLInputElement;
|
||||
loginPassword: HTMLInputElement;
|
||||
campaignForm: HTMLFormElement;
|
||||
campaignNameInput: HTMLInputElement;
|
||||
campaignRulesetSelect: HTMLSelectElement;
|
||||
campaignSelect: HTMLSelectElement;
|
||||
refreshCampaignButton: HTMLButtonElement;
|
||||
characterForm: HTMLFormElement;
|
||||
characterNameInput: HTMLInputElement;
|
||||
characterSelect: HTMLSelectElement;
|
||||
activateCharacterButton: HTMLButtonElement;
|
||||
skillForm: HTMLFormElement;
|
||||
skillNameInput: HTMLInputElement;
|
||||
skillExpressionInput: HTMLInputElement;
|
||||
updateSkillButton: HTMLButtonElement;
|
||||
skillSelect: HTMLSelectElement;
|
||||
rollForm: HTMLFormElement;
|
||||
rollVisibilitySelect: HTMLSelectElement;
|
||||
};
|
||||
|
||||
export function getAppElements(): AppElements {
|
||||
return {
|
||||
healthElement: mustElement("health"),
|
||||
messageElement: mustElement("message"),
|
||||
campaignMetaElement: mustElement("campaign-meta"),
|
||||
campaignDetailsElement: mustElement("campaign-details"),
|
||||
rollResultElement: mustElement("roll-result"),
|
||||
campaignLogElement: mustElement("campaign-log"),
|
||||
registerForm: mustForm("register-form"),
|
||||
loginForm: mustForm("login-form"),
|
||||
logoutButton: mustButton("logout-button"),
|
||||
registerUsername: mustInput("register-username"),
|
||||
registerDisplayName: mustInput("register-display-name"),
|
||||
registerPassword: mustInput("register-password"),
|
||||
loginUsername: mustInput("login-username"),
|
||||
loginPassword: mustInput("login-password"),
|
||||
campaignForm: mustForm("campaign-form"),
|
||||
campaignNameInput: mustInput("campaign-name"),
|
||||
campaignRulesetSelect: mustSelect("campaign-ruleset"),
|
||||
campaignSelect: mustSelect("campaign-select"),
|
||||
refreshCampaignButton: mustButton("refresh-campaign-button"),
|
||||
characterForm: mustForm("character-form"),
|
||||
characterNameInput: mustInput("character-name"),
|
||||
characterSelect: mustSelect("character-select"),
|
||||
activateCharacterButton: mustButton("activate-character-button"),
|
||||
skillForm: mustForm("skill-form"),
|
||||
skillNameInput: mustInput("skill-name"),
|
||||
skillExpressionInput: mustInput("skill-expression"),
|
||||
updateSkillButton: mustButton("update-skill-button"),
|
||||
skillSelect: mustSelect("skill-select"),
|
||||
rollForm: mustForm("roll-form"),
|
||||
rollVisibilitySelect: mustSelect("roll-visibility")
|
||||
};
|
||||
}
|
||||
|
||||
function mustElement(id: string): HTMLElement {
|
||||
const element = document.getElementById(id);
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
throw new Error(`Missing HTMLElement: ${id}`);
|
||||
}
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
function mustInput(id: string): HTMLInputElement {
|
||||
const element = document.getElementById(id);
|
||||
if (!(element instanceof HTMLInputElement)) {
|
||||
throw new Error(`Missing HTMLInputElement: ${id}`);
|
||||
}
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
function mustSelect(id: string): HTMLSelectElement {
|
||||
const element = document.getElementById(id);
|
||||
if (!(element instanceof HTMLSelectElement)) {
|
||||
throw new Error(`Missing HTMLSelectElement: ${id}`);
|
||||
}
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
function mustForm(id: string): HTMLFormElement {
|
||||
const element = document.getElementById(id);
|
||||
if (!(element instanceof HTMLFormElement)) {
|
||||
throw new Error(`Missing HTMLFormElement: ${id}`);
|
||||
}
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
function mustButton(id: string): HTMLButtonElement {
|
||||
const element = document.getElementById(id);
|
||||
if (!(element instanceof HTMLButtonElement)) {
|
||||
throw new Error(`Missing HTMLButtonElement: ${id}`);
|
||||
}
|
||||
|
||||
return element;
|
||||
}
|
||||
30
RpgRoller/frontend/app/events.ts
Normal file
30
RpgRoller/frontend/app/events.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { AppState } from "./types.js";
|
||||
|
||||
export function connectStateEvents(state: AppState, onStateChanged: () => void): void {
|
||||
if (!state.selectedCampaignId || !state.user) {
|
||||
closeStateEvents(state);
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.eventSource && state.eventSource.url.endsWith(`campaignId=${state.selectedCampaignId}`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
closeStateEvents(state);
|
||||
state.eventSource = new EventSource(`/api/events/state?campaignId=${state.selectedCampaignId}`);
|
||||
|
||||
state.eventSource.addEventListener("state", () => {
|
||||
onStateChanged();
|
||||
});
|
||||
|
||||
state.eventSource.onerror = () => {
|
||||
closeStateEvents(state);
|
||||
};
|
||||
}
|
||||
|
||||
export function closeStateEvents(state: AppState): void {
|
||||
if (state.eventSource) {
|
||||
state.eventSource.close();
|
||||
state.eventSource = null;
|
||||
}
|
||||
}
|
||||
74
RpgRoller/frontend/app/loaders.ts
Normal file
74
RpgRoller/frontend/app/loaders.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { getCampaign, getCampaignLog, getCampaigns, getHealth, getMe, getRulesets } from "../generated/api-client.js";
|
||||
import type { AppElements } from "./dom.js";
|
||||
import { syncSelectedCharacter } from "./state.js";
|
||||
import type { AppState } from "./types.js";
|
||||
|
||||
export async function refreshHealth(elements: AppElements): Promise<void> {
|
||||
const health = await getHealth();
|
||||
elements.healthElement.textContent = `API status: ${health.status}`;
|
||||
}
|
||||
|
||||
export async function ensureRulesets(state: AppState, elements: AppElements): Promise<void> {
|
||||
if (state.rulesets.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.rulesets = await getRulesets();
|
||||
elements.campaignRulesetSelect.innerHTML = state.rulesets
|
||||
.map((ruleset) => `<option value="${ruleset.id}">${ruleset.name}</option>`)
|
||||
.join("");
|
||||
}
|
||||
|
||||
export async function reloadSession(state: AppState): Promise<void> {
|
||||
try {
|
||||
const me = await getMe();
|
||||
state.user = me.user;
|
||||
state.activeCharacterId = me.activeCharacterId ?? null;
|
||||
state.selectedCampaignId = me.currentCampaignId ?? state.selectedCampaignId;
|
||||
}
|
||||
catch {
|
||||
state.user = null;
|
||||
state.activeCharacterId = null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function reloadCampaigns(state: AppState, preferredCampaignId: string | null): Promise<void> {
|
||||
state.campaigns = await getCampaigns();
|
||||
|
||||
if (state.campaigns.length === 0) {
|
||||
state.selectedCampaignId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const availableIds = new Set(state.campaigns.map((campaign) => campaign.id));
|
||||
|
||||
if (preferredCampaignId && availableIds.has(preferredCampaignId)) {
|
||||
state.selectedCampaignId = preferredCampaignId;
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.selectedCampaignId && availableIds.has(state.selectedCampaignId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.selectedCampaignId = state.campaigns[0].id;
|
||||
}
|
||||
|
||||
export async function reloadSelectedCampaign(state: AppState): Promise<void> {
|
||||
if (!state.selectedCampaignId) {
|
||||
state.selectedCampaign = null;
|
||||
return;
|
||||
}
|
||||
|
||||
state.selectedCampaign = await getCampaign(state.selectedCampaignId);
|
||||
syncSelectedCharacter(state);
|
||||
}
|
||||
|
||||
export async function reloadCampaignLog(state: AppState): Promise<void> {
|
||||
if (!state.selectedCampaignId) {
|
||||
state.campaignLog = [];
|
||||
return;
|
||||
}
|
||||
|
||||
state.campaignLog = await getCampaignLog(state.selectedCampaignId);
|
||||
}
|
||||
117
RpgRoller/frontend/app/render.ts
Normal file
117
RpgRoller/frontend/app/render.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import type { AppElements } from "./dom.js";
|
||||
import { resolveSelectedCharacterId, selectedSkillFromCampaign } from "./state.js";
|
||||
import type { AppState } from "./types.js";
|
||||
|
||||
export function renderAll(state: AppState, elements: AppElements): void {
|
||||
renderCampaignSelect(state, elements);
|
||||
renderCampaignMeta(state, elements);
|
||||
renderCampaignDetails(state, elements);
|
||||
renderCharacterSelect(state, elements);
|
||||
renderSkillSelect(state, elements);
|
||||
renderCampaignLog(state, elements);
|
||||
}
|
||||
|
||||
export function renderCampaignMeta(state: AppState, elements: AppElements): void {
|
||||
if (!state.user) {
|
||||
elements.campaignMetaElement.textContent = "Log in to manage campaigns.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!state.selectedCampaign) {
|
||||
elements.campaignMetaElement.textContent = "No campaign selected.";
|
||||
return;
|
||||
}
|
||||
|
||||
const isGm = state.selectedCampaign.gm.id === state.user.id;
|
||||
const activeCharacter = state.selectedCampaign.characters.find((character) => character.id === state.activeCharacterId);
|
||||
const activeLabel = activeCharacter ? ` | Active: ${activeCharacter.name}` : "";
|
||||
elements.campaignMetaElement.textContent = `Selected: ${state.selectedCampaign.name} (${state.selectedCampaign.rulesetId}) - ${isGm ? "You are GM" : "Player context"}${activeLabel}`;
|
||||
}
|
||||
|
||||
export function renderCampaignDetails(state: AppState, elements: AppElements): void {
|
||||
if (!state.selectedCampaign) {
|
||||
elements.campaignDetailsElement.textContent = "No details available.";
|
||||
return;
|
||||
}
|
||||
|
||||
const characters = state.selectedCampaign.characters
|
||||
.map((character) => `<li>${character.name} (${character.id})</li>`)
|
||||
.join("");
|
||||
|
||||
const skills = state.selectedCampaign.skills
|
||||
.map((skill) => `<li>${skill.name} [${skill.diceRollDefinition}]</li>`)
|
||||
.join("");
|
||||
|
||||
elements.campaignDetailsElement.innerHTML = `
|
||||
<p>GM: ${state.selectedCampaign.gm.displayName}</p>
|
||||
<p>Characters visible to you: ${state.selectedCampaign.characters.length}</p>
|
||||
<ul>${characters}</ul>
|
||||
<p>Skills visible to you: ${state.selectedCampaign.skills.length}</p>
|
||||
<ul>${skills}</ul>
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderCharacterSelect(state: AppState, elements: AppElements): void {
|
||||
if (!state.selectedCampaign) {
|
||||
elements.characterSelect.innerHTML = "";
|
||||
state.selectedCharacterId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedCharacterId = resolveSelectedCharacterId(state);
|
||||
const options = state.selectedCampaign.characters
|
||||
.map((character) => {
|
||||
const selected = character.id === selectedCharacterId ? " selected" : "";
|
||||
return `<option value="${character.id}"${selected}>${character.name}</option>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
elements.characterSelect.innerHTML = options;
|
||||
state.selectedCharacterId = selectedCharacterId;
|
||||
}
|
||||
|
||||
export function renderSkillSelect(state: AppState, elements: AppElements): void {
|
||||
if (!state.selectedCampaign) {
|
||||
elements.skillSelect.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedCharacterId = resolveSelectedCharacterId(state);
|
||||
const characterSkills = state.selectedCampaign.skills.filter((skill) => skill.characterId === selectedCharacterId);
|
||||
const options = characterSkills
|
||||
.map((skill) => `<option value="${skill.id}">${skill.name} (${skill.diceRollDefinition})</option>`)
|
||||
.join("");
|
||||
|
||||
elements.skillSelect.innerHTML = options;
|
||||
const selectedSkill = selectedSkillFromCampaign(state, elements.skillSelect.value);
|
||||
if (selectedSkill) {
|
||||
elements.skillNameInput.value = selectedSkill.name;
|
||||
elements.skillExpressionInput.value = selectedSkill.diceRollDefinition;
|
||||
}
|
||||
}
|
||||
|
||||
export function renderCampaignLog(state: AppState, elements: AppElements): void {
|
||||
if (state.campaignLog.length === 0) {
|
||||
elements.campaignLogElement.innerHTML = "<li class=\"log-item\">No rolls yet.</li>";
|
||||
return;
|
||||
}
|
||||
|
||||
elements.campaignLogElement.innerHTML = state.campaignLog
|
||||
.map((entry) => `
|
||||
<li class="log-item">
|
||||
<strong>${entry.visibility.toUpperCase()}</strong>
|
||||
<span> ${entry.breakdown}</span>
|
||||
<small> ${new Date(entry.timestampUtc).toLocaleString()}</small>
|
||||
</li>
|
||||
`)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function renderCampaignSelect(state: AppState, elements: AppElements): void {
|
||||
elements.campaignSelect.innerHTML = state.campaigns
|
||||
.map((campaign) => {
|
||||
const selected = campaign.id === state.selectedCampaignId ? " selected" : "";
|
||||
return `<option value="${campaign.id}"${selected}>${campaign.name} (${campaign.rulesetId})</option>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
71
RpgRoller/frontend/app/state.ts
Normal file
71
RpgRoller/frontend/app/state.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { SkillSummary } from "../generated/api-client.js";
|
||||
import type { AppState } from "./types.js";
|
||||
|
||||
export function createInitialState(): AppState {
|
||||
return {
|
||||
user: null,
|
||||
activeCharacterId: null,
|
||||
selectedCharacterId: null,
|
||||
campaigns: [],
|
||||
selectedCampaignId: null,
|
||||
selectedCampaign: null,
|
||||
campaignLog: [],
|
||||
rulesets: [],
|
||||
eventSource: null
|
||||
};
|
||||
}
|
||||
|
||||
export function resetAuthenticatedState(state: AppState): void {
|
||||
state.activeCharacterId = null;
|
||||
state.selectedCharacterId = null;
|
||||
state.campaigns = [];
|
||||
state.selectedCampaignId = null;
|
||||
state.selectedCampaign = null;
|
||||
state.campaignLog = [];
|
||||
}
|
||||
|
||||
export function resetStateAfterLogout(state: AppState): void {
|
||||
state.user = null;
|
||||
resetAuthenticatedState(state);
|
||||
}
|
||||
|
||||
export function syncSelectedCharacter(state: AppState): void {
|
||||
if (!state.selectedCampaign) {
|
||||
state.selectedCharacterId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const availableIds = new Set(state.selectedCampaign.characters.map((character) => character.id));
|
||||
if (state.selectedCharacterId && availableIds.has(state.selectedCharacterId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.activeCharacterId && availableIds.has(state.activeCharacterId)) {
|
||||
state.selectedCharacterId = state.activeCharacterId;
|
||||
return;
|
||||
}
|
||||
|
||||
state.selectedCharacterId = state.selectedCampaign.characters.length > 0
|
||||
? state.selectedCampaign.characters[0].id
|
||||
: null;
|
||||
}
|
||||
|
||||
export function resolveSelectedCharacterId(state: AppState): string | null {
|
||||
if (!state.selectedCampaign) {
|
||||
return null;
|
||||
}
|
||||
|
||||
syncSelectedCharacter(state);
|
||||
return state.selectedCharacterId;
|
||||
}
|
||||
|
||||
export function selectedSkillFromCampaign(state: AppState, selectedSkillId: string): SkillSummary | null {
|
||||
if (!state.selectedCampaign) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const selectedCharacterId = resolveSelectedCharacterId(state);
|
||||
return state.selectedCampaign.skills
|
||||
.filter((skill) => skill.characterId === selectedCharacterId)
|
||||
.find((skill) => skill.id === selectedSkillId) ?? null;
|
||||
}
|
||||
19
RpgRoller/frontend/app/types.ts
Normal file
19
RpgRoller/frontend/app/types.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type {
|
||||
CampaignDetails,
|
||||
CampaignLogEntry,
|
||||
CampaignSummary,
|
||||
RulesetDefinition,
|
||||
UserSummary
|
||||
} from "../generated/api-client.js";
|
||||
|
||||
export type AppState = {
|
||||
user: UserSummary | null;
|
||||
activeCharacterId: string | null;
|
||||
selectedCharacterId: string | null;
|
||||
campaigns: CampaignSummary[];
|
||||
selectedCampaignId: string | null;
|
||||
selectedCampaign: CampaignDetails | null;
|
||||
campaignLog: CampaignLogEntry[];
|
||||
rulesets: RulesetDefinition[];
|
||||
eventSource: EventSource | null;
|
||||
};
|
||||
@@ -1,133 +1,107 @@
|
||||
import { activateCharacter, createCampaign, createCharacter, createSkill, getCampaign, getCampaignLog, getCampaigns, getHealth, getMe, getRulesets, loginUser, logoutUser, registerUser, rollSkill, updateSkill } from "./generated/api-client.js";
|
||||
const healthElement = mustElement("health");
|
||||
const messageElement = mustElement("message");
|
||||
const campaignMetaElement = mustElement("campaign-meta");
|
||||
const campaignDetailsElement = mustElement("campaign-details");
|
||||
const rollResultElement = mustElement("roll-result");
|
||||
const campaignLogElement = mustElement("campaign-log");
|
||||
const registerForm = mustForm("register-form");
|
||||
const loginForm = mustForm("login-form");
|
||||
const logoutButton = mustButton("logout-button");
|
||||
const registerUsername = mustInput("register-username");
|
||||
const registerDisplayName = mustInput("register-display-name");
|
||||
const registerPassword = mustInput("register-password");
|
||||
const loginUsername = mustInput("login-username");
|
||||
const loginPassword = mustInput("login-password");
|
||||
const campaignForm = mustForm("campaign-form");
|
||||
const campaignNameInput = mustInput("campaign-name");
|
||||
const campaignRulesetSelect = mustSelect("campaign-ruleset");
|
||||
const campaignSelect = mustSelect("campaign-select");
|
||||
const refreshCampaignButton = mustButton("refresh-campaign-button");
|
||||
const characterForm = mustForm("character-form");
|
||||
const characterNameInput = mustInput("character-name");
|
||||
const characterSelect = mustSelect("character-select");
|
||||
const activateCharacterButton = mustButton("activate-character-button");
|
||||
const skillForm = mustForm("skill-form");
|
||||
const skillNameInput = mustInput("skill-name");
|
||||
const skillExpressionInput = mustInput("skill-expression");
|
||||
const updateSkillButton = mustButton("update-skill-button");
|
||||
const skillSelect = mustSelect("skill-select");
|
||||
const rollForm = mustForm("roll-form");
|
||||
const rollVisibilitySelect = mustSelect("roll-visibility");
|
||||
const state = {
|
||||
user: null,
|
||||
activeCharacterId: null,
|
||||
selectedCharacterId: null,
|
||||
campaigns: [],
|
||||
selectedCampaignId: null,
|
||||
selectedCampaign: null,
|
||||
campaignLog: [],
|
||||
rulesets: [],
|
||||
eventSource: null
|
||||
};
|
||||
registerForm.addEventListener("submit", async (event) => {
|
||||
import { activateCharacter, createCampaign, createCharacter, createSkill, loginUser, logoutUser, registerUser, rollSkill, updateSkill } from "./generated/api-client.js";
|
||||
import { runAction, setMessage } from "./app/actions.js";
|
||||
import { getAppElements } from "./app/dom.js";
|
||||
import { closeStateEvents, connectStateEvents } from "./app/events.js";
|
||||
import { ensureRulesets, refreshHealth, reloadCampaignLog, reloadCampaigns, reloadSelectedCampaign, reloadSession } from "./app/loaders.js";
|
||||
import { renderAll, renderCampaignDetails, renderCampaignLog, renderCampaignMeta, renderCharacterSelect, renderSkillSelect } from "./app/render.js";
|
||||
import { createInitialState, resetAuthenticatedState, resetStateAfterLogout, syncSelectedCharacter } from "./app/state.js";
|
||||
const elements = getAppElements();
|
||||
const state = createInitialState();
|
||||
elements.registerForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
await runAction(async () => {
|
||||
await runAppAction(async () => {
|
||||
await registerUser({
|
||||
username: registerUsername.value.trim(),
|
||||
displayName: registerDisplayName.value.trim(),
|
||||
password: registerPassword.value
|
||||
username: elements.registerUsername.value.trim(),
|
||||
displayName: elements.registerDisplayName.value.trim(),
|
||||
password: elements.registerPassword.value
|
||||
});
|
||||
registerPassword.value = "";
|
||||
setMessage("Registration successful. You can log in now.", false);
|
||||
elements.registerPassword.value = "";
|
||||
setStatus("Registration successful. You can log in now.", false);
|
||||
});
|
||||
});
|
||||
loginForm.addEventListener("submit", async (event) => {
|
||||
elements.loginForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
await runAction(async () => {
|
||||
await runAppAction(async () => {
|
||||
await loginUser({
|
||||
username: loginUsername.value.trim(),
|
||||
password: loginPassword.value
|
||||
username: elements.loginUsername.value.trim(),
|
||||
password: elements.loginPassword.value
|
||||
});
|
||||
loginPassword.value = "";
|
||||
elements.loginPassword.value = "";
|
||||
await reloadAll();
|
||||
setMessage("Logged in.", false);
|
||||
setStatus("Logged in.", false);
|
||||
});
|
||||
});
|
||||
logoutButton.addEventListener("click", async () => {
|
||||
await runAction(async () => {
|
||||
elements.logoutButton.addEventListener("click", async () => {
|
||||
await runAppAction(async () => {
|
||||
await logoutUser();
|
||||
resetStateAfterLogout();
|
||||
renderAll();
|
||||
setMessage("Logged out.", false);
|
||||
resetStateAfterLogout(state);
|
||||
closeStateEvents(state);
|
||||
renderAll(state, elements);
|
||||
setStatus("Logged out.", false);
|
||||
});
|
||||
});
|
||||
campaignForm.addEventListener("submit", async (event) => {
|
||||
elements.campaignForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
await runAction(async () => {
|
||||
await runAppAction(async () => {
|
||||
const createdCampaign = await createCampaign({
|
||||
name: campaignNameInput.value.trim(),
|
||||
rulesetId: campaignRulesetSelect.value
|
||||
name: elements.campaignNameInput.value.trim(),
|
||||
rulesetId: elements.campaignRulesetSelect.value
|
||||
});
|
||||
campaignNameInput.value = "";
|
||||
await reloadCampaigns(createdCampaign.id);
|
||||
setMessage("Campaign created.", false);
|
||||
elements.campaignNameInput.value = "";
|
||||
await reloadCampaigns(state, createdCampaign.id);
|
||||
await reloadSelectedCampaign(state);
|
||||
await reloadCampaignLog(state);
|
||||
syncEventStream();
|
||||
renderAll(state, elements);
|
||||
setStatus("Campaign created.", false);
|
||||
});
|
||||
});
|
||||
campaignSelect.addEventListener("change", async () => {
|
||||
await runAction(async () => {
|
||||
const selected = campaignSelect.value;
|
||||
elements.campaignSelect.addEventListener("change", async () => {
|
||||
await runAppAction(async () => {
|
||||
const selected = elements.campaignSelect.value;
|
||||
state.selectedCampaignId = selected.length > 0 ? selected : null;
|
||||
await reloadSelectedCampaign();
|
||||
syncSelectedCharacter();
|
||||
renderCharacterSelect();
|
||||
renderSkillSelect();
|
||||
connectStateEvents();
|
||||
renderCampaignMeta();
|
||||
renderCampaignDetails();
|
||||
renderCampaignLog();
|
||||
await reloadSelectedCampaign(state);
|
||||
syncSelectedCharacter(state);
|
||||
renderCharacterSelect(state, elements);
|
||||
renderSkillSelect(state, elements);
|
||||
syncEventStream();
|
||||
renderCampaignMeta(state, elements);
|
||||
renderCampaignDetails(state, elements);
|
||||
renderCampaignLog(state, elements);
|
||||
});
|
||||
});
|
||||
characterSelect.addEventListener("change", () => {
|
||||
state.selectedCharacterId = characterSelect.value.length > 0 ? characterSelect.value : null;
|
||||
renderSkillSelect();
|
||||
elements.characterSelect.addEventListener("change", () => {
|
||||
state.selectedCharacterId = elements.characterSelect.value.length > 0 ? elements.characterSelect.value : null;
|
||||
renderSkillSelect(state, elements);
|
||||
});
|
||||
refreshCampaignButton.addEventListener("click", async () => {
|
||||
await runAction(async () => {
|
||||
await reloadSelectedCampaign();
|
||||
renderAll();
|
||||
setMessage("Campaign refreshed.", false);
|
||||
elements.refreshCampaignButton.addEventListener("click", async () => {
|
||||
await runAppAction(async () => {
|
||||
await reloadSelectedCampaign(state);
|
||||
await reloadCampaignLog(state);
|
||||
renderAll(state, elements);
|
||||
setStatus("Campaign refreshed.", false);
|
||||
});
|
||||
});
|
||||
characterForm.addEventListener("submit", async (event) => {
|
||||
elements.characterForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
await runAction(async () => {
|
||||
await runAppAction(async () => {
|
||||
if (!state.selectedCampaignId) {
|
||||
throw new Error("Select a campaign first.");
|
||||
}
|
||||
await createCharacter({
|
||||
name: characterNameInput.value.trim(),
|
||||
name: elements.characterNameInput.value.trim(),
|
||||
campaignId: state.selectedCampaignId
|
||||
});
|
||||
characterNameInput.value = "";
|
||||
await reloadSelectedCampaign();
|
||||
await reloadCampaignLog();
|
||||
setMessage("Character created.", false);
|
||||
elements.characterNameInput.value = "";
|
||||
await reloadSelectedCampaign(state);
|
||||
await reloadCampaignLog(state);
|
||||
renderAll(state, elements);
|
||||
setStatus("Character created.", false);
|
||||
});
|
||||
});
|
||||
activateCharacterButton.addEventListener("click", async () => {
|
||||
await runAction(async () => {
|
||||
const characterId = characterSelect.value;
|
||||
elements.activateCharacterButton.addEventListener("click", async () => {
|
||||
await runAppAction(async () => {
|
||||
const characterId = elements.characterSelect.value;
|
||||
if (characterId.length === 0) {
|
||||
throw new Error("Select a character to activate.");
|
||||
}
|
||||
@@ -135,349 +109,90 @@ activateCharacterButton.addEventListener("click", async () => {
|
||||
state.activeCharacterId = characterId;
|
||||
state.selectedCharacterId = characterId;
|
||||
await reloadAll();
|
||||
setMessage("Active character updated.", false);
|
||||
setStatus("Active character updated.", false);
|
||||
});
|
||||
});
|
||||
skillForm.addEventListener("submit", async (event) => {
|
||||
elements.skillForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
await runAction(async () => {
|
||||
const characterId = characterSelect.value;
|
||||
await runAppAction(async () => {
|
||||
const characterId = elements.characterSelect.value;
|
||||
if (characterId.length === 0) {
|
||||
throw new Error("Select a character first.");
|
||||
}
|
||||
await createSkill(characterId, {
|
||||
name: skillNameInput.value.trim(),
|
||||
diceRollDefinition: skillExpressionInput.value.trim()
|
||||
name: elements.skillNameInput.value.trim(),
|
||||
diceRollDefinition: elements.skillExpressionInput.value.trim()
|
||||
});
|
||||
skillNameInput.value = "";
|
||||
skillExpressionInput.value = "";
|
||||
await reloadSelectedCampaign();
|
||||
setMessage("Skill created.", false);
|
||||
elements.skillNameInput.value = "";
|
||||
elements.skillExpressionInput.value = "";
|
||||
await reloadSelectedCampaign(state);
|
||||
renderAll(state, elements);
|
||||
setStatus("Skill created.", false);
|
||||
});
|
||||
});
|
||||
updateSkillButton.addEventListener("click", async () => {
|
||||
await runAction(async () => {
|
||||
const selectedSkillId = skillSelect.value;
|
||||
elements.updateSkillButton.addEventListener("click", async () => {
|
||||
await runAppAction(async () => {
|
||||
const selectedSkillId = elements.skillSelect.value;
|
||||
if (selectedSkillId.length === 0) {
|
||||
throw new Error("Select a skill to update.");
|
||||
}
|
||||
await updateSkill(selectedSkillId, {
|
||||
name: skillNameInput.value.trim(),
|
||||
diceRollDefinition: skillExpressionInput.value.trim()
|
||||
name: elements.skillNameInput.value.trim(),
|
||||
diceRollDefinition: elements.skillExpressionInput.value.trim()
|
||||
});
|
||||
await reloadSelectedCampaign();
|
||||
setMessage("Skill updated.", false);
|
||||
await reloadSelectedCampaign(state);
|
||||
renderAll(state, elements);
|
||||
setStatus("Skill updated.", false);
|
||||
});
|
||||
});
|
||||
rollForm.addEventListener("submit", async (event) => {
|
||||
elements.rollForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
await runAction(async () => {
|
||||
const selectedSkillId = skillSelect.value;
|
||||
await runAppAction(async () => {
|
||||
const selectedSkillId = elements.skillSelect.value;
|
||||
if (selectedSkillId.length === 0) {
|
||||
throw new Error("Select a skill to roll.");
|
||||
}
|
||||
const roll = await rollSkill(selectedSkillId, { visibility: rollVisibilitySelect.value });
|
||||
rollResultElement.textContent = `Roll ${roll.rollId}: ${roll.breakdown} (${roll.visibility})`;
|
||||
await reloadCampaignLog();
|
||||
setMessage("Roll recorded.", false);
|
||||
const roll = await rollSkill(selectedSkillId, { visibility: elements.rollVisibilitySelect.value });
|
||||
elements.rollResultElement.textContent = `Roll ${roll.rollId}: ${roll.breakdown} (${roll.visibility})`;
|
||||
await reloadCampaignLog(state);
|
||||
renderCampaignLog(state, elements);
|
||||
setStatus("Roll recorded.", false);
|
||||
});
|
||||
});
|
||||
await runAction(async () => {
|
||||
await refreshHealth();
|
||||
await runAppAction(async () => {
|
||||
await refreshHealth(elements);
|
||||
await reloadAll();
|
||||
setMessage("Ready.", false);
|
||||
setStatus("Ready.", false);
|
||||
});
|
||||
async function refreshHealth() {
|
||||
const health = await getHealth();
|
||||
healthElement.textContent = `API status: ${health.status}`;
|
||||
}
|
||||
async function reloadAll() {
|
||||
await ensureRulesets();
|
||||
await reloadSession();
|
||||
await ensureRulesets(state, elements);
|
||||
await reloadSession(state);
|
||||
if (state.user) {
|
||||
await reloadCampaigns(state.selectedCampaignId);
|
||||
await reloadSelectedCampaign();
|
||||
await reloadCampaignLog();
|
||||
connectStateEvents();
|
||||
await reloadCampaigns(state, state.selectedCampaignId);
|
||||
await reloadSelectedCampaign(state);
|
||||
await reloadCampaignLog(state);
|
||||
syncEventStream();
|
||||
}
|
||||
else {
|
||||
resetAuthenticatedState();
|
||||
resetAuthenticatedState(state);
|
||||
closeStateEvents(state);
|
||||
}
|
||||
renderAll();
|
||||
renderAll(state, elements);
|
||||
}
|
||||
async function reloadSession() {
|
||||
try {
|
||||
const me = await getMe();
|
||||
state.user = me.user;
|
||||
state.activeCharacterId = me.activeCharacterId ?? null;
|
||||
state.selectedCampaignId = me.currentCampaignId ?? state.selectedCampaignId;
|
||||
}
|
||||
catch {
|
||||
state.user = null;
|
||||
state.activeCharacterId = null;
|
||||
}
|
||||
}
|
||||
async function ensureRulesets() {
|
||||
if (state.rulesets.length > 0) {
|
||||
return;
|
||||
}
|
||||
state.rulesets = await getRulesets();
|
||||
campaignRulesetSelect.innerHTML = state.rulesets
|
||||
.map((ruleset) => `<option value="${ruleset.id}">${ruleset.name}</option>`)
|
||||
.join("");
|
||||
}
|
||||
async function reloadCampaigns(preferredCampaignId) {
|
||||
state.campaigns = await getCampaigns();
|
||||
if (state.campaigns.length === 0) {
|
||||
state.selectedCampaignId = null;
|
||||
return;
|
||||
}
|
||||
const availableIds = new Set(state.campaigns.map((campaign) => campaign.id));
|
||||
if (preferredCampaignId && availableIds.has(preferredCampaignId)) {
|
||||
state.selectedCampaignId = preferredCampaignId;
|
||||
return;
|
||||
}
|
||||
if (state.selectedCampaignId && availableIds.has(state.selectedCampaignId)) {
|
||||
return;
|
||||
}
|
||||
state.selectedCampaignId = state.campaigns[0].id;
|
||||
}
|
||||
async function reloadSelectedCampaign() {
|
||||
if (!state.selectedCampaignId) {
|
||||
state.selectedCampaign = null;
|
||||
return;
|
||||
}
|
||||
state.selectedCampaign = await getCampaign(state.selectedCampaignId);
|
||||
syncSelectedCharacter();
|
||||
}
|
||||
async function reloadCampaignLog() {
|
||||
if (!state.selectedCampaignId) {
|
||||
state.campaignLog = [];
|
||||
return;
|
||||
}
|
||||
state.campaignLog = await getCampaignLog(state.selectedCampaignId);
|
||||
}
|
||||
function connectStateEvents() {
|
||||
if (!state.selectedCampaignId || !state.user) {
|
||||
closeStateEvents();
|
||||
return;
|
||||
}
|
||||
if (state.eventSource && state.eventSource.url.endsWith(`campaignId=${state.selectedCampaignId}`)) {
|
||||
return;
|
||||
}
|
||||
closeStateEvents();
|
||||
state.eventSource = new EventSource(`/api/events/state?campaignId=${state.selectedCampaignId}`);
|
||||
state.eventSource.addEventListener("state", () => {
|
||||
void runAction(async () => {
|
||||
await reloadSelectedCampaign();
|
||||
await reloadCampaignLog();
|
||||
renderAll();
|
||||
function syncEventStream() {
|
||||
connectStateEvents(state, () => {
|
||||
void runAppAction(async () => {
|
||||
await reloadSelectedCampaign(state);
|
||||
await reloadCampaignLog(state);
|
||||
renderAll(state, elements);
|
||||
});
|
||||
});
|
||||
state.eventSource.onerror = () => {
|
||||
closeStateEvents();
|
||||
};
|
||||
}
|
||||
function closeStateEvents() {
|
||||
if (state.eventSource) {
|
||||
state.eventSource.close();
|
||||
state.eventSource = null;
|
||||
}
|
||||
async function runAppAction(action) {
|
||||
await runAction(action, (message) => {
|
||||
setStatus(message, true);
|
||||
});
|
||||
}
|
||||
function resetStateAfterLogout() {
|
||||
state.user = null;
|
||||
resetAuthenticatedState();
|
||||
closeStateEvents();
|
||||
}
|
||||
function resetAuthenticatedState() {
|
||||
state.activeCharacterId = null;
|
||||
state.selectedCharacterId = null;
|
||||
state.campaigns = [];
|
||||
state.selectedCampaignId = null;
|
||||
state.selectedCampaign = null;
|
||||
state.campaignLog = [];
|
||||
}
|
||||
function renderAll() {
|
||||
renderCampaignSelect();
|
||||
renderCampaignMeta();
|
||||
renderCampaignDetails();
|
||||
renderCharacterSelect();
|
||||
renderSkillSelect();
|
||||
renderCampaignLog();
|
||||
}
|
||||
function renderCampaignSelect() {
|
||||
campaignSelect.innerHTML = state.campaigns
|
||||
.map((campaign) => {
|
||||
const selected = campaign.id === state.selectedCampaignId ? " selected" : "";
|
||||
return `<option value="${campaign.id}"${selected}>${campaign.name} (${campaign.rulesetId})</option>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
function renderCampaignMeta() {
|
||||
if (!state.user) {
|
||||
campaignMetaElement.textContent = "Log in to manage campaigns.";
|
||||
return;
|
||||
}
|
||||
if (!state.selectedCampaign) {
|
||||
campaignMetaElement.textContent = "No campaign selected.";
|
||||
return;
|
||||
}
|
||||
const isGm = state.selectedCampaign.gm.id === state.user.id;
|
||||
const activeCharacter = state.selectedCampaign.characters.find((character) => character.id === state.activeCharacterId);
|
||||
const activeLabel = activeCharacter ? ` | Active: ${activeCharacter.name}` : "";
|
||||
campaignMetaElement.textContent = `Selected: ${state.selectedCampaign.name} (${state.selectedCampaign.rulesetId}) - ${isGm ? "You are GM" : "Player context"}${activeLabel}`;
|
||||
}
|
||||
function renderCampaignDetails() {
|
||||
if (!state.selectedCampaign) {
|
||||
campaignDetailsElement.textContent = "No details available.";
|
||||
return;
|
||||
}
|
||||
const characters = state.selectedCampaign.characters
|
||||
.map((character) => `<li>${character.name} (${character.id})</li>`)
|
||||
.join("");
|
||||
const skills = state.selectedCampaign.skills
|
||||
.map((skill) => `<li>${skill.name} [${skill.diceRollDefinition}]</li>`)
|
||||
.join("");
|
||||
campaignDetailsElement.innerHTML = `
|
||||
<p>GM: ${state.selectedCampaign.gm.displayName}</p>
|
||||
<p>Characters visible to you: ${state.selectedCampaign.characters.length}</p>
|
||||
<ul>${characters}</ul>
|
||||
<p>Skills visible to you: ${state.selectedCampaign.skills.length}</p>
|
||||
<ul>${skills}</ul>
|
||||
`;
|
||||
}
|
||||
function renderCharacterSelect() {
|
||||
if (!state.selectedCampaign) {
|
||||
characterSelect.innerHTML = "";
|
||||
state.selectedCharacterId = null;
|
||||
return;
|
||||
}
|
||||
const selectedCharacterId = resolveSelectedCharacterId();
|
||||
const options = state.selectedCampaign.characters
|
||||
.map((character) => {
|
||||
const selected = character.id === selectedCharacterId ? " selected" : "";
|
||||
return `<option value="${character.id}"${selected}>${character.name}</option>`;
|
||||
})
|
||||
.join("");
|
||||
characterSelect.innerHTML = options;
|
||||
state.selectedCharacterId = selectedCharacterId;
|
||||
}
|
||||
function renderSkillSelect() {
|
||||
if (!state.selectedCampaign) {
|
||||
skillSelect.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
const selectedCharacterId = resolveSelectedCharacterId();
|
||||
const characterSkills = state.selectedCampaign.skills.filter((skill) => skill.characterId === selectedCharacterId);
|
||||
const options = characterSkills
|
||||
.map((skill) => `<option value="${skill.id}">${skill.name} (${skill.diceRollDefinition})</option>`)
|
||||
.join("");
|
||||
skillSelect.innerHTML = options;
|
||||
const selectedSkill = selectedSkillFromCampaign();
|
||||
if (selectedSkill) {
|
||||
skillNameInput.value = selectedSkill.name;
|
||||
skillExpressionInput.value = selectedSkill.diceRollDefinition;
|
||||
}
|
||||
}
|
||||
function selectedSkillFromCampaign() {
|
||||
if (!state.selectedCampaign) {
|
||||
return null;
|
||||
}
|
||||
const selectedCharacterId = resolveSelectedCharacterId();
|
||||
const selectedSkillId = skillSelect.value;
|
||||
return state.selectedCampaign.skills
|
||||
.filter((skill) => skill.characterId === selectedCharacterId)
|
||||
.find((skill) => skill.id === selectedSkillId) ?? null;
|
||||
}
|
||||
function renderCampaignLog() {
|
||||
if (state.campaignLog.length === 0) {
|
||||
campaignLogElement.innerHTML = "<li class=\"log-item\">No rolls yet.</li>";
|
||||
return;
|
||||
}
|
||||
campaignLogElement.innerHTML = state.campaignLog
|
||||
.map((entry) => `
|
||||
<li class="log-item">
|
||||
<strong>${entry.visibility.toUpperCase()}</strong>
|
||||
<span> ${entry.breakdown}</span>
|
||||
<small> ${new Date(entry.timestampUtc).toLocaleString()}</small>
|
||||
</li>
|
||||
`)
|
||||
.join("");
|
||||
}
|
||||
async function runAction(action) {
|
||||
try {
|
||||
await action();
|
||||
}
|
||||
catch (error) {
|
||||
setMessage(formatError(error), true);
|
||||
}
|
||||
}
|
||||
function setMessage(message, isError) {
|
||||
messageElement.textContent = message;
|
||||
messageElement.style.color = isError ? "#b91c1c" : "#1d4ed8";
|
||||
}
|
||||
function formatError(error) {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
function mustElement(id) {
|
||||
const element = document.getElementById(id);
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
throw new Error(`Missing HTMLElement: ${id}`);
|
||||
}
|
||||
return element;
|
||||
}
|
||||
function mustInput(id) {
|
||||
const element = document.getElementById(id);
|
||||
if (!(element instanceof HTMLInputElement)) {
|
||||
throw new Error(`Missing HTMLInputElement: ${id}`);
|
||||
}
|
||||
return element;
|
||||
}
|
||||
function mustSelect(id) {
|
||||
const element = document.getElementById(id);
|
||||
if (!(element instanceof HTMLSelectElement)) {
|
||||
throw new Error(`Missing HTMLSelectElement: ${id}`);
|
||||
}
|
||||
return element;
|
||||
}
|
||||
function mustForm(id) {
|
||||
const element = document.getElementById(id);
|
||||
if (!(element instanceof HTMLFormElement)) {
|
||||
throw new Error(`Missing HTMLFormElement: ${id}`);
|
||||
}
|
||||
return element;
|
||||
}
|
||||
function mustButton(id) {
|
||||
const element = document.getElementById(id);
|
||||
if (!(element instanceof HTMLButtonElement)) {
|
||||
throw new Error(`Missing HTMLButtonElement: ${id}`);
|
||||
}
|
||||
return element;
|
||||
}
|
||||
function syncSelectedCharacter() {
|
||||
if (!state.selectedCampaign) {
|
||||
state.selectedCharacterId = null;
|
||||
return;
|
||||
}
|
||||
const availableIds = new Set(state.selectedCampaign.characters.map((character) => character.id));
|
||||
if (state.selectedCharacterId && availableIds.has(state.selectedCharacterId)) {
|
||||
return;
|
||||
}
|
||||
if (state.activeCharacterId && availableIds.has(state.activeCharacterId)) {
|
||||
state.selectedCharacterId = state.activeCharacterId;
|
||||
return;
|
||||
}
|
||||
state.selectedCharacterId = state.selectedCampaign.characters.length > 0
|
||||
? state.selectedCampaign.characters[0].id
|
||||
: null;
|
||||
}
|
||||
function resolveSelectedCharacterId() {
|
||||
if (!state.selectedCampaign) {
|
||||
return null;
|
||||
}
|
||||
syncSelectedCharacter();
|
||||
return state.selectedCharacterId;
|
||||
function setStatus(message, isError) {
|
||||
setMessage(elements.messageElement, message, isError);
|
||||
}
|
||||
|
||||
18
RpgRoller/wwwroot/app/actions.js
Normal file
18
RpgRoller/wwwroot/app/actions.js
Normal file
@@ -0,0 +1,18 @@
|
||||
export async function runAction(action, onError) {
|
||||
try {
|
||||
await action();
|
||||
}
|
||||
catch (error) {
|
||||
onError(formatError(error));
|
||||
}
|
||||
}
|
||||
export function setMessage(messageElement, message, isError) {
|
||||
messageElement.textContent = message;
|
||||
messageElement.style.color = isError ? "#b91c1c" : "#1d4ed8";
|
||||
}
|
||||
function formatError(error) {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
69
RpgRoller/wwwroot/app/dom.js
Normal file
69
RpgRoller/wwwroot/app/dom.js
Normal file
@@ -0,0 +1,69 @@
|
||||
export function getAppElements() {
|
||||
return {
|
||||
healthElement: mustElement("health"),
|
||||
messageElement: mustElement("message"),
|
||||
campaignMetaElement: mustElement("campaign-meta"),
|
||||
campaignDetailsElement: mustElement("campaign-details"),
|
||||
rollResultElement: mustElement("roll-result"),
|
||||
campaignLogElement: mustElement("campaign-log"),
|
||||
registerForm: mustForm("register-form"),
|
||||
loginForm: mustForm("login-form"),
|
||||
logoutButton: mustButton("logout-button"),
|
||||
registerUsername: mustInput("register-username"),
|
||||
registerDisplayName: mustInput("register-display-name"),
|
||||
registerPassword: mustInput("register-password"),
|
||||
loginUsername: mustInput("login-username"),
|
||||
loginPassword: mustInput("login-password"),
|
||||
campaignForm: mustForm("campaign-form"),
|
||||
campaignNameInput: mustInput("campaign-name"),
|
||||
campaignRulesetSelect: mustSelect("campaign-ruleset"),
|
||||
campaignSelect: mustSelect("campaign-select"),
|
||||
refreshCampaignButton: mustButton("refresh-campaign-button"),
|
||||
characterForm: mustForm("character-form"),
|
||||
characterNameInput: mustInput("character-name"),
|
||||
characterSelect: mustSelect("character-select"),
|
||||
activateCharacterButton: mustButton("activate-character-button"),
|
||||
skillForm: mustForm("skill-form"),
|
||||
skillNameInput: mustInput("skill-name"),
|
||||
skillExpressionInput: mustInput("skill-expression"),
|
||||
updateSkillButton: mustButton("update-skill-button"),
|
||||
skillSelect: mustSelect("skill-select"),
|
||||
rollForm: mustForm("roll-form"),
|
||||
rollVisibilitySelect: mustSelect("roll-visibility")
|
||||
};
|
||||
}
|
||||
function mustElement(id) {
|
||||
const element = document.getElementById(id);
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
throw new Error(`Missing HTMLElement: ${id}`);
|
||||
}
|
||||
return element;
|
||||
}
|
||||
function mustInput(id) {
|
||||
const element = document.getElementById(id);
|
||||
if (!(element instanceof HTMLInputElement)) {
|
||||
throw new Error(`Missing HTMLInputElement: ${id}`);
|
||||
}
|
||||
return element;
|
||||
}
|
||||
function mustSelect(id) {
|
||||
const element = document.getElementById(id);
|
||||
if (!(element instanceof HTMLSelectElement)) {
|
||||
throw new Error(`Missing HTMLSelectElement: ${id}`);
|
||||
}
|
||||
return element;
|
||||
}
|
||||
function mustForm(id) {
|
||||
const element = document.getElementById(id);
|
||||
if (!(element instanceof HTMLFormElement)) {
|
||||
throw new Error(`Missing HTMLFormElement: ${id}`);
|
||||
}
|
||||
return element;
|
||||
}
|
||||
function mustButton(id) {
|
||||
const element = document.getElementById(id);
|
||||
if (!(element instanceof HTMLButtonElement)) {
|
||||
throw new Error(`Missing HTMLButtonElement: ${id}`);
|
||||
}
|
||||
return element;
|
||||
}
|
||||
23
RpgRoller/wwwroot/app/events.js
Normal file
23
RpgRoller/wwwroot/app/events.js
Normal file
@@ -0,0 +1,23 @@
|
||||
export function connectStateEvents(state, onStateChanged) {
|
||||
if (!state.selectedCampaignId || !state.user) {
|
||||
closeStateEvents(state);
|
||||
return;
|
||||
}
|
||||
if (state.eventSource && state.eventSource.url.endsWith(`campaignId=${state.selectedCampaignId}`)) {
|
||||
return;
|
||||
}
|
||||
closeStateEvents(state);
|
||||
state.eventSource = new EventSource(`/api/events/state?campaignId=${state.selectedCampaignId}`);
|
||||
state.eventSource.addEventListener("state", () => {
|
||||
onStateChanged();
|
||||
});
|
||||
state.eventSource.onerror = () => {
|
||||
closeStateEvents(state);
|
||||
};
|
||||
}
|
||||
export function closeStateEvents(state) {
|
||||
if (state.eventSource) {
|
||||
state.eventSource.close();
|
||||
state.eventSource = null;
|
||||
}
|
||||
}
|
||||
58
RpgRoller/wwwroot/app/loaders.js
Normal file
58
RpgRoller/wwwroot/app/loaders.js
Normal file
@@ -0,0 +1,58 @@
|
||||
import { getCampaign, getCampaignLog, getCampaigns, getHealth, getMe, getRulesets } from "../generated/api-client.js";
|
||||
import { syncSelectedCharacter } from "./state.js";
|
||||
export async function refreshHealth(elements) {
|
||||
const health = await getHealth();
|
||||
elements.healthElement.textContent = `API status: ${health.status}`;
|
||||
}
|
||||
export async function ensureRulesets(state, elements) {
|
||||
if (state.rulesets.length > 0) {
|
||||
return;
|
||||
}
|
||||
state.rulesets = await getRulesets();
|
||||
elements.campaignRulesetSelect.innerHTML = state.rulesets
|
||||
.map((ruleset) => `<option value="${ruleset.id}">${ruleset.name}</option>`)
|
||||
.join("");
|
||||
}
|
||||
export async function reloadSession(state) {
|
||||
try {
|
||||
const me = await getMe();
|
||||
state.user = me.user;
|
||||
state.activeCharacterId = me.activeCharacterId ?? null;
|
||||
state.selectedCampaignId = me.currentCampaignId ?? state.selectedCampaignId;
|
||||
}
|
||||
catch {
|
||||
state.user = null;
|
||||
state.activeCharacterId = null;
|
||||
}
|
||||
}
|
||||
export async function reloadCampaigns(state, preferredCampaignId) {
|
||||
state.campaigns = await getCampaigns();
|
||||
if (state.campaigns.length === 0) {
|
||||
state.selectedCampaignId = null;
|
||||
return;
|
||||
}
|
||||
const availableIds = new Set(state.campaigns.map((campaign) => campaign.id));
|
||||
if (preferredCampaignId && availableIds.has(preferredCampaignId)) {
|
||||
state.selectedCampaignId = preferredCampaignId;
|
||||
return;
|
||||
}
|
||||
if (state.selectedCampaignId && availableIds.has(state.selectedCampaignId)) {
|
||||
return;
|
||||
}
|
||||
state.selectedCampaignId = state.campaigns[0].id;
|
||||
}
|
||||
export async function reloadSelectedCampaign(state) {
|
||||
if (!state.selectedCampaignId) {
|
||||
state.selectedCampaign = null;
|
||||
return;
|
||||
}
|
||||
state.selectedCampaign = await getCampaign(state.selectedCampaignId);
|
||||
syncSelectedCharacter(state);
|
||||
}
|
||||
export async function reloadCampaignLog(state) {
|
||||
if (!state.selectedCampaignId) {
|
||||
state.campaignLog = [];
|
||||
return;
|
||||
}
|
||||
state.campaignLog = await getCampaignLog(state.selectedCampaignId);
|
||||
}
|
||||
98
RpgRoller/wwwroot/app/render.js
Normal file
98
RpgRoller/wwwroot/app/render.js
Normal file
@@ -0,0 +1,98 @@
|
||||
import { resolveSelectedCharacterId, selectedSkillFromCampaign } from "./state.js";
|
||||
export function renderAll(state, elements) {
|
||||
renderCampaignSelect(state, elements);
|
||||
renderCampaignMeta(state, elements);
|
||||
renderCampaignDetails(state, elements);
|
||||
renderCharacterSelect(state, elements);
|
||||
renderSkillSelect(state, elements);
|
||||
renderCampaignLog(state, elements);
|
||||
}
|
||||
export function renderCampaignMeta(state, elements) {
|
||||
if (!state.user) {
|
||||
elements.campaignMetaElement.textContent = "Log in to manage campaigns.";
|
||||
return;
|
||||
}
|
||||
if (!state.selectedCampaign) {
|
||||
elements.campaignMetaElement.textContent = "No campaign selected.";
|
||||
return;
|
||||
}
|
||||
const isGm = state.selectedCampaign.gm.id === state.user.id;
|
||||
const activeCharacter = state.selectedCampaign.characters.find((character) => character.id === state.activeCharacterId);
|
||||
const activeLabel = activeCharacter ? ` | Active: ${activeCharacter.name}` : "";
|
||||
elements.campaignMetaElement.textContent = `Selected: ${state.selectedCampaign.name} (${state.selectedCampaign.rulesetId}) - ${isGm ? "You are GM" : "Player context"}${activeLabel}`;
|
||||
}
|
||||
export function renderCampaignDetails(state, elements) {
|
||||
if (!state.selectedCampaign) {
|
||||
elements.campaignDetailsElement.textContent = "No details available.";
|
||||
return;
|
||||
}
|
||||
const characters = state.selectedCampaign.characters
|
||||
.map((character) => `<li>${character.name} (${character.id})</li>`)
|
||||
.join("");
|
||||
const skills = state.selectedCampaign.skills
|
||||
.map((skill) => `<li>${skill.name} [${skill.diceRollDefinition}]</li>`)
|
||||
.join("");
|
||||
elements.campaignDetailsElement.innerHTML = `
|
||||
<p>GM: ${state.selectedCampaign.gm.displayName}</p>
|
||||
<p>Characters visible to you: ${state.selectedCampaign.characters.length}</p>
|
||||
<ul>${characters}</ul>
|
||||
<p>Skills visible to you: ${state.selectedCampaign.skills.length}</p>
|
||||
<ul>${skills}</ul>
|
||||
`;
|
||||
}
|
||||
export function renderCharacterSelect(state, elements) {
|
||||
if (!state.selectedCampaign) {
|
||||
elements.characterSelect.innerHTML = "";
|
||||
state.selectedCharacterId = null;
|
||||
return;
|
||||
}
|
||||
const selectedCharacterId = resolveSelectedCharacterId(state);
|
||||
const options = state.selectedCampaign.characters
|
||||
.map((character) => {
|
||||
const selected = character.id === selectedCharacterId ? " selected" : "";
|
||||
return `<option value="${character.id}"${selected}>${character.name}</option>`;
|
||||
})
|
||||
.join("");
|
||||
elements.characterSelect.innerHTML = options;
|
||||
state.selectedCharacterId = selectedCharacterId;
|
||||
}
|
||||
export function renderSkillSelect(state, elements) {
|
||||
if (!state.selectedCampaign) {
|
||||
elements.skillSelect.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
const selectedCharacterId = resolveSelectedCharacterId(state);
|
||||
const characterSkills = state.selectedCampaign.skills.filter((skill) => skill.characterId === selectedCharacterId);
|
||||
const options = characterSkills
|
||||
.map((skill) => `<option value="${skill.id}">${skill.name} (${skill.diceRollDefinition})</option>`)
|
||||
.join("");
|
||||
elements.skillSelect.innerHTML = options;
|
||||
const selectedSkill = selectedSkillFromCampaign(state, elements.skillSelect.value);
|
||||
if (selectedSkill) {
|
||||
elements.skillNameInput.value = selectedSkill.name;
|
||||
elements.skillExpressionInput.value = selectedSkill.diceRollDefinition;
|
||||
}
|
||||
}
|
||||
export function renderCampaignLog(state, elements) {
|
||||
if (state.campaignLog.length === 0) {
|
||||
elements.campaignLogElement.innerHTML = "<li class=\"log-item\">No rolls yet.</li>";
|
||||
return;
|
||||
}
|
||||
elements.campaignLogElement.innerHTML = state.campaignLog
|
||||
.map((entry) => `
|
||||
<li class="log-item">
|
||||
<strong>${entry.visibility.toUpperCase()}</strong>
|
||||
<span> ${entry.breakdown}</span>
|
||||
<small> ${new Date(entry.timestampUtc).toLocaleString()}</small>
|
||||
</li>
|
||||
`)
|
||||
.join("");
|
||||
}
|
||||
function renderCampaignSelect(state, elements) {
|
||||
elements.campaignSelect.innerHTML = state.campaigns
|
||||
.map((campaign) => {
|
||||
const selected = campaign.id === state.selectedCampaignId ? " selected" : "";
|
||||
return `<option value="${campaign.id}"${selected}>${campaign.name} (${campaign.rulesetId})</option>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
58
RpgRoller/wwwroot/app/state.js
Normal file
58
RpgRoller/wwwroot/app/state.js
Normal file
@@ -0,0 +1,58 @@
|
||||
export function createInitialState() {
|
||||
return {
|
||||
user: null,
|
||||
activeCharacterId: null,
|
||||
selectedCharacterId: null,
|
||||
campaigns: [],
|
||||
selectedCampaignId: null,
|
||||
selectedCampaign: null,
|
||||
campaignLog: [],
|
||||
rulesets: [],
|
||||
eventSource: null
|
||||
};
|
||||
}
|
||||
export function resetAuthenticatedState(state) {
|
||||
state.activeCharacterId = null;
|
||||
state.selectedCharacterId = null;
|
||||
state.campaigns = [];
|
||||
state.selectedCampaignId = null;
|
||||
state.selectedCampaign = null;
|
||||
state.campaignLog = [];
|
||||
}
|
||||
export function resetStateAfterLogout(state) {
|
||||
state.user = null;
|
||||
resetAuthenticatedState(state);
|
||||
}
|
||||
export function syncSelectedCharacter(state) {
|
||||
if (!state.selectedCampaign) {
|
||||
state.selectedCharacterId = null;
|
||||
return;
|
||||
}
|
||||
const availableIds = new Set(state.selectedCampaign.characters.map((character) => character.id));
|
||||
if (state.selectedCharacterId && availableIds.has(state.selectedCharacterId)) {
|
||||
return;
|
||||
}
|
||||
if (state.activeCharacterId && availableIds.has(state.activeCharacterId)) {
|
||||
state.selectedCharacterId = state.activeCharacterId;
|
||||
return;
|
||||
}
|
||||
state.selectedCharacterId = state.selectedCampaign.characters.length > 0
|
||||
? state.selectedCampaign.characters[0].id
|
||||
: null;
|
||||
}
|
||||
export function resolveSelectedCharacterId(state) {
|
||||
if (!state.selectedCampaign) {
|
||||
return null;
|
||||
}
|
||||
syncSelectedCharacter(state);
|
||||
return state.selectedCharacterId;
|
||||
}
|
||||
export function selectedSkillFromCampaign(state, selectedSkillId) {
|
||||
if (!state.selectedCampaign) {
|
||||
return null;
|
||||
}
|
||||
const selectedCharacterId = resolveSelectedCharacterId(state);
|
||||
return state.selectedCampaign.skills
|
||||
.filter((skill) => skill.characterId === selectedCharacterId)
|
||||
.find((skill) => skill.id === selectedSkillId) ?? null;
|
||||
}
|
||||
1
RpgRoller/wwwroot/app/types.js
Normal file
1
RpgRoller/wwwroot/app/types.js
Normal file
@@ -0,0 +1 @@
|
||||
export {};
|
||||
3
TECH.md
3
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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user