Compare commits
4 Commits
feature/ef
...
feature/bl
| Author | SHA1 | Date | |
|---|---|---|---|
| 11ab7c959b | |||
| 0f44cc466b | |||
| 35c60c4ea2 | |||
| a8ee637374 |
31
FAQ.md
31
FAQ.md
@@ -1,19 +1,21 @@
|
||||
# FAQ
|
||||
|
||||
## Why does this starter use custom frontend lint/format scripts instead of heavy npm dependencies?
|
||||
## Does this project still require npm/frontend TypeScript tooling?
|
||||
|
||||
The kickoff scaffold is intentionally lightweight and keeps only strictly relevant tooling:
|
||||
|
||||
- API client generation from the OpenAPI contract
|
||||
- TypeScript compilation for frontend source files
|
||||
- basic frontend contract checks
|
||||
- deterministic formatting checks used by `scripts/ci-local.ps1`
|
||||
|
||||
This keeps the first commit small while preserving CI discipline. Additional tooling can be introduced when the frontend stack is finalized.
|
||||
No. The legacy TypeScript frontend pipeline was removed after the Blazor rewrite.
|
||||
`scripts/ci-local.ps1` is now a .NET-only flow (restore, build, tests, coverage checks).
|
||||
|
||||
## Is frontend JavaScript handwritten?
|
||||
|
||||
No. Frontend source code lives in `RpgRoller/frontend/*.ts` and is compiled to `RpgRoller/wwwroot/*.js` for browser delivery.
|
||||
The runtime UI is now built with Blazor components (`RpgRoller/Components/*`).
|
||||
|
||||
There is still small handwritten JavaScript in `RpgRoller/wwwroot/js/rpgroller-api.js` for:
|
||||
|
||||
- browser `fetch` calls with cookie auth
|
||||
- SSE connection/reconnect handling
|
||||
- per-tab session storage helpers used by the Blazor UI
|
||||
|
||||
There is no TypeScript runtime frontend in the current codebase.
|
||||
|
||||
## Where is backend state stored locally?
|
||||
|
||||
@@ -39,3 +41,12 @@ Coverage now includes the entire backend project (`RpgRoller`), including API/ho
|
||||
## Why do backend services avoid API request DTO dependencies?
|
||||
|
||||
Service workflows accept explicit parameters (for example, `CreateCampaign(sessionToken, name, rulesetId)`) instead of API request DTOs. This keeps the service layer independent from HTTP transport contracts while avoiding extra service-only wrapper command types.
|
||||
|
||||
## How do d6 wild dice and fumbles work now?
|
||||
|
||||
d6 skills now store two explicit options:
|
||||
|
||||
- `wildDice`: number of wild dice for the skill
|
||||
- `allowFumble`: whether wild dice rolling `1` can trigger fumble removal
|
||||
|
||||
Roll responses also include per-die state flags (`crit`, `fumble`, `wild`, `removed`, `added`) so the frontend can render the full die-by-die outcome, not just the final total.
|
||||
|
||||
36
FRONTEND_PROGRESS.md
Normal file
36
FRONTEND_PROGRESS.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# Frontend Rebuild Progress (Blazor)
|
||||
|
||||
Tracking against `UX.md` tasks and decisions.
|
||||
|
||||
## Status Snapshot
|
||||
|
||||
- Branch: `feature/blazor-frontend-rebuild-ux`
|
||||
- Runtime frontend stack: Blazor (`RpgRoller/Components/*`) + browser JS interop (`wwwroot/js/rpgroller-api.js`)
|
||||
- Legacy TypeScript frontend/runtime artifacts: removed
|
||||
|
||||
## UX Checklist
|
||||
|
||||
| UX area | Status | Notes |
|
||||
|---|---|---|
|
||||
| 9.1 App load + session restore | Implemented | Health check on load, rulesets/session load, unauthorized session reset, API unhealthy retry banner. |
|
||||
| 9.2 Authentication view | Implemented | Register/login cards, required validation, register password length check, server-error display. |
|
||||
| 9.3 Shared authenticated header | Implemented | User chip, campaign/active context, connection state, screen switch, refresh, logout. |
|
||||
| 9.4 Play screen character column | Implemented | Character icon tabs, sheet, modal edit/create flows, activate action, skill list, d6 skill options (wild/fumble), roll controls, and die-state visualized last roll card. |
|
||||
| 9.5 Play screen log column | Implemented | Chronological feed, private/public badges, private perspective styles (roller vs GM), local time + ISO tooltip. |
|
||||
| 9.6 Campaign management screen | Implemented | Campaign selector/summary, create form, details card, character management actions with modal edit pattern. |
|
||||
| 9.7 Tablet/mobile bottom bar | Implemented | `Character` / `Log` panel switch in play screen and per-tab session persistence. |
|
||||
| 10 Validation and error UX | Partially implemented | Required-field and common API errors are mapped; message/code-specific mapping is limited by current API exposing only text messages. |
|
||||
| 11 Empty/loading/disabled states | Implemented | Empty states, skeleton placeholders, mutation button disabling. |
|
||||
| 12 Real-time and sync rules | Implemented | Campaign-scoped SSE subscribe/unsubscribe, reconnect with exponential backoff, manual refresh fallback. |
|
||||
| 13 Accessibility requirements | Partially implemented | Keyboard-friendly controls, labels, focus styling, `aria-live` announcements; screen-reader validation for all flows still needs dedicated accessibility QA. |
|
||||
| 14 Content and copy guidance | Implemented | Direct action labels and corrective error copy used throughout. |
|
||||
| 15 Visual direction | Implemented | Tabletop utility styling, tokenized colors, responsive layout, private/public visual differentiation. |
|
||||
| 17 Next iteration targets: wireframes | Not yet implemented | No separate low-fidelity wireframe artifact added in repo. |
|
||||
| 17 Next iteration targets: component contracts doc | Not yet implemented | Component contract document not yet extracted from implementation. |
|
||||
| 17 Next iteration targets: visual token doc | Not yet implemented | Tokens are implemented in CSS but not yet documented in a dedicated spec file. |
|
||||
|
||||
## Follow-up Candidates
|
||||
|
||||
1. Add explicit machine-readable API error codes to HTTP responses for richer field-level mapping.
|
||||
2. Add automated accessibility checks (focus order, contrast, and screen-reader behavior assertions).
|
||||
3. Document component contracts and visual token references as separate markdown artifacts.
|
||||
40
README.md
40
README.md
@@ -2,11 +2,11 @@
|
||||
|
||||
Fresh full-stack starter scaffold:
|
||||
|
||||
- `RpgRoller/`: ASP.NET Core backend + static frontend output (`wwwroot`)
|
||||
- `RpgRoller/frontend/`: TypeScript frontend source
|
||||
- `RpgRoller/`: ASP.NET Core backend + Blazor frontend host (`Components` + `wwwroot`)
|
||||
- `RpgRoller.Tests/`: xUnit integration-heavy test project
|
||||
- `RpgRoller.sln`: solution used by local CI script
|
||||
- `UX.md`: frontend UX and interaction design specification (pre-implementation baseline)
|
||||
- `FRONTEND_PROGRESS.md`: implementation tracking (`Implemented` / `Partially implemented` / `Not yet implemented`)
|
||||
|
||||
Test layout:
|
||||
|
||||
@@ -25,10 +25,10 @@ Backend:
|
||||
|
||||
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
|
||||
- `RpgRoller/Components/`: Blazor root app, routes, layout and page components
|
||||
- `RpgRoller/Components/Pages/Home.razor(.cs)`: main UX implementation for auth/play/management screens
|
||||
- `RpgRoller/wwwroot/js/rpgroller-api.js`: browser-side API + SSE + session storage interop for Blazor
|
||||
- `RpgRoller/wwwroot/styles.css`: responsive UX styling and theme tokens
|
||||
|
||||
Backend state persistence:
|
||||
|
||||
@@ -41,7 +41,6 @@ Backend state persistence:
|
||||
## Prerequisites
|
||||
|
||||
- .NET SDK 10.0+
|
||||
- Node.js 22+ and npm
|
||||
- PowerShell 7+
|
||||
|
||||
## Local Development
|
||||
@@ -58,13 +57,11 @@ Backend state persistence:
|
||||
|
||||
To use a custom SQLite database path, set `ConnectionStrings__RpgRoller`.
|
||||
|
||||
## Frontend Tooling
|
||||
## Frontend Runtime
|
||||
|
||||
- OpenAPI contract: `openapi/RpgRoller.json`
|
||||
- TypeScript build output config: `tsconfig.frontend.json`
|
||||
- API client generation + frontend compile: `npm run generate:api-client`
|
||||
- Frontend lint checks: `npm run lint`
|
||||
- Frontend format checks: `npm run format:check`
|
||||
- Runtime frontend is Blazor Server with interactive components.
|
||||
- Browser interop is in `RpgRoller/wwwroot/js/rpgroller-api.js`.
|
||||
- OpenAPI contract source remains at `openapi/RpgRoller.json`.
|
||||
|
||||
## Test and Coverage
|
||||
|
||||
@@ -86,16 +83,21 @@ To use a custom SQLite database path, set `ConnectionStrings__RpgRoller`.
|
||||
- Rulesets: d6 and dnd5e validation rules
|
||||
- Campaigns: create/list/read
|
||||
- Characters: create/update/activate/current-campaign list
|
||||
- Skills: create/update with ruleset-aware dice expression validation
|
||||
- Rolls: public/private skill rolls with append-only campaign log
|
||||
- Skills: create/update with ruleset-aware dice expression validation and d6 wild-dice/fumble options
|
||||
- Rolls: public/private skill rolls with append-only campaign log; d6 rolls include wild/crit/fumble/add/remove die-state payloads
|
||||
- State stream: SSE endpoint for campaign version updates
|
||||
|
||||
## Implemented Frontend Scope
|
||||
|
||||
- TypeScript-driven UI for:
|
||||
- Blazor-driven UI for:
|
||||
- registration, login, logout
|
||||
- play screen and campaign management screen switch
|
||||
- campaign creation and selection
|
||||
- character creation and activation
|
||||
- skill creation and editing
|
||||
- character create/edit/activate via modal forms
|
||||
- skill create/edit via modal forms including d6 wild dice + allow-fumble controls
|
||||
- public/private rolling and campaign log viewing
|
||||
- SSE-backed live refresh for selected campaign state/log
|
||||
- die-state visualization in Last Roll (critical, fumble, wild, removed, added)
|
||||
- responsive play UX:
|
||||
- desktop two-column (character + log)
|
||||
- tablet/mobile panel switching with bottom tab bar (`Character` / `Log`)
|
||||
- SSE-backed live refresh with reconnect status + manual refresh fallback
|
||||
|
||||
@@ -98,6 +98,8 @@
|
||||
|
||||
* name
|
||||
* diceRollDefinition (ruleset-compliant expression, e.g. `5D+4`, `2d12+2`)
|
||||
* wildDice (d6 only; number of wild dice)
|
||||
* allowFumble (d6 only; whether wild-1 fumbles remove dice)
|
||||
* Behavior:
|
||||
|
||||
* Can be rolled
|
||||
@@ -198,6 +200,7 @@
|
||||
* System must:
|
||||
|
||||
* Validate dice expressions against the campaign ruleset
|
||||
* Validate d6 skill options (`wildDice`, `allowFumble`) as part of skill create/edit
|
||||
|
||||
---
|
||||
|
||||
@@ -213,7 +216,9 @@
|
||||
* System must:
|
||||
|
||||
* Evaluate dice expressions deterministically and fairly
|
||||
* For d6 skills, apply wild-die explosions and fumble-removal logic
|
||||
* Record all rolls in the campaign log
|
||||
* Return die-by-die roll states so the frontend can visualize critical/fumble/wild/removed/added outcomes
|
||||
|
||||
---
|
||||
|
||||
@@ -263,6 +268,7 @@
|
||||
### Skills
|
||||
|
||||
* As a **player**, I want to define skills with dice formulas so that I can perform actions.
|
||||
* As a **player**, I want to configure wild dice and fumble behavior for d6 skills so the roll follows my table rules.
|
||||
* As a **GM**, I want to edit character skills so that I can enforce or adjust rules.
|
||||
|
||||
---
|
||||
@@ -270,6 +276,7 @@
|
||||
### Dice Rolling
|
||||
|
||||
* As a **player**, I want to roll dice for a skill so that I can resolve actions.
|
||||
* As a **player**, I want to see which dice were wild, exploded, fumbled, removed, or added so that I can audit the roll result.
|
||||
* As a **user**, I want to choose whether a roll is public or private so that I can control information visibility.
|
||||
* As a **GM**, I want to see all rolls (including private ones) so that I can oversee the game.
|
||||
|
||||
|
||||
@@ -34,19 +34,23 @@ public sealed class CampaignApiTests : ApiTestBase
|
||||
var createdSkill = await PostAsync<CreateSkillRequest, SkillSummary>(
|
||||
gmClient,
|
||||
$"/api/characters/{gmCharacter.Id}/skills",
|
||||
new CreateSkillRequest("Arcana", "2d12+2"));
|
||||
new CreateSkillRequest("Arcana", "2d12+2", 0, false));
|
||||
Assert.Equal("2d12+2", createdSkill.DiceRollDefinition);
|
||||
Assert.Equal(0, createdSkill.WildDice);
|
||||
Assert.False(createdSkill.AllowFumble);
|
||||
|
||||
var updatedSkill = await PutAsync<UpdateSkillRequest, SkillSummary>(
|
||||
gmClient,
|
||||
$"/api/skills/{createdSkill.Id}",
|
||||
new UpdateSkillRequest("Arcana Mastery", "2d12+3"));
|
||||
new UpdateSkillRequest("Arcana Mastery", "2d12+3", 0, false));
|
||||
Assert.Equal("Arcana Mastery", updatedSkill.Name);
|
||||
Assert.Equal("2d12+3", updatedSkill.DiceRollDefinition);
|
||||
Assert.Equal(0, updatedSkill.WildDice);
|
||||
Assert.False(updatedSkill.AllowFumble);
|
||||
|
||||
var invalidSkill = await gmClient.PostAsJsonAsync(
|
||||
$"/api/characters/{gmCharacter.Id}/skills",
|
||||
new CreateSkillRequest("Broken", "5D+4"));
|
||||
new CreateSkillRequest("Broken", "5D+4", 0, false));
|
||||
Assert.Equal(HttpStatusCode.BadRequest, invalidSkill.StatusCode);
|
||||
|
||||
var details = await GetAsync<CampaignDetails>(gmClient, $"/api/campaigns/{campaign.Id}");
|
||||
|
||||
24
RpgRoller.Tests/Api/FrontendHostTests.cs
Normal file
24
RpgRoller.Tests/Api/FrontendHostTests.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
|
||||
namespace RpgRoller.Tests;
|
||||
|
||||
public sealed class FrontendHostTests : ApiTestBase
|
||||
{
|
||||
public FrontendHostTests(WebApplicationFactory<Program> factory)
|
||||
: base(factory)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RootPath_ServesBlazorFrontendShell()
|
||||
{
|
||||
using var factory = CreateFactory(1);
|
||||
using var client = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
|
||||
var response = await client.GetAsync("/");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var html = await response.Content.ReadAsStringAsync();
|
||||
Assert.Contains("_framework/blazor.web.js", html);
|
||||
Assert.Contains("Connecting...", html);
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,9 @@ public sealed class RollVisibilityApiTests : ApiTestBase
|
||||
var skill = await PostAsync<CreateSkillRequest, SkillSummary>(
|
||||
playerClient,
|
||||
$"/api/characters/{playerCharacter.Id}/skills",
|
||||
new CreateSkillRequest("Stealth", "2D+1"));
|
||||
new CreateSkillRequest("Stealth", "2D+1", 1, true));
|
||||
Assert.Equal(1, skill.WildDice);
|
||||
Assert.True(skill.AllowFumble);
|
||||
|
||||
await RegisterAsync(observerClient, "observer", "Password123", "Observer");
|
||||
await LoginAsync(observerClient, "observer", "Password123");
|
||||
|
||||
@@ -88,8 +88,8 @@ public sealed class ServiceCampaignTests
|
||||
var ownerCharacter = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Owner Character", campaign.Id));
|
||||
var otherCharacter = ServiceTestSupport.GetValue(service.CreateCharacter(otherSession, "Other Character", campaign.Id));
|
||||
|
||||
var ownerSkill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, ownerCharacter.Id, "Stealth", "2D+1"));
|
||||
_ = ServiceTestSupport.GetValue(service.CreateSkill(otherSession, otherCharacter.Id, "Perception", "1D+2"));
|
||||
var ownerSkill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, ownerCharacter.Id, "Stealth", "2D+1", 1, true));
|
||||
_ = ServiceTestSupport.GetValue(service.CreateSkill(otherSession, otherCharacter.Id, "Perception", "1D+2", 1, true));
|
||||
|
||||
var ownerView = ServiceTestSupport.GetValue(service.GetCampaign(ownerSession, campaign.Id));
|
||||
Assert.Single(ownerView.Characters);
|
||||
|
||||
80
RpgRoller.Tests/Services/ServiceD6RollTests.cs
Normal file
80
RpgRoller.Tests/Services/ServiceD6RollTests.cs
Normal file
@@ -0,0 +1,80 @@
|
||||
namespace RpgRoller.Tests;
|
||||
|
||||
public sealed class ServiceD6RollTests
|
||||
{
|
||||
[Fact]
|
||||
public void RollSkill_D6WildCritical_AddsExtraDieAndTracksFlags()
|
||||
{
|
||||
using var harness = ServiceTestSupport.CreateHarness(6, 4, 2);
|
||||
var service = harness.Service;
|
||||
|
||||
service.Register("gm", "Password123", "GM");
|
||||
var session = ServiceTestSupport.GetValue(service.Login("gm", "Password123")).SessionToken;
|
||||
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(session, "Main", "d6"));
|
||||
var character = ServiceTestSupport.GetValue(service.CreateCharacter(session, "Hero", campaign.Id));
|
||||
var skill = ServiceTestSupport.GetValue(service.CreateSkill(session, character.Id, "Blaster", "2D+1", 1, true));
|
||||
|
||||
var roll = ServiceTestSupport.GetValue(service.RollSkill(session, skill.Id, "public"));
|
||||
|
||||
Assert.Equal(13, roll.Result);
|
||||
Assert.Equal("6+4+2+1=13", roll.Breakdown);
|
||||
Assert.Equal(3, roll.Dice.Count);
|
||||
Assert.True(roll.Dice[0].Wild);
|
||||
Assert.True(roll.Dice[0].Crit);
|
||||
Assert.True(roll.Dice[2].Added);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RollSkill_D6Fumble_RemovesHighestDieAndPreservesFumbleDie()
|
||||
{
|
||||
using var harness = ServiceTestSupport.CreateHarness(1, 3, 6, 1);
|
||||
var service = harness.Service;
|
||||
|
||||
service.Register("gm", "Password123", "GM");
|
||||
var session = ServiceTestSupport.GetValue(service.Login("gm", "Password123")).SessionToken;
|
||||
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(session, "Main", "d6"));
|
||||
var character = ServiceTestSupport.GetValue(service.CreateCharacter(session, "Hero", campaign.Id));
|
||||
var skill = ServiceTestSupport.GetValue(service.CreateSkill(session, character.Id, "Brawl", "3D", 1, true));
|
||||
|
||||
var roll = ServiceTestSupport.GetValue(service.RollSkill(session, skill.Id, "public"));
|
||||
|
||||
Assert.Equal(4, roll.Result);
|
||||
Assert.Equal("1+3=4", roll.Breakdown);
|
||||
Assert.Equal(3, roll.Dice.Count);
|
||||
Assert.True(roll.Dice[0].Fumble);
|
||||
Assert.True(roll.Dice[0].Wild);
|
||||
Assert.True(roll.Dice[2].Removed);
|
||||
Assert.False(roll.Dice[2].Crit);
|
||||
Assert.False(roll.Dice[2].Fumble);
|
||||
|
||||
var noFumbleSkill = ServiceTestSupport.GetValue(service.CreateSkill(session, character.Id, "Calm", "1D", 1, false));
|
||||
var noFumbleRoll = ServiceTestSupport.GetValue(service.RollSkill(session, noFumbleSkill.Id, "public"));
|
||||
Assert.False(noFumbleRoll.Dice[0].Fumble);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SkillOptions_AreValidatedForD6AndIgnoredForDnd5e()
|
||||
{
|
||||
using var harness = ServiceTestSupport.CreateHarness(2, 2);
|
||||
var service = harness.Service;
|
||||
|
||||
service.Register("gm", "Password123", "GM");
|
||||
var session = ServiceTestSupport.GetValue(service.Login("gm", "Password123")).SessionToken;
|
||||
|
||||
var d6Campaign = ServiceTestSupport.GetValue(service.CreateCampaign(session, "D6", "d6"));
|
||||
var d6Character = ServiceTestSupport.GetValue(service.CreateCharacter(session, "D6 Hero", d6Campaign.Id));
|
||||
|
||||
var missingWildDice = service.CreateSkill(session, d6Character.Id, "Broken", "2D+1", 0, true);
|
||||
Assert.False(missingWildDice.Succeeded);
|
||||
|
||||
var tooManyWildDice = service.CreateSkill(session, d6Character.Id, "Broken 2", "2D+1", 51, true);
|
||||
Assert.False(tooManyWildDice.Succeeded);
|
||||
|
||||
var dndCampaign = ServiceTestSupport.GetValue(service.CreateCampaign(session, "DND", "dnd5e"));
|
||||
var dndCharacter = ServiceTestSupport.GetValue(service.CreateCharacter(session, "Mage", dndCampaign.Id));
|
||||
var dndSkill = ServiceTestSupport.GetValue(service.CreateSkill(session, dndCharacter.Id, "Arcana", "1d20+2", 5, true));
|
||||
|
||||
Assert.Equal(0, dndSkill.WildDice);
|
||||
Assert.False(dndSkill.AllowFumble);
|
||||
}
|
||||
}
|
||||
@@ -34,9 +34,9 @@ public sealed class ServicePersistenceTests
|
||||
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, "Stealth", "2D+1").Succeeded);
|
||||
Assert.False(service.CreateSkill(ownerSession, Guid.NewGuid(), "Stealth", "2D+1").Succeeded);
|
||||
Assert.False(service.CreateSkill(otherSession, ownerCharacter.Id, "Stealth", "2D+1").Succeeded);
|
||||
Assert.False(service.CreateSkill(string.Empty, ownerCharacter.Id, "Stealth", "2D+1", 1, true).Succeeded);
|
||||
Assert.False(service.CreateSkill(ownerSession, Guid.NewGuid(), "Stealth", "2D+1", 1, true).Succeeded);
|
||||
Assert.False(service.CreateSkill(otherSession, ownerCharacter.Id, "Stealth", "2D+1", 1, true).Succeeded);
|
||||
|
||||
using (var db = harness.CreateDbContext())
|
||||
{
|
||||
@@ -74,10 +74,10 @@ public sealed class ServicePersistenceTests
|
||||
Assert.Null(db.Users.Single(u => u.UsernameNormalized == "OWNER").ActiveCharacterId);
|
||||
}
|
||||
|
||||
var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, ownerCharacter.Id, "Stealth", "2D+1"));
|
||||
Assert.False(service.UpdateSkill(ownerSession, skill.Id, "", "2D+1").Succeeded);
|
||||
Assert.False(service.UpdateSkill(string.Empty, skill.Id, "Stealth", "2D+1").Succeeded);
|
||||
Assert.False(service.UpdateSkill(ownerSession, skill.Id, "Stealth", "bad").Succeeded);
|
||||
var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, ownerCharacter.Id, "Stealth", "2D+1", 1, true));
|
||||
Assert.False(service.UpdateSkill(ownerSession, skill.Id, "", "2D+1", 1, true).Succeeded);
|
||||
Assert.False(service.UpdateSkill(string.Empty, skill.Id, "Stealth", "2D+1", 1, true).Succeeded);
|
||||
Assert.False(service.UpdateSkill(ownerSession, skill.Id, "Stealth", "bad", 1, true).Succeeded);
|
||||
Assert.False(service.RollSkill(string.Empty, skill.Id, "public").Succeeded);
|
||||
|
||||
using (var db = harness.CreateDbContext())
|
||||
|
||||
@@ -27,21 +27,21 @@ public sealed class ServiceSkillRollTests
|
||||
var missingTargetCampaign = service.UpdateCharacter(ownerSession, character.Id, "Renamed", Guid.NewGuid());
|
||||
Assert.False(missingTargetCampaign.Succeeded);
|
||||
|
||||
var noSkillName = service.CreateSkill(ownerSession, character.Id, "", "1d20");
|
||||
var noSkillName = service.CreateSkill(ownerSession, character.Id, "", "1d20", 0, false);
|
||||
Assert.False(noSkillName.Succeeded);
|
||||
|
||||
var invalidExpression = service.CreateSkill(ownerSession, character.Id, "Skill", "5D+4");
|
||||
var invalidExpression = service.CreateSkill(ownerSession, character.Id, "Skill", "5D+4", 0, false);
|
||||
Assert.False(invalidExpression.Succeeded);
|
||||
|
||||
var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, character.Id, "Skill", "1d20+2"));
|
||||
var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, character.Id, "Skill", "1d20+2", 0, false));
|
||||
|
||||
var missingSkillUpdate = service.UpdateSkill(ownerSession, Guid.NewGuid(), "X", "1d20");
|
||||
var missingSkillUpdate = service.UpdateSkill(ownerSession, Guid.NewGuid(), "X", "1d20", 0, false);
|
||||
Assert.False(missingSkillUpdate.Succeeded);
|
||||
|
||||
var forbiddenSkillUpdate = service.UpdateSkill(otherSession, skill.Id, "X", "1d20");
|
||||
var forbiddenSkillUpdate = service.UpdateSkill(otherSession, skill.Id, "X", "1d20", 0, false);
|
||||
Assert.False(forbiddenSkillUpdate.Succeeded);
|
||||
|
||||
var gmSkillUpdate = service.UpdateSkill(gmSession, skill.Id, "GM Edit", "2d6+1");
|
||||
var gmSkillUpdate = service.UpdateSkill(gmSession, skill.Id, "GM Edit", "2d6+1", 0, false);
|
||||
Assert.True(gmSkillUpdate.Succeeded);
|
||||
|
||||
var missingRoll = service.RollSkill(ownerSession, Guid.NewGuid(), "public");
|
||||
|
||||
@@ -9,13 +9,13 @@ internal static class SkillEndpoints
|
||||
{
|
||||
group.MapPost("/characters/{characterId:guid}/skills", (Guid characterId, CreateSkillRequest request, HttpContext context, IGameService game) =>
|
||||
{
|
||||
var result = game.CreateSkill(context.GetRequiredSessionToken(), characterId, request.Name, request.DiceRollDefinition);
|
||||
var result = game.CreateSkill(context.GetRequiredSessionToken(), characterId, request.Name, request.DiceRollDefinition, request.WildDice, request.AllowFumble);
|
||||
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.Name, request.DiceRollDefinition);
|
||||
var result = game.UpdateSkill(context.GetRequiredSessionToken(), skillId, request.Name, request.DiceRollDefinition, request.WildDice, request.AllowFumble);
|
||||
return ApiResultMapper.ToApiResult(result);
|
||||
});
|
||||
|
||||
|
||||
19
RpgRoller/Components/App.razor
Normal file
19
RpgRoller/Components/App.razor
Normal file
@@ -0,0 +1,19 @@
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@attribute [ExcludeFromCodeCoverage]
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<base href="/" />
|
||||
<title>RpgRoller</title>
|
||||
<link rel="stylesheet" href="/styles.css" />
|
||||
<HeadOutlet @rendermode="InteractiveServer" />
|
||||
</head>
|
||||
<body>
|
||||
<Routes @rendermode="InteractiveServer" />
|
||||
<script src="/js/rpgroller-api.js"></script>
|
||||
<script src="_framework/blazor.web.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
4
RpgRoller/Components/Layout/MainLayout.razor
Normal file
4
RpgRoller/Components/Layout/MainLayout.razor
Normal file
@@ -0,0 +1,4 @@
|
||||
@inherits LayoutComponentBase
|
||||
@attribute [ExcludeFromCodeCoverage]
|
||||
|
||||
@Body
|
||||
502
RpgRoller/Components/Pages/Home.razor
Normal file
502
RpgRoller/Components/Pages/Home.razor
Normal file
@@ -0,0 +1,502 @@
|
||||
@page "/"
|
||||
@implements IAsyncDisposable
|
||||
|
||||
<div class="rr-app">
|
||||
<p class="sr-only" aria-live="polite">@LiveAnnouncement</p>
|
||||
|
||||
@if (!IsInitialized)
|
||||
{
|
||||
<main class="loading-shell" aria-busy="true" aria-live="polite">
|
||||
<h1>RpgRoller</h1>
|
||||
<p>Connecting...</p>
|
||||
</main>
|
||||
}
|
||||
else
|
||||
{
|
||||
@if (HasHealthIssue)
|
||||
{
|
||||
<section class="health-banner" role="alert">
|
||||
<div>
|
||||
<strong>API currently unavailable.</strong>
|
||||
<p>@HealthIssueMessage</p>
|
||||
</div>
|
||||
<button type="button" @onclick="RetryAfterHealthIssueAsync">Retry</button>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (User is null)
|
||||
{
|
||||
<main class="auth-shell">
|
||||
<h1>RpgRoller</h1>
|
||||
<p class="auth-subtitle">Register or log in to join a campaign session.</p>
|
||||
@if (!string.IsNullOrWhiteSpace(StatusMessage))
|
||||
{
|
||||
<p class="status-message @(StatusIsError ? "error" : "success")">@StatusMessage</p>
|
||||
}
|
||||
<div class="auth-grid">
|
||||
<section class="card auth-card">
|
||||
<h2>Register</h2>
|
||||
@if (!string.IsNullOrWhiteSpace(RegisterFormError))
|
||||
{
|
||||
<p class="form-error">@RegisterFormError</p>
|
||||
}
|
||||
<form class="form-grid" @onsubmit="RegisterAsync" @onsubmit:preventDefault>
|
||||
<label for="register-username">Username</label>
|
||||
<input id="register-username" @bind="RegisterForm.Username" @bind:event="oninput" autocomplete="username" />
|
||||
@if (RegisterErrors.TryGetValue("username", out var registerUsernameError))
|
||||
{
|
||||
<p class="field-error">@registerUsernameError</p>
|
||||
}
|
||||
<label for="register-display-name">Display name</label>
|
||||
<input id="register-display-name" @bind="RegisterForm.DisplayName" @bind:event="oninput" autocomplete="name" />
|
||||
@if (RegisterErrors.TryGetValue("displayName", out var registerDisplayNameError))
|
||||
{
|
||||
<p class="field-error">@registerDisplayNameError</p>
|
||||
}
|
||||
<label for="register-password">Password</label>
|
||||
<input id="register-password" type="password" @bind="RegisterForm.Password" @bind:event="oninput" autocomplete="new-password" />
|
||||
@if (RegisterErrors.TryGetValue("password", out var registerPasswordError))
|
||||
{
|
||||
<p class="field-error">@registerPasswordError</p>
|
||||
}
|
||||
<button type="submit" disabled="@IsMutating">@(IsMutating ? "Registering..." : "Register")</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card auth-card">
|
||||
<h2>Login</h2>
|
||||
@if (!string.IsNullOrWhiteSpace(LoginFormError))
|
||||
{
|
||||
<p class="form-error">@LoginFormError</p>
|
||||
}
|
||||
<form class="form-grid" @onsubmit="LoginAsync" @onsubmit:preventDefault>
|
||||
<label for="login-username">Username</label>
|
||||
<input id="login-username" @bind="LoginForm.Username" @bind:event="oninput" autocomplete="username" />
|
||||
@if (LoginErrors.TryGetValue("username", out var loginUsernameError))
|
||||
{
|
||||
<p class="field-error">@loginUsernameError</p>
|
||||
}
|
||||
<label for="login-password">Password</label>
|
||||
<input id="login-password" type="password" @bind="LoginForm.Password" @bind:event="oninput" autocomplete="current-password" />
|
||||
@if (LoginErrors.TryGetValue("password", out var loginPasswordError))
|
||||
{
|
||||
<p class="field-error">@loginPasswordError</p>
|
||||
}
|
||||
<button type="submit" disabled="@IsMutating">@(IsMutating ? "Logging in..." : "Login")</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="workspace-shell">
|
||||
<header class="workspace-header">
|
||||
<div class="header-group brand">
|
||||
<h1>RpgRoller</h1>
|
||||
<p>Tabletop utility cockpit</p>
|
||||
</div>
|
||||
<div class="header-group context">
|
||||
<p><strong>@User.DisplayName</strong> <span class="muted">(@User.Username)</span></p>
|
||||
<p>Campaign: <strong>@(SelectedCampaignName ?? "No campaign selected")</strong></p>
|
||||
<p>Active: <strong>@(ActiveCharacterName ?? "None selected")</strong></p>
|
||||
</div>
|
||||
<div class="header-group controls">
|
||||
<p class="connection @ConnectionStateCssClass">@ConnectionStateLabel</p>
|
||||
<div class="switch-group" role="tablist" aria-label="Screen selector">
|
||||
<button type="button" class="switch @(CurrentScreen == "play" ? "active" : string.Empty)" @onclick="SwitchToPlayAsync">Play</button>
|
||||
<button type="button" class="switch @(CurrentScreen == "management" ? "active" : string.Empty)" @onclick="SwitchToManagementAsync">Campaign Management</button>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button type="button" @onclick="ManualRefreshAsync" disabled="@IsMutating">Refresh</button>
|
||||
<button type="button" class="ghost" @onclick="LogoutAsync" disabled="@IsMutating">Logout</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(StatusMessage))
|
||||
{
|
||||
<p class="status-message @(StatusIsError ? "error" : "success")">@StatusMessage</p>
|
||||
}
|
||||
|
||||
@if (CurrentScreen == "play")
|
||||
{
|
||||
<main class="play-screen @(MobilePanel == "log" ? "mobile-log" : "mobile-character")">
|
||||
<section class="card character-panel">
|
||||
<div class="section-head"><h2>Character Context</h2></div>
|
||||
@if (IsCampaignDataLoading)
|
||||
{
|
||||
<div class="skeleton-stack"><div class="skeleton-line"></div><div class="skeleton-line short"></div><div class="skeleton-line"></div></div>
|
||||
}
|
||||
else if (SelectedCampaign is null)
|
||||
{
|
||||
<p class="empty">No campaign selected. Choose one in Campaign Management.</p>
|
||||
}
|
||||
else if (SelectedCampaign.Characters.Count == 0)
|
||||
{
|
||||
<p class="empty">No characters in this campaign yet.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="character-picker" role="tablist" aria-label="Character picker">
|
||||
@foreach (var character in SelectedCampaign.Characters)
|
||||
{
|
||||
var isSelectedCharacter = SelectedCharacterId == character.Id;
|
||||
<button type="button" class="icon-tab @(isSelectedCharacter ? "active" : string.Empty)" aria-label="@character.Name" @onclick="() => SelectCharacter(character.Id)">
|
||||
<span class="icon-tab-glyph">@InitialsFor(character.Name)</span>
|
||||
<span class="icon-tab-text">@character.Name</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@if (SelectedCharacter is not null)
|
||||
{
|
||||
<article class="character-sheet">
|
||||
<h3>@SelectedCharacter.Name</h3>
|
||||
<p>Owner: @OwnerLabel(SelectedCharacter.OwnerUserId)</p>
|
||||
<p>Campaign: @SelectedCampaign.Name</p>
|
||||
@if (SelectedCharacter.Id == ActiveCharacterId)
|
||||
{
|
||||
<span class="badge active">Active</span>
|
||||
}
|
||||
<div class="inline-actions">
|
||||
<button type="button" disabled="@(IsMutating || !CanEditCharacter(SelectedCharacter))" @onclick="() => OpenEditCharacterModal(SelectedCharacter)">Edit Character</button>
|
||||
<button type="button" disabled="@(IsMutating || !CanActivateCharacter(SelectedCharacter))" @onclick="() => ActivateCharacterAsync(SelectedCharacter.Id)">Activate Character</button>
|
||||
</div>
|
||||
</article>
|
||||
<article class="skills-section">
|
||||
<div class="section-head">
|
||||
<h3>Skills</h3>
|
||||
<div class="inline-actions">
|
||||
<button type="button" disabled="@(IsMutating || !CanEditCharacter(SelectedCharacter))" @onclick="OpenCreateSkillModal">Create Skill</button>
|
||||
<button type="button" disabled="@(IsMutating || SelectedSkill is null || !CanEditSkill(SelectedSkill))" @onclick="OpenEditSkillModal">Edit Skill</button>
|
||||
</div>
|
||||
</div>
|
||||
@if (SelectedCharacterSkills.Count == 0)
|
||||
{
|
||||
<p class="empty">No skills for this character yet.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="skill-list">
|
||||
@foreach (var skill in SelectedCharacterSkills)
|
||||
{
|
||||
var isSelectedSkill = SelectedSkillId == skill.Id;
|
||||
<button type="button" class="skill-item @(isSelectedSkill ? "active" : string.Empty)" @onclick="() => SelectSkill(skill.Id)">
|
||||
<strong>@skill.Name</strong><span>@SkillDefinitionLabel(skill)</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<form class="roll-panel" @onsubmit="RollSelectedSkillAsync" @onsubmit:preventDefault>
|
||||
<label for="roll-visibility">Visibility</label>
|
||||
<select id="roll-visibility" @bind="RollVisibility">
|
||||
<option value="public">Public</option>
|
||||
<option value="private">Private</option>
|
||||
</select>
|
||||
<button type="submit" disabled="@(IsMutating || SelectedSkill is null || !CanRollSkill(SelectedSkill))">Roll Skill</button>
|
||||
</form>
|
||||
</article>
|
||||
}
|
||||
}
|
||||
<article class="last-roll">
|
||||
<h3>Last Roll</h3>
|
||||
@if (LastRoll is null)
|
||||
{
|
||||
<p class="empty">No roll yet.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="roll-total">@LastRoll.Result</p>
|
||||
@if (LastRoll.Dice.Count > 0)
|
||||
{
|
||||
<div class="roll-dice-strip" aria-label="Rolled dice">
|
||||
@foreach (var die in LastRoll.Dice)
|
||||
{
|
||||
<span class="@RollDieCssClass(die)" title="@RollDieTitle(die)">@RollDieGlyph(die.Roll)</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<p>@LastRoll.Breakdown</p>
|
||||
<p><span class="badge @(LastRoll.Visibility == "private" ? "private-self" : "public")">@LastRoll.Visibility</span> <time title="@LastRoll.TimestampUtc.ToString("O")">@LastRoll.TimestampUtc.ToLocalTime().ToString("g")</time></p>
|
||||
}
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<aside class="card log-panel">
|
||||
<div class="section-head"><h2>Campaign Log</h2></div>
|
||||
@if (IsCampaignDataLoading)
|
||||
{
|
||||
<div class="skeleton-stack"><div class="skeleton-line"></div><div class="skeleton-line"></div><div class="skeleton-line short"></div></div>
|
||||
}
|
||||
else if (CampaignLog.Count == 0)
|
||||
{
|
||||
<p class="empty">No log entries yet.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<ul class="log-list">
|
||||
@foreach (var entry in CampaignLog)
|
||||
{
|
||||
<li class="log-entry @LogEntryCssClass(entry)">
|
||||
<p><strong>@RollerLabel(entry)</strong> rolled <strong>@SkillLabel(entry.SkillId)</strong> with <strong>@CharacterLabel(entry.CharacterId)</strong></p>
|
||||
<p>@entry.Breakdown</p>
|
||||
<p class="log-meta"><span class="badge @VisibilityBadgeCssClass(entry)">@VisibilityLabel(entry)</span> <time title="@entry.TimestampUtc.ToString("O")">@entry.TimestampUtc.ToLocalTime().ToString("g")</time></p>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</aside>
|
||||
</main>
|
||||
<nav class="mobile-bottom-nav" aria-label="Play panel selector">
|
||||
<button type="button" class="switch @(MobilePanel == "character" ? "active" : string.Empty)" @onclick="SetMobilePanelCharacterAsync">Character</button>
|
||||
<button type="button" class="switch @(MobilePanel == "log" ? "active" : string.Empty)" @onclick="SetMobilePanelLogAsync">Log</button>
|
||||
</nav>
|
||||
}
|
||||
else
|
||||
{
|
||||
<main class="management-screen">
|
||||
<section class="card">
|
||||
<h2>Campaign Selector</h2>
|
||||
@if (Campaigns.Count == 0)
|
||||
{
|
||||
<p class="empty">No campaigns yet.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<label for="campaign-select">Campaign</label>
|
||||
<select id="campaign-select" @onchange="OnCampaignSelectionChangedAsync">
|
||||
@foreach (var campaign in Campaigns)
|
||||
{
|
||||
<option value="@campaign.Id" selected="@(campaign.Id == SelectedCampaignId)">@campaign.Name (@campaign.RulesetId)</option>
|
||||
}
|
||||
</select>
|
||||
}
|
||||
<p class="muted">Current campaign in this tab: <strong>@(SelectedCampaignName ?? "None selected")</strong></p>
|
||||
</section>
|
||||
<section class="card">
|
||||
<h2>Create Campaign</h2>
|
||||
@if (!string.IsNullOrWhiteSpace(CampaignFormError))
|
||||
{
|
||||
<p class="form-error">@CampaignFormError</p>
|
||||
}
|
||||
<form class="form-grid" @onsubmit="CreateCampaignAsync" @onsubmit:preventDefault>
|
||||
<label for="campaign-name">Campaign name</label>
|
||||
<input id="campaign-name" @bind="CampaignForm.Name" @bind:event="oninput" />
|
||||
@if (CampaignErrors.TryGetValue("name", out var campaignNameError))
|
||||
{
|
||||
<p class="field-error">@campaignNameError</p>
|
||||
}
|
||||
<label for="campaign-ruleset">Ruleset</label>
|
||||
<select id="campaign-ruleset" @bind="CampaignForm.RulesetId">
|
||||
<option value="">Select ruleset</option>
|
||||
@foreach (var ruleset in Rulesets)
|
||||
{
|
||||
<option value="@ruleset.Id">@ruleset.Name</option>
|
||||
}
|
||||
</select>
|
||||
@if (CampaignErrors.TryGetValue("rulesetId", out var campaignRulesetError))
|
||||
{
|
||||
<p class="field-error">@campaignRulesetError</p>
|
||||
}
|
||||
<button type="submit" disabled="@IsMutating">@(IsMutating ? "Creating..." : "Create Campaign")</button>
|
||||
</form>
|
||||
</section>
|
||||
<section class="card">
|
||||
<h2>Campaign Details</h2>
|
||||
@if (SelectedCampaign is null)
|
||||
{
|
||||
<p class="empty">No campaign selected.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p>Name: <strong>@SelectedCampaign.Name</strong></p>
|
||||
<p>Ruleset: <strong>@SelectedCampaign.RulesetId</strong></p>
|
||||
<p>GM: <strong>@SelectedCampaign.Gm.DisplayName</strong> <span class="muted">(@SelectedCampaign.Gm.Username)</span></p>
|
||||
<p>Characters visible: <strong>@SelectedCampaign.Characters.Count</strong></p>
|
||||
}
|
||||
</section>
|
||||
<section class="card">
|
||||
<div class="section-head">
|
||||
<h2>Character Management</h2>
|
||||
<button type="button" disabled="@(IsMutating || SelectedCampaign is null)" @onclick="OpenCreateCharacterModal">Create Character</button>
|
||||
</div>
|
||||
@if (SelectedCampaign is null)
|
||||
{
|
||||
<p class="empty">Select a campaign first.</p>
|
||||
}
|
||||
else if (SelectedCampaign.Characters.Count == 0)
|
||||
{
|
||||
<p class="empty">No characters in this campaign yet.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<ul class="management-list">
|
||||
@foreach (var character in SelectedCampaign.Characters)
|
||||
{
|
||||
<li>
|
||||
<div><strong>@character.Name</strong><p class="muted">Owner: @OwnerLabel(character.OwnerUserId)</p></div>
|
||||
<div class="inline-actions">
|
||||
<button type="button" disabled="@(IsMutating || !CanEditCharacter(character))" @onclick="() => OpenEditCharacterModal(character)">Edit</button>
|
||||
<button type="button" disabled="@(IsMutating || !CanActivateCharacter(character))" @onclick="() => ActivateCharacterAsync(character.Id)">Activate</button>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</section>
|
||||
</main>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (ShowCreateCharacterModal)
|
||||
{
|
||||
<div class="modal-overlay" role="presentation">
|
||||
<section class="modal-card" role="dialog" aria-modal="true" aria-label="Create character">
|
||||
<h2>Create Character</h2>
|
||||
@if (!string.IsNullOrWhiteSpace(CharacterFormError))
|
||||
{
|
||||
<p class="form-error">@CharacterFormError</p>
|
||||
}
|
||||
<form class="form-grid" @onsubmit="CreateCharacterAsync" @onsubmit:preventDefault>
|
||||
<label for="character-create-name">Character name</label>
|
||||
<input id="character-create-name" @bind="CharacterForm.Name" @bind:event="oninput" />
|
||||
@if (CharacterErrors.TryGetValue("name", out var createCharacterNameError))
|
||||
{
|
||||
<p class="field-error">@createCharacterNameError</p>
|
||||
}
|
||||
<label for="character-create-campaign">Campaign</label>
|
||||
<select id="character-create-campaign" @bind="CharacterForm.CampaignId">
|
||||
<option value="">Select campaign</option>
|
||||
@foreach (var campaign in Campaigns)
|
||||
{
|
||||
<option value="@campaign.Id">@campaign.Name</option>
|
||||
}
|
||||
</select>
|
||||
<div class="inline-actions">
|
||||
<button type="submit" disabled="@IsMutating">Create Character</button>
|
||||
<button type="button" class="ghost" @onclick="CloseCharacterModals">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (ShowEditCharacterModal)
|
||||
{
|
||||
<div class="modal-overlay" role="presentation">
|
||||
<section class="modal-card" role="dialog" aria-modal="true" aria-label="Edit character">
|
||||
<h2>Edit Character</h2>
|
||||
@if (!string.IsNullOrWhiteSpace(EditCharacterFormError))
|
||||
{
|
||||
<p class="form-error">@EditCharacterFormError</p>
|
||||
}
|
||||
<form class="form-grid" @onsubmit="UpdateCharacterAsync" @onsubmit:preventDefault>
|
||||
<label for="character-edit-name">Character name</label>
|
||||
<input id="character-edit-name" @bind="EditCharacterForm.Name" @bind:event="oninput" />
|
||||
@if (EditCharacterErrors.TryGetValue("name", out var editCharacterNameError))
|
||||
{
|
||||
<p class="field-error">@editCharacterNameError</p>
|
||||
}
|
||||
<label for="character-edit-campaign">Campaign</label>
|
||||
<select id="character-edit-campaign" @bind="EditCharacterForm.CampaignId">
|
||||
<option value="">Select campaign</option>
|
||||
@foreach (var campaign in Campaigns)
|
||||
{
|
||||
<option value="@campaign.Id">@campaign.Name</option>
|
||||
}
|
||||
</select>
|
||||
<div class="inline-actions">
|
||||
<button type="submit" disabled="@IsMutating">Save Character</button>
|
||||
<button type="button" class="ghost" @onclick="CloseCharacterModals">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (ShowCreateSkillModal)
|
||||
{
|
||||
<div class="modal-overlay" role="presentation">
|
||||
<section class="modal-card" role="dialog" aria-modal="true" aria-label="Create skill">
|
||||
<h2>Create Skill</h2>
|
||||
@if (!string.IsNullOrWhiteSpace(SkillFormError))
|
||||
{
|
||||
<p class="form-error">@SkillFormError</p>
|
||||
}
|
||||
<form class="form-grid" @onsubmit="CreateSkillAsync" @onsubmit:preventDefault>
|
||||
<label for="skill-create-name">Skill name</label>
|
||||
<input id="skill-create-name" @bind="SkillForm.Name" @bind:event="oninput" />
|
||||
@if (SkillErrors.TryGetValue("name", out var createSkillNameError))
|
||||
{
|
||||
<p class="field-error">@createSkillNameError</p>
|
||||
}
|
||||
<label for="skill-create-expression">Expression</label>
|
||||
<input id="skill-create-expression" @bind="SkillForm.DiceRollDefinition" @bind:event="oninput" />
|
||||
@if (SkillErrors.TryGetValue("diceRollDefinition", out var createSkillExpressionError))
|
||||
{
|
||||
<p class="field-error">@createSkillExpressionError</p>
|
||||
}
|
||||
@if (IsSelectedCampaignD6)
|
||||
{
|
||||
<label for="skill-create-wild-dice">Wild dice</label>
|
||||
<input id="skill-create-wild-dice" type="number" min="1" step="1" @bind="SkillForm.WildDice" />
|
||||
@if (SkillErrors.TryGetValue("wildDice", out var createSkillWildDiceError))
|
||||
{
|
||||
<p class="field-error">@createSkillWildDiceError</p>
|
||||
}
|
||||
<label for="skill-create-allow-fumble">Allow fumble</label>
|
||||
<input id="skill-create-allow-fumble" type="checkbox" @bind="SkillForm.AllowFumble" />
|
||||
}
|
||||
<div class="inline-actions">
|
||||
<button type="submit" disabled="@IsMutating">Create Skill</button>
|
||||
<button type="button" class="ghost" @onclick="CloseSkillModals">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (ShowEditSkillModal)
|
||||
{
|
||||
<div class="modal-overlay" role="presentation">
|
||||
<section class="modal-card" role="dialog" aria-modal="true" aria-label="Edit skill">
|
||||
<h2>Edit Skill</h2>
|
||||
@if (!string.IsNullOrWhiteSpace(EditSkillFormError))
|
||||
{
|
||||
<p class="form-error">@EditSkillFormError</p>
|
||||
}
|
||||
<form class="form-grid" @onsubmit="UpdateSkillAsync" @onsubmit:preventDefault>
|
||||
<label for="skill-edit-name">Skill name</label>
|
||||
<input id="skill-edit-name" @bind="EditSkillForm.Name" @bind:event="oninput" />
|
||||
@if (EditSkillErrors.TryGetValue("name", out var editSkillNameError))
|
||||
{
|
||||
<p class="field-error">@editSkillNameError</p>
|
||||
}
|
||||
<label for="skill-edit-expression">Expression</label>
|
||||
<input id="skill-edit-expression" @bind="EditSkillForm.DiceRollDefinition" @bind:event="oninput" />
|
||||
@if (EditSkillErrors.TryGetValue("diceRollDefinition", out var editSkillExpressionError))
|
||||
{
|
||||
<p class="field-error">@editSkillExpressionError</p>
|
||||
}
|
||||
@if (IsSelectedCampaignD6)
|
||||
{
|
||||
<label for="skill-edit-wild-dice">Wild dice</label>
|
||||
<input id="skill-edit-wild-dice" type="number" min="1" step="1" @bind="EditSkillForm.WildDice" />
|
||||
@if (EditSkillErrors.TryGetValue("wildDice", out var editSkillWildDiceError))
|
||||
{
|
||||
<p class="field-error">@editSkillWildDiceError</p>
|
||||
}
|
||||
<label for="skill-edit-allow-fumble">Allow fumble</label>
|
||||
<input id="skill-edit-allow-fumble" type="checkbox" @bind="EditSkillForm.AllowFumble" />
|
||||
}
|
||||
<div class="inline-actions">
|
||||
<button type="submit" disabled="@IsMutating">Save Skill</button>
|
||||
<button type="button" class="ghost" @onclick="CloseSkillModals">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
1267
RpgRoller/Components/Pages/Home.razor.cs
Normal file
1267
RpgRoller/Components/Pages/Home.razor.cs
Normal file
File diff suppressed because it is too large
Load Diff
8
RpgRoller/Components/Routes.razor
Normal file
8
RpgRoller/Components/Routes.razor
Normal file
@@ -0,0 +1,8 @@
|
||||
@attribute [ExcludeFromCodeCoverage]
|
||||
|
||||
<Router AppAssembly="@typeof(Program).Assembly">
|
||||
<Found Context="routeData">
|
||||
<RouteView RouteData="@routeData" DefaultLayout="@typeof(Layout.MainLayout)" />
|
||||
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
|
||||
</Found>
|
||||
</Router>
|
||||
9
RpgRoller/Components/_Imports.razor
Normal file
9
RpgRoller/Components/_Imports.razor
Normal file
@@ -0,0 +1,9 @@
|
||||
@using System.Diagnostics.CodeAnalysis
|
||||
@using RpgRoller
|
||||
@using RpgRoller.Contracts
|
||||
@using Microsoft.AspNetCore.Components
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.AspNetCore.Components.Routing
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using Microsoft.JSInterop
|
||||
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
||||
@@ -25,11 +25,12 @@ public sealed record CreateCharacterRequest(string Name, Guid CampaignId);
|
||||
public sealed record UpdateCharacterRequest(string Name, Guid CampaignId);
|
||||
public sealed record CharacterSummary(Guid Id, string Name, Guid OwnerUserId, Guid CampaignId);
|
||||
|
||||
public sealed record CreateSkillRequest(string Name, string DiceRollDefinition);
|
||||
public sealed record UpdateSkillRequest(string Name, string DiceRollDefinition);
|
||||
public sealed record SkillSummary(Guid Id, Guid CharacterId, string Name, string DiceRollDefinition);
|
||||
public sealed record CreateSkillRequest(string Name, string DiceRollDefinition, int WildDice, bool AllowFumble);
|
||||
public sealed record UpdateSkillRequest(string Name, string DiceRollDefinition, int WildDice, bool AllowFumble);
|
||||
public sealed record SkillSummary(Guid Id, Guid CharacterId, string Name, string DiceRollDefinition, int WildDice, bool AllowFumble);
|
||||
|
||||
public sealed record RollSkillRequest(string Visibility);
|
||||
public sealed record RollDieResult(int Roll, bool Crit, bool Fumble, bool Wild, bool Removed, bool Added);
|
||||
public sealed record RollResult(
|
||||
Guid RollId,
|
||||
Guid CampaignId,
|
||||
@@ -39,6 +40,7 @@ public sealed record RollResult(
|
||||
string Visibility,
|
||||
int Result,
|
||||
string Breakdown,
|
||||
IReadOnlyList<RollDieResult> Dice,
|
||||
DateTimeOffset TimestampUtc);
|
||||
|
||||
public sealed record CampaignLogEntry(
|
||||
|
||||
@@ -59,6 +59,8 @@ public sealed class RpgRollerDbContext : DbContext
|
||||
entity.HasKey(x => x.Id);
|
||||
entity.Property(x => x.Name).IsRequired().HasMaxLength(128);
|
||||
entity.Property(x => x.DiceRollDefinition).IsRequired().HasMaxLength(128);
|
||||
entity.Property(x => x.WildDice).IsRequired();
|
||||
entity.Property(x => x.AllowFumble).IsRequired();
|
||||
entity.HasIndex(x => x.CharacterId);
|
||||
});
|
||||
|
||||
|
||||
@@ -52,6 +52,8 @@ public sealed class Skill
|
||||
public required Guid CharacterId { get; set; }
|
||||
public required string Name { get; set; }
|
||||
public required string DiceRollDefinition { get; set; }
|
||||
public required int WildDice { get; set; }
|
||||
public required bool AllowFumble { get; set; }
|
||||
}
|
||||
|
||||
public sealed class RollLogEntry
|
||||
|
||||
@@ -3,14 +3,18 @@ using RpgRoller.Hosting;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddRpgRollerCore(builder.Configuration, builder.Environment);
|
||||
builder.Services.AddRazorComponents()
|
||||
.AddInteractiveServerComponents();
|
||||
|
||||
var app = builder.Build();
|
||||
app.InitializeRpgRollerState();
|
||||
|
||||
app.UseDefaultFiles();
|
||||
app.UseStaticFiles();
|
||||
app.UseAntiforgery();
|
||||
|
||||
app.MapRpgRollerApi();
|
||||
app.MapRazorComponents<RpgRoller.Components.App>()
|
||||
.AddInteractiveServerRenderMode();
|
||||
app.Run();
|
||||
|
||||
public partial class Program;
|
||||
|
||||
@@ -399,7 +399,7 @@ public sealed class GameService : IGameService
|
||||
}
|
||||
}
|
||||
|
||||
public ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition)
|
||||
public ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
@@ -431,12 +431,20 @@ public sealed class GameService : IGameService
|
||||
return ServiceResult<SkillSummary>.Failure(expressionValidation.Error!.Code, expressionValidation.Error.Message);
|
||||
}
|
||||
|
||||
var optionsValidation = ValidateSkillOptions(campaign.Ruleset, wildDice, allowFumble);
|
||||
if (!optionsValidation.Succeeded)
|
||||
{
|
||||
return ServiceResult<SkillSummary>.Failure(optionsValidation.Error!.Code, optionsValidation.Error.Message);
|
||||
}
|
||||
|
||||
var skill = new Skill
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CharacterId = character.Id,
|
||||
Name = name.Trim(),
|
||||
DiceRollDefinition = expressionValidation.Value!.Canonical
|
||||
DiceRollDefinition = expressionValidation.Value!.Canonical,
|
||||
WildDice = optionsValidation.Value!.WildDice,
|
||||
AllowFumble = optionsValidation.Value.AllowFumble
|
||||
};
|
||||
|
||||
m_SkillsById[skill.Id] = skill;
|
||||
@@ -447,7 +455,7 @@ public sealed class GameService : IGameService
|
||||
}
|
||||
}
|
||||
|
||||
public ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition)
|
||||
public ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
@@ -480,8 +488,16 @@ public sealed class GameService : IGameService
|
||||
return ServiceResult<SkillSummary>.Failure(expressionValidation.Error!.Code, expressionValidation.Error.Message);
|
||||
}
|
||||
|
||||
var optionsValidation = ValidateSkillOptions(campaign.Ruleset, wildDice, allowFumble);
|
||||
if (!optionsValidation.Succeeded)
|
||||
{
|
||||
return ServiceResult<SkillSummary>.Failure(optionsValidation.Error!.Code, optionsValidation.Error.Message);
|
||||
}
|
||||
|
||||
skill.Name = name.Trim();
|
||||
skill.DiceRollDefinition = expressionValidation.Value!.Canonical;
|
||||
skill.WildDice = optionsValidation.Value!.WildDice;
|
||||
skill.AllowFumble = optionsValidation.Value.AllowFumble;
|
||||
TouchCampaignLocked(campaign.Id);
|
||||
|
||||
PersistStateLocked();
|
||||
@@ -523,7 +539,7 @@ public sealed class GameService : IGameService
|
||||
return ServiceResult<RollResult>.Failure(parsedVisibility.Error!.Code, parsedVisibility.Error.Message);
|
||||
}
|
||||
|
||||
var roll = ComputeRoll(parsedExpression.Value!);
|
||||
var roll = ComputeRoll(campaign.Ruleset, parsedExpression.Value!, skill);
|
||||
var entry = new RollLogEntry
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
@@ -541,7 +557,7 @@ public sealed class GameService : IGameService
|
||||
TouchCampaignLocked(campaign.Id);
|
||||
|
||||
PersistStateLocked();
|
||||
return ServiceResult<RollResult>.Success(ToRollResult(entry));
|
||||
return ServiceResult<RollResult>.Success(ToRollResult(entry, roll.Dice));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -582,20 +598,143 @@ public sealed class GameService : IGameService
|
||||
}
|
||||
}
|
||||
|
||||
private (int Total, string Breakdown) ComputeRoll(DiceExpression expression)
|
||||
private static ServiceResult<(int WildDice, bool AllowFumble)> ValidateSkillOptions(RulesetKind ruleset, int wildDice, bool allowFumble)
|
||||
{
|
||||
if (wildDice < 0 || wildDice > 50)
|
||||
{
|
||||
return ServiceResult<(int WildDice, bool AllowFumble)>.Failure("invalid_wild_dice", "Wild dice must be between 0 and 50.");
|
||||
}
|
||||
|
||||
if (ruleset == RulesetKind.D6)
|
||||
{
|
||||
if (wildDice < 1)
|
||||
{
|
||||
return ServiceResult<(int WildDice, bool AllowFumble)>.Failure("invalid_wild_dice", "D6 skills require at least one wild die.");
|
||||
}
|
||||
|
||||
return ServiceResult<(int WildDice, bool AllowFumble)>.Success((wildDice, allowFumble));
|
||||
}
|
||||
|
||||
return ServiceResult<(int WildDice, bool AllowFumble)>.Success((0, false));
|
||||
}
|
||||
|
||||
private (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) ComputeRoll(RulesetKind ruleset, DiceExpression expression, Skill skill)
|
||||
{
|
||||
return ruleset == RulesetKind.D6
|
||||
? ComputeD6Roll(expression, skill.WildDice, skill.AllowFumble)
|
||||
: ComputeStandardRoll(expression);
|
||||
}
|
||||
|
||||
private (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) ComputeStandardRoll(DiceExpression expression)
|
||||
{
|
||||
var diceValues = new int[expression.DiceCount];
|
||||
var dice = new RollDieResult[expression.DiceCount];
|
||||
var total = expression.Modifier;
|
||||
for (var i = 0; i < expression.DiceCount; i += 1)
|
||||
{
|
||||
var value = m_DiceRoller.Roll(expression.Sides);
|
||||
diceValues[i] = value;
|
||||
dice[i] = new RollDieResult(value, false, false, false, false, false);
|
||||
total += value;
|
||||
}
|
||||
|
||||
var modifierPart = expression.Modifier > 0 ? $"+{expression.Modifier}" : string.Empty;
|
||||
var breakdown = $"{string.Join("+", diceValues)}{modifierPart}={total}";
|
||||
return (total, breakdown);
|
||||
return (total, BuildBreakdown(diceValues, expression.Modifier, total), dice);
|
||||
}
|
||||
|
||||
private (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) ComputeD6Roll(DiceExpression expression, int wildDice, bool allowFumble)
|
||||
{
|
||||
var initialDice = expression.DiceCount;
|
||||
var currentDice = initialDice;
|
||||
var pendingExplodingDice = 0;
|
||||
var pendingFumbles = 0;
|
||||
var dieResults = new List<RollDieResult>(initialDice);
|
||||
|
||||
for (var i = 0; i < currentDice; i += 1)
|
||||
{
|
||||
var roll = m_DiceRoller.Roll(expression.Sides);
|
||||
var isWild = i < wildDice;
|
||||
var isCrit = false;
|
||||
var isFumble = false;
|
||||
var isAdded = false;
|
||||
|
||||
if (isWild)
|
||||
{
|
||||
if (roll == expression.Sides)
|
||||
{
|
||||
pendingExplodingDice += 1;
|
||||
currentDice += 1;
|
||||
isCrit = true;
|
||||
}
|
||||
else if (allowFumble && roll == 1)
|
||||
{
|
||||
pendingFumbles += 1;
|
||||
isFumble = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingExplodingDice > 0 && i >= initialDice)
|
||||
{
|
||||
pendingExplodingDice -= 1;
|
||||
isAdded = true;
|
||||
if (roll == expression.Sides)
|
||||
{
|
||||
pendingExplodingDice += 1;
|
||||
currentDice += 1;
|
||||
}
|
||||
}
|
||||
|
||||
dieResults.Add(new RollDieResult(roll, isCrit, isFumble, isWild, false, isAdded));
|
||||
}
|
||||
|
||||
for (var roll = expression.Sides; roll >= 1 && pendingFumbles > 0; roll -= 1)
|
||||
{
|
||||
for (var i = currentDice - 1; i >= 0 && pendingFumbles > 0; i -= 1)
|
||||
{
|
||||
if (dieResults[i].Roll != roll)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
dieResults[i] = dieResults[i] with
|
||||
{
|
||||
Removed = true,
|
||||
Added = false,
|
||||
Crit = false,
|
||||
Fumble = false
|
||||
};
|
||||
pendingFumbles -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
var total = expression.Modifier;
|
||||
var includedDice = new List<int>(dieResults.Count);
|
||||
foreach (var die in dieResults)
|
||||
{
|
||||
if (die.Fumble)
|
||||
{
|
||||
total += 1;
|
||||
includedDice.Add(1);
|
||||
}
|
||||
else if (!die.Removed)
|
||||
{
|
||||
total += die.Roll;
|
||||
includedDice.Add(die.Roll);
|
||||
}
|
||||
}
|
||||
|
||||
return (total, BuildBreakdown(includedDice, expression.Modifier, total), dieResults);
|
||||
}
|
||||
|
||||
private static string BuildBreakdown(IEnumerable<int> diceValues, int modifier, int total)
|
||||
{
|
||||
var dicePart = string.Join("+", diceValues);
|
||||
if (string.IsNullOrWhiteSpace(dicePart))
|
||||
{
|
||||
dicePart = "0";
|
||||
}
|
||||
|
||||
var modifierPart = modifier > 0 ? $"+{modifier}" : string.Empty;
|
||||
return $"{dicePart}{modifierPart}={total}";
|
||||
}
|
||||
|
||||
private ServiceResult<RollVisibility> ParseVisibility(string visibility)
|
||||
@@ -651,10 +790,10 @@ public sealed class GameService : IGameService
|
||||
|
||||
private static SkillSummary ToSkillSummary(Skill skill)
|
||||
{
|
||||
return new SkillSummary(skill.Id, skill.CharacterId, skill.Name, skill.DiceRollDefinition);
|
||||
return new SkillSummary(skill.Id, skill.CharacterId, skill.Name, skill.DiceRollDefinition, skill.WildDice, skill.AllowFumble);
|
||||
}
|
||||
|
||||
private static RollResult ToRollResult(RollLogEntry entry)
|
||||
private static RollResult ToRollResult(RollLogEntry entry, IReadOnlyList<RollDieResult> dice)
|
||||
{
|
||||
return new RollResult(
|
||||
entry.Id,
|
||||
@@ -665,6 +804,7 @@ public sealed class GameService : IGameService
|
||||
entry.Visibility == RollVisibility.Public ? "public" : "private",
|
||||
entry.Result,
|
||||
entry.Breakdown,
|
||||
dice,
|
||||
entry.TimestampUtc);
|
||||
}
|
||||
|
||||
@@ -905,7 +1045,9 @@ public sealed class GameService : IGameService
|
||||
Id = skill.Id,
|
||||
CharacterId = skill.CharacterId,
|
||||
Name = skill.Name,
|
||||
DiceRollDefinition = skill.DiceRollDefinition
|
||||
DiceRollDefinition = skill.DiceRollDefinition,
|
||||
WildDice = skill.WildDice,
|
||||
AllowFumble = skill.AllowFumble
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -22,8 +22,8 @@ public interface IGameService
|
||||
ServiceResult<bool> ActivateCharacter(string sessionToken, Guid characterId);
|
||||
ServiceResult<IReadOnlyList<CharacterSummary>> GetCurrentCampaignCharacters(string sessionToken);
|
||||
|
||||
ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition);
|
||||
ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition);
|
||||
ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble);
|
||||
ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble);
|
||||
|
||||
ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility);
|
||||
ServiceResult<IReadOnlyList<CampaignLogEntry>> GetCampaignLog(string sessionToken, Guid campaignId);
|
||||
|
||||
@@ -1,252 +0,0 @@
|
||||
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 runAppAction(async () => {
|
||||
await registerUser({
|
||||
username: elements.registerUsername.value.trim(),
|
||||
displayName: elements.registerDisplayName.value.trim(),
|
||||
password: elements.registerPassword.value
|
||||
});
|
||||
|
||||
elements.registerPassword.value = "";
|
||||
setStatus("Registration successful. You can log in now.", false);
|
||||
});
|
||||
});
|
||||
|
||||
elements.loginForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
await runAppAction(async () => {
|
||||
await loginUser({
|
||||
username: elements.loginUsername.value.trim(),
|
||||
password: elements.loginPassword.value
|
||||
});
|
||||
|
||||
elements.loginPassword.value = "";
|
||||
await reloadAll();
|
||||
setStatus("Logged in.", false);
|
||||
});
|
||||
});
|
||||
|
||||
elements.logoutButton.addEventListener("click", async () => {
|
||||
await runAppAction(async () => {
|
||||
await logoutUser();
|
||||
resetStateAfterLogout(state);
|
||||
closeStateEvents(state);
|
||||
renderAll(state, elements);
|
||||
setStatus("Logged out.", false);
|
||||
});
|
||||
});
|
||||
|
||||
elements.campaignForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
await runAppAction(async () => {
|
||||
const createdCampaign = await createCampaign({
|
||||
name: elements.campaignNameInput.value.trim(),
|
||||
rulesetId: elements.campaignRulesetSelect.value
|
||||
});
|
||||
|
||||
elements.campaignNameInput.value = "";
|
||||
await reloadCampaigns(state, createdCampaign.id);
|
||||
await reloadSelectedCampaign(state);
|
||||
await reloadCampaignLog(state);
|
||||
syncEventStream();
|
||||
renderAll(state, elements);
|
||||
setStatus("Campaign created.", false);
|
||||
});
|
||||
});
|
||||
|
||||
elements.campaignSelect.addEventListener("change", async () => {
|
||||
await runAppAction(async () => {
|
||||
const selected = elements.campaignSelect.value;
|
||||
state.selectedCampaignId = selected.length > 0 ? selected : null;
|
||||
await reloadSelectedCampaign(state);
|
||||
syncSelectedCharacter(state);
|
||||
renderCharacterSelect(state, elements);
|
||||
renderSkillSelect(state, elements);
|
||||
syncEventStream();
|
||||
renderCampaignMeta(state, elements);
|
||||
renderCampaignDetails(state, elements);
|
||||
renderCampaignLog(state, elements);
|
||||
});
|
||||
});
|
||||
|
||||
elements.characterSelect.addEventListener("change", () => {
|
||||
state.selectedCharacterId = elements.characterSelect.value.length > 0 ? elements.characterSelect.value : null;
|
||||
renderSkillSelect(state, elements);
|
||||
});
|
||||
|
||||
elements.refreshCampaignButton.addEventListener("click", async () => {
|
||||
await runAppAction(async () => {
|
||||
await reloadSelectedCampaign(state);
|
||||
await reloadCampaignLog(state);
|
||||
renderAll(state, elements);
|
||||
setStatus("Campaign refreshed.", false);
|
||||
});
|
||||
});
|
||||
|
||||
elements.characterForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
await runAppAction(async () => {
|
||||
if (!state.selectedCampaignId) {
|
||||
throw new Error("Select a campaign first.");
|
||||
}
|
||||
|
||||
await createCharacter({
|
||||
name: elements.characterNameInput.value.trim(),
|
||||
campaignId: state.selectedCampaignId
|
||||
});
|
||||
|
||||
elements.characterNameInput.value = "";
|
||||
await reloadSelectedCampaign(state);
|
||||
await reloadCampaignLog(state);
|
||||
renderAll(state, elements);
|
||||
setStatus("Character created.", false);
|
||||
});
|
||||
});
|
||||
|
||||
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.");
|
||||
}
|
||||
|
||||
await activateCharacter(characterId);
|
||||
state.activeCharacterId = characterId;
|
||||
state.selectedCharacterId = characterId;
|
||||
await reloadAll();
|
||||
setStatus("Active character updated.", false);
|
||||
});
|
||||
});
|
||||
|
||||
elements.skillForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
await runAppAction(async () => {
|
||||
const characterId = elements.characterSelect.value;
|
||||
if (characterId.length === 0) {
|
||||
throw new Error("Select a character first.");
|
||||
}
|
||||
|
||||
await createSkill(characterId, {
|
||||
name: elements.skillNameInput.value.trim(),
|
||||
diceRollDefinition: elements.skillExpressionInput.value.trim()
|
||||
});
|
||||
|
||||
elements.skillNameInput.value = "";
|
||||
elements.skillExpressionInput.value = "";
|
||||
await reloadSelectedCampaign(state);
|
||||
renderAll(state, elements);
|
||||
setStatus("Skill created.", false);
|
||||
});
|
||||
});
|
||||
|
||||
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: elements.skillNameInput.value.trim(),
|
||||
diceRollDefinition: elements.skillExpressionInput.value.trim()
|
||||
});
|
||||
|
||||
await reloadSelectedCampaign(state);
|
||||
renderAll(state, elements);
|
||||
setStatus("Skill updated.", false);
|
||||
});
|
||||
});
|
||||
|
||||
elements.rollForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
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: elements.rollVisibilitySelect.value });
|
||||
elements.rollResultElement.textContent = `Roll ${roll.rollId}: ${roll.breakdown} (${roll.visibility})`;
|
||||
|
||||
await reloadCampaignLog(state);
|
||||
renderCampaignLog(state, elements);
|
||||
setStatus("Roll recorded.", false);
|
||||
});
|
||||
});
|
||||
|
||||
await runAppAction(async () => {
|
||||
await refreshHealth(elements);
|
||||
await reloadAll();
|
||||
setStatus("Ready.", false);
|
||||
});
|
||||
|
||||
async function reloadAll(): Promise<void> {
|
||||
await ensureRulesets(state, elements);
|
||||
await reloadSession(state);
|
||||
if (state.user) {
|
||||
await reloadCampaigns(state, state.selectedCampaignId);
|
||||
await reloadSelectedCampaign(state);
|
||||
await reloadCampaignLog(state);
|
||||
syncEventStream();
|
||||
}
|
||||
else {
|
||||
resetAuthenticatedState(state);
|
||||
closeStateEvents(state);
|
||||
}
|
||||
|
||||
renderAll(state, elements);
|
||||
}
|
||||
|
||||
function syncEventStream(): void {
|
||||
connectStateEvents(state, () => {
|
||||
void runAppAction(async () => {
|
||||
await reloadSelectedCampaign(state);
|
||||
await reloadCampaignLog(state);
|
||||
renderAll(state, elements);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function runAppAction(action: () => Promise<void>): Promise<void> {
|
||||
await runAction(action, (message) => {
|
||||
setStatus(message, true);
|
||||
});
|
||||
}
|
||||
|
||||
function setStatus(message: string, isError: boolean): void {
|
||||
setMessage(elements.messageElement, message, isError);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
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("");
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
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,343 +0,0 @@
|
||||
/* This file is generated by scripts/generate-api-client.mjs. */
|
||||
|
||||
export interface ApiError {
|
||||
error: string;
|
||||
}
|
||||
|
||||
export interface CampaignDetails {
|
||||
id: string;
|
||||
name: string;
|
||||
rulesetId: string;
|
||||
gm: UserSummary;
|
||||
characters: Array<CharacterSummary>;
|
||||
skills: Array<SkillSummary>;
|
||||
}
|
||||
|
||||
export interface CampaignLogEntry {
|
||||
rollId: string;
|
||||
campaignId: string;
|
||||
characterId: string;
|
||||
skillId: string;
|
||||
rollerUserId: string;
|
||||
visibility: string;
|
||||
result: number;
|
||||
breakdown: string;
|
||||
timestampUtc: string;
|
||||
}
|
||||
|
||||
export interface CampaignSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
rulesetId: string;
|
||||
gmUserId: string;
|
||||
}
|
||||
|
||||
export interface CharacterSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
ownerUserId: string;
|
||||
campaignId: string;
|
||||
}
|
||||
|
||||
export interface CreateCampaignRequest {
|
||||
name: string;
|
||||
rulesetId: string;
|
||||
}
|
||||
|
||||
export interface CreateCharacterRequest {
|
||||
name: string;
|
||||
campaignId: string;
|
||||
}
|
||||
|
||||
export interface CreateSkillRequest {
|
||||
name: string;
|
||||
diceRollDefinition: string;
|
||||
}
|
||||
|
||||
export interface HealthResponse {
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface LoginRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface MeResponse {
|
||||
user: UserSummary;
|
||||
activeCharacterId?: string;
|
||||
currentCampaignId?: string;
|
||||
}
|
||||
|
||||
export interface RegisterRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export interface RollResult {
|
||||
rollId: string;
|
||||
campaignId: string;
|
||||
characterId: string;
|
||||
skillId: string;
|
||||
rollerUserId: string;
|
||||
visibility: string;
|
||||
result: number;
|
||||
breakdown: string;
|
||||
timestampUtc: string;
|
||||
}
|
||||
|
||||
export interface RollSkillRequest {
|
||||
visibility: string;
|
||||
}
|
||||
|
||||
export interface RulesetDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
diceSyntax: string;
|
||||
}
|
||||
|
||||
export interface SkillSummary {
|
||||
id: string;
|
||||
characterId: string;
|
||||
name: string;
|
||||
diceRollDefinition: string;
|
||||
}
|
||||
|
||||
export interface UpdateCharacterRequest {
|
||||
name: string;
|
||||
campaignId: string;
|
||||
}
|
||||
|
||||
export interface UpdateSkillRequest {
|
||||
name: string;
|
||||
diceRollDefinition: string;
|
||||
}
|
||||
|
||||
export interface UserSummary {
|
||||
id: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
type ApiOperation = {
|
||||
method: string;
|
||||
path: string;
|
||||
expectsJson: boolean;
|
||||
};
|
||||
|
||||
type RequestOptions = {
|
||||
pathParams?: Record<string, string | number | boolean>;
|
||||
query?: Record<string, string | number | boolean | undefined>;
|
||||
body?: unknown;
|
||||
};
|
||||
|
||||
export const apiOperations = {
|
||||
activateCharacter: { method: "POST", path: "/api/characters/{characterId}/activate", expectsJson: true },
|
||||
createCampaign: { method: "POST", path: "/api/campaigns", expectsJson: true },
|
||||
createCharacter: { method: "POST", path: "/api/characters", expectsJson: true },
|
||||
createSkill: { method: "POST", path: "/api/characters/{characterId}/skills", expectsJson: true },
|
||||
getCampaign: { method: "GET", path: "/api/campaigns/{campaignId}", expectsJson: true },
|
||||
getCampaignLog: { method: "GET", path: "/api/campaigns/{campaignId}/log", expectsJson: true },
|
||||
getCampaigns: { method: "GET", path: "/api/campaigns", expectsJson: true },
|
||||
getCurrentCampaignCharacters: { method: "GET", path: "/api/characters/current-campaign", expectsJson: true },
|
||||
getHealth: { method: "GET", path: "/api/health", expectsJson: true },
|
||||
getMe: { method: "GET", path: "/api/me", expectsJson: true },
|
||||
getRulesets: { method: "GET", path: "/api/rulesets", expectsJson: true },
|
||||
loginUser: { method: "POST", path: "/api/auth/login", expectsJson: true },
|
||||
logoutUser: { method: "POST", path: "/api/auth/logout", expectsJson: false },
|
||||
registerUser: { method: "POST", path: "/api/auth/register", expectsJson: true },
|
||||
rollSkill: { method: "POST", path: "/api/skills/{skillId}/roll", expectsJson: true },
|
||||
updateCharacter: { method: "PUT", path: "/api/characters/{characterId}", expectsJson: true },
|
||||
updateSkill: { method: "PUT", path: "/api/skills/{skillId}", expectsJson: true }
|
||||
} as const satisfies Record<string, ApiOperation>;
|
||||
|
||||
function withPathParams(pathTemplate: string, pathParams: Record<string, string | number | boolean>): string {
|
||||
let pathValue = pathTemplate;
|
||||
for (const [key, value] of Object.entries(pathParams)) {
|
||||
pathValue = pathValue.replace(`{${key}}`, encodeURIComponent(String(value)));
|
||||
}
|
||||
|
||||
return pathValue;
|
||||
}
|
||||
|
||||
function withQuery(pathValue: string, query: Record<string, string | number | boolean | undefined>): string {
|
||||
const entries = Object.entries(query).filter(([, value]) => value !== undefined);
|
||||
if (entries.length === 0) {
|
||||
return pathValue;
|
||||
}
|
||||
|
||||
const queryString = new URLSearchParams(entries.map(([key, value]) => [key, String(value)])).toString();
|
||||
return queryString.length === 0 ? pathValue : `${pathValue}?${queryString}`;
|
||||
}
|
||||
|
||||
async function send<TResult>(operation: ApiOperation, options: RequestOptions): Promise<TResult> {
|
||||
const resolvedPath = withQuery(withPathParams(operation.path, options.pathParams ?? {}), options.query ?? {});
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Accept": "application/json"
|
||||
};
|
||||
|
||||
let body: string | undefined;
|
||||
if (options.body !== undefined) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
body = JSON.stringify(options.body);
|
||||
}
|
||||
|
||||
const response = await fetch(resolvedPath, {
|
||||
method: operation.method,
|
||||
headers,
|
||||
body
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorPayload: unknown = await response.json().catch(() => ({ error: "Unknown API error." }));
|
||||
if (errorPayload && typeof errorPayload === "object" && "error" in errorPayload && typeof errorPayload.error === "string") {
|
||||
throw new Error(errorPayload.error);
|
||||
}
|
||||
|
||||
throw new Error(`Request failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
if (!operation.expectsJson) {
|
||||
return undefined as TResult;
|
||||
}
|
||||
|
||||
return response.json() as Promise<TResult>;
|
||||
}
|
||||
|
||||
export async function activateCharacter(characterId: string): Promise<boolean> {
|
||||
return send<boolean>(apiOperations.activateCharacter, {
|
||||
pathParams: { characterId: characterId },
|
||||
query: undefined,
|
||||
body: undefined
|
||||
});
|
||||
}
|
||||
|
||||
export async function createCampaign(body: CreateCampaignRequest): Promise<CampaignSummary> {
|
||||
return send<CampaignSummary>(apiOperations.createCampaign, {
|
||||
pathParams: {},
|
||||
query: undefined,
|
||||
body: body
|
||||
});
|
||||
}
|
||||
|
||||
export async function createCharacter(body: CreateCharacterRequest): Promise<CharacterSummary> {
|
||||
return send<CharacterSummary>(apiOperations.createCharacter, {
|
||||
pathParams: {},
|
||||
query: undefined,
|
||||
body: body
|
||||
});
|
||||
}
|
||||
|
||||
export async function createSkill(characterId: string, body: CreateSkillRequest): Promise<SkillSummary> {
|
||||
return send<SkillSummary>(apiOperations.createSkill, {
|
||||
pathParams: { characterId: characterId },
|
||||
query: undefined,
|
||||
body: body
|
||||
});
|
||||
}
|
||||
|
||||
export async function getCampaign(campaignId: string): Promise<CampaignDetails> {
|
||||
return send<CampaignDetails>(apiOperations.getCampaign, {
|
||||
pathParams: { campaignId: campaignId },
|
||||
query: undefined,
|
||||
body: undefined
|
||||
});
|
||||
}
|
||||
|
||||
export async function getCampaignLog(campaignId: string): Promise<Array<CampaignLogEntry>> {
|
||||
return send<Array<CampaignLogEntry>>(apiOperations.getCampaignLog, {
|
||||
pathParams: { campaignId: campaignId },
|
||||
query: undefined,
|
||||
body: undefined
|
||||
});
|
||||
}
|
||||
|
||||
export async function getCampaigns(): Promise<Array<CampaignSummary>> {
|
||||
return send<Array<CampaignSummary>>(apiOperations.getCampaigns, {
|
||||
pathParams: {},
|
||||
query: undefined,
|
||||
body: undefined
|
||||
});
|
||||
}
|
||||
|
||||
export async function getCurrentCampaignCharacters(): Promise<Array<CharacterSummary>> {
|
||||
return send<Array<CharacterSummary>>(apiOperations.getCurrentCampaignCharacters, {
|
||||
pathParams: {},
|
||||
query: undefined,
|
||||
body: undefined
|
||||
});
|
||||
}
|
||||
|
||||
export async function getHealth(): Promise<HealthResponse> {
|
||||
return send<HealthResponse>(apiOperations.getHealth, {
|
||||
pathParams: {},
|
||||
query: undefined,
|
||||
body: undefined
|
||||
});
|
||||
}
|
||||
|
||||
export async function getMe(): Promise<MeResponse> {
|
||||
return send<MeResponse>(apiOperations.getMe, {
|
||||
pathParams: {},
|
||||
query: undefined,
|
||||
body: undefined
|
||||
});
|
||||
}
|
||||
|
||||
export async function getRulesets(): Promise<Array<RulesetDefinition>> {
|
||||
return send<Array<RulesetDefinition>>(apiOperations.getRulesets, {
|
||||
pathParams: {},
|
||||
query: undefined,
|
||||
body: undefined
|
||||
});
|
||||
}
|
||||
|
||||
export async function loginUser(body: LoginRequest): Promise<UserSummary> {
|
||||
return send<UserSummary>(apiOperations.loginUser, {
|
||||
pathParams: {},
|
||||
query: undefined,
|
||||
body: body
|
||||
});
|
||||
}
|
||||
|
||||
export async function logoutUser(): Promise<void> {
|
||||
return send<void>(apiOperations.logoutUser, {
|
||||
pathParams: {},
|
||||
query: undefined,
|
||||
body: undefined
|
||||
});
|
||||
}
|
||||
|
||||
export async function registerUser(body: RegisterRequest): Promise<UserSummary> {
|
||||
return send<UserSummary>(apiOperations.registerUser, {
|
||||
pathParams: {},
|
||||
query: undefined,
|
||||
body: body
|
||||
});
|
||||
}
|
||||
|
||||
export async function rollSkill(skillId: string, body: RollSkillRequest): Promise<RollResult> {
|
||||
return send<RollResult>(apiOperations.rollSkill, {
|
||||
pathParams: { skillId: skillId },
|
||||
query: undefined,
|
||||
body: body
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateCharacter(characterId: string, body: UpdateCharacterRequest): Promise<CharacterSummary> {
|
||||
return send<CharacterSummary>(apiOperations.updateCharacter, {
|
||||
pathParams: { characterId: characterId },
|
||||
query: undefined,
|
||||
body: body
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateSkill(skillId: string, body: UpdateSkillRequest): Promise<SkillSummary> {
|
||||
return send<SkillSummary>(apiOperations.updateSkill, {
|
||||
pathParams: { skillId: skillId },
|
||||
query: undefined,
|
||||
body: body
|
||||
});
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
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 runAppAction(async () => {
|
||||
await registerUser({
|
||||
username: elements.registerUsername.value.trim(),
|
||||
displayName: elements.registerDisplayName.value.trim(),
|
||||
password: elements.registerPassword.value
|
||||
});
|
||||
elements.registerPassword.value = "";
|
||||
setStatus("Registration successful. You can log in now.", false);
|
||||
});
|
||||
});
|
||||
elements.loginForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
await runAppAction(async () => {
|
||||
await loginUser({
|
||||
username: elements.loginUsername.value.trim(),
|
||||
password: elements.loginPassword.value
|
||||
});
|
||||
elements.loginPassword.value = "";
|
||||
await reloadAll();
|
||||
setStatus("Logged in.", false);
|
||||
});
|
||||
});
|
||||
elements.logoutButton.addEventListener("click", async () => {
|
||||
await runAppAction(async () => {
|
||||
await logoutUser();
|
||||
resetStateAfterLogout(state);
|
||||
closeStateEvents(state);
|
||||
renderAll(state, elements);
|
||||
setStatus("Logged out.", false);
|
||||
});
|
||||
});
|
||||
elements.campaignForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
await runAppAction(async () => {
|
||||
const createdCampaign = await createCampaign({
|
||||
name: elements.campaignNameInput.value.trim(),
|
||||
rulesetId: elements.campaignRulesetSelect.value
|
||||
});
|
||||
elements.campaignNameInput.value = "";
|
||||
await reloadCampaigns(state, createdCampaign.id);
|
||||
await reloadSelectedCampaign(state);
|
||||
await reloadCampaignLog(state);
|
||||
syncEventStream();
|
||||
renderAll(state, elements);
|
||||
setStatus("Campaign created.", false);
|
||||
});
|
||||
});
|
||||
elements.campaignSelect.addEventListener("change", async () => {
|
||||
await runAppAction(async () => {
|
||||
const selected = elements.campaignSelect.value;
|
||||
state.selectedCampaignId = selected.length > 0 ? selected : null;
|
||||
await reloadSelectedCampaign(state);
|
||||
syncSelectedCharacter(state);
|
||||
renderCharacterSelect(state, elements);
|
||||
renderSkillSelect(state, elements);
|
||||
syncEventStream();
|
||||
renderCampaignMeta(state, elements);
|
||||
renderCampaignDetails(state, elements);
|
||||
renderCampaignLog(state, elements);
|
||||
});
|
||||
});
|
||||
elements.characterSelect.addEventListener("change", () => {
|
||||
state.selectedCharacterId = elements.characterSelect.value.length > 0 ? elements.characterSelect.value : null;
|
||||
renderSkillSelect(state, elements);
|
||||
});
|
||||
elements.refreshCampaignButton.addEventListener("click", async () => {
|
||||
await runAppAction(async () => {
|
||||
await reloadSelectedCampaign(state);
|
||||
await reloadCampaignLog(state);
|
||||
renderAll(state, elements);
|
||||
setStatus("Campaign refreshed.", false);
|
||||
});
|
||||
});
|
||||
elements.characterForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
await runAppAction(async () => {
|
||||
if (!state.selectedCampaignId) {
|
||||
throw new Error("Select a campaign first.");
|
||||
}
|
||||
await createCharacter({
|
||||
name: elements.characterNameInput.value.trim(),
|
||||
campaignId: state.selectedCampaignId
|
||||
});
|
||||
elements.characterNameInput.value = "";
|
||||
await reloadSelectedCampaign(state);
|
||||
await reloadCampaignLog(state);
|
||||
renderAll(state, elements);
|
||||
setStatus("Character created.", false);
|
||||
});
|
||||
});
|
||||
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.");
|
||||
}
|
||||
await activateCharacter(characterId);
|
||||
state.activeCharacterId = characterId;
|
||||
state.selectedCharacterId = characterId;
|
||||
await reloadAll();
|
||||
setStatus("Active character updated.", false);
|
||||
});
|
||||
});
|
||||
elements.skillForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
await runAppAction(async () => {
|
||||
const characterId = elements.characterSelect.value;
|
||||
if (characterId.length === 0) {
|
||||
throw new Error("Select a character first.");
|
||||
}
|
||||
await createSkill(characterId, {
|
||||
name: elements.skillNameInput.value.trim(),
|
||||
diceRollDefinition: elements.skillExpressionInput.value.trim()
|
||||
});
|
||||
elements.skillNameInput.value = "";
|
||||
elements.skillExpressionInput.value = "";
|
||||
await reloadSelectedCampaign(state);
|
||||
renderAll(state, elements);
|
||||
setStatus("Skill created.", false);
|
||||
});
|
||||
});
|
||||
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: elements.skillNameInput.value.trim(),
|
||||
diceRollDefinition: elements.skillExpressionInput.value.trim()
|
||||
});
|
||||
await reloadSelectedCampaign(state);
|
||||
renderAll(state, elements);
|
||||
setStatus("Skill updated.", false);
|
||||
});
|
||||
});
|
||||
elements.rollForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
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: elements.rollVisibilitySelect.value });
|
||||
elements.rollResultElement.textContent = `Roll ${roll.rollId}: ${roll.breakdown} (${roll.visibility})`;
|
||||
await reloadCampaignLog(state);
|
||||
renderCampaignLog(state, elements);
|
||||
setStatus("Roll recorded.", false);
|
||||
});
|
||||
});
|
||||
await runAppAction(async () => {
|
||||
await refreshHealth(elements);
|
||||
await reloadAll();
|
||||
setStatus("Ready.", false);
|
||||
});
|
||||
async function reloadAll() {
|
||||
await ensureRulesets(state, elements);
|
||||
await reloadSession(state);
|
||||
if (state.user) {
|
||||
await reloadCampaigns(state, state.selectedCampaignId);
|
||||
await reloadSelectedCampaign(state);
|
||||
await reloadCampaignLog(state);
|
||||
syncEventStream();
|
||||
}
|
||||
else {
|
||||
resetAuthenticatedState(state);
|
||||
closeStateEvents(state);
|
||||
}
|
||||
renderAll(state, elements);
|
||||
}
|
||||
function syncEventStream() {
|
||||
connectStateEvents(state, () => {
|
||||
void runAppAction(async () => {
|
||||
await reloadSelectedCampaign(state);
|
||||
await reloadCampaignLog(state);
|
||||
renderAll(state, elements);
|
||||
});
|
||||
});
|
||||
}
|
||||
async function runAppAction(action) {
|
||||
await runAction(action, (message) => {
|
||||
setStatus(message, true);
|
||||
});
|
||||
}
|
||||
function setStatus(message, isError) {
|
||||
setMessage(elements.messageElement, message, isError);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
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("");
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
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 +0,0 @@
|
||||
export {};
|
||||
@@ -1,181 +0,0 @@
|
||||
/* This file is generated by scripts/generate-api-client.mjs. */
|
||||
export const apiOperations = {
|
||||
activateCharacter: { method: "POST", path: "/api/characters/{characterId}/activate", expectsJson: true },
|
||||
createCampaign: { method: "POST", path: "/api/campaigns", expectsJson: true },
|
||||
createCharacter: { method: "POST", path: "/api/characters", expectsJson: true },
|
||||
createSkill: { method: "POST", path: "/api/characters/{characterId}/skills", expectsJson: true },
|
||||
getCampaign: { method: "GET", path: "/api/campaigns/{campaignId}", expectsJson: true },
|
||||
getCampaignLog: { method: "GET", path: "/api/campaigns/{campaignId}/log", expectsJson: true },
|
||||
getCampaigns: { method: "GET", path: "/api/campaigns", expectsJson: true },
|
||||
getCurrentCampaignCharacters: { method: "GET", path: "/api/characters/current-campaign", expectsJson: true },
|
||||
getHealth: { method: "GET", path: "/api/health", expectsJson: true },
|
||||
getMe: { method: "GET", path: "/api/me", expectsJson: true },
|
||||
getRulesets: { method: "GET", path: "/api/rulesets", expectsJson: true },
|
||||
loginUser: { method: "POST", path: "/api/auth/login", expectsJson: true },
|
||||
logoutUser: { method: "POST", path: "/api/auth/logout", expectsJson: false },
|
||||
registerUser: { method: "POST", path: "/api/auth/register", expectsJson: true },
|
||||
rollSkill: { method: "POST", path: "/api/skills/{skillId}/roll", expectsJson: true },
|
||||
updateCharacter: { method: "PUT", path: "/api/characters/{characterId}", expectsJson: true },
|
||||
updateSkill: { method: "PUT", path: "/api/skills/{skillId}", expectsJson: true }
|
||||
};
|
||||
function withPathParams(pathTemplate, pathParams) {
|
||||
let pathValue = pathTemplate;
|
||||
for (const [key, value] of Object.entries(pathParams)) {
|
||||
pathValue = pathValue.replace(`{${key}}`, encodeURIComponent(String(value)));
|
||||
}
|
||||
return pathValue;
|
||||
}
|
||||
function withQuery(pathValue, query) {
|
||||
const entries = Object.entries(query).filter(([, value]) => value !== undefined);
|
||||
if (entries.length === 0) {
|
||||
return pathValue;
|
||||
}
|
||||
const queryString = new URLSearchParams(entries.map(([key, value]) => [key, String(value)])).toString();
|
||||
return queryString.length === 0 ? pathValue : `${pathValue}?${queryString}`;
|
||||
}
|
||||
async function send(operation, options) {
|
||||
const resolvedPath = withQuery(withPathParams(operation.path, options.pathParams ?? {}), options.query ?? {});
|
||||
const headers = {
|
||||
"Accept": "application/json"
|
||||
};
|
||||
let body;
|
||||
if (options.body !== undefined) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
body = JSON.stringify(options.body);
|
||||
}
|
||||
const response = await fetch(resolvedPath, {
|
||||
method: operation.method,
|
||||
headers,
|
||||
body
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorPayload = await response.json().catch(() => ({ error: "Unknown API error." }));
|
||||
if (errorPayload && typeof errorPayload === "object" && "error" in errorPayload && typeof errorPayload.error === "string") {
|
||||
throw new Error(errorPayload.error);
|
||||
}
|
||||
throw new Error(`Request failed with status ${response.status}`);
|
||||
}
|
||||
if (!operation.expectsJson) {
|
||||
return undefined;
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
export async function activateCharacter(characterId) {
|
||||
return send(apiOperations.activateCharacter, {
|
||||
pathParams: { characterId: characterId },
|
||||
query: undefined,
|
||||
body: undefined
|
||||
});
|
||||
}
|
||||
export async function createCampaign(body) {
|
||||
return send(apiOperations.createCampaign, {
|
||||
pathParams: {},
|
||||
query: undefined,
|
||||
body: body
|
||||
});
|
||||
}
|
||||
export async function createCharacter(body) {
|
||||
return send(apiOperations.createCharacter, {
|
||||
pathParams: {},
|
||||
query: undefined,
|
||||
body: body
|
||||
});
|
||||
}
|
||||
export async function createSkill(characterId, body) {
|
||||
return send(apiOperations.createSkill, {
|
||||
pathParams: { characterId: characterId },
|
||||
query: undefined,
|
||||
body: body
|
||||
});
|
||||
}
|
||||
export async function getCampaign(campaignId) {
|
||||
return send(apiOperations.getCampaign, {
|
||||
pathParams: { campaignId: campaignId },
|
||||
query: undefined,
|
||||
body: undefined
|
||||
});
|
||||
}
|
||||
export async function getCampaignLog(campaignId) {
|
||||
return send(apiOperations.getCampaignLog, {
|
||||
pathParams: { campaignId: campaignId },
|
||||
query: undefined,
|
||||
body: undefined
|
||||
});
|
||||
}
|
||||
export async function getCampaigns() {
|
||||
return send(apiOperations.getCampaigns, {
|
||||
pathParams: {},
|
||||
query: undefined,
|
||||
body: undefined
|
||||
});
|
||||
}
|
||||
export async function getCurrentCampaignCharacters() {
|
||||
return send(apiOperations.getCurrentCampaignCharacters, {
|
||||
pathParams: {},
|
||||
query: undefined,
|
||||
body: undefined
|
||||
});
|
||||
}
|
||||
export async function getHealth() {
|
||||
return send(apiOperations.getHealth, {
|
||||
pathParams: {},
|
||||
query: undefined,
|
||||
body: undefined
|
||||
});
|
||||
}
|
||||
export async function getMe() {
|
||||
return send(apiOperations.getMe, {
|
||||
pathParams: {},
|
||||
query: undefined,
|
||||
body: undefined
|
||||
});
|
||||
}
|
||||
export async function getRulesets() {
|
||||
return send(apiOperations.getRulesets, {
|
||||
pathParams: {},
|
||||
query: undefined,
|
||||
body: undefined
|
||||
});
|
||||
}
|
||||
export async function loginUser(body) {
|
||||
return send(apiOperations.loginUser, {
|
||||
pathParams: {},
|
||||
query: undefined,
|
||||
body: body
|
||||
});
|
||||
}
|
||||
export async function logoutUser() {
|
||||
return send(apiOperations.logoutUser, {
|
||||
pathParams: {},
|
||||
query: undefined,
|
||||
body: undefined
|
||||
});
|
||||
}
|
||||
export async function registerUser(body) {
|
||||
return send(apiOperations.registerUser, {
|
||||
pathParams: {},
|
||||
query: undefined,
|
||||
body: body
|
||||
});
|
||||
}
|
||||
export async function rollSkill(skillId, body) {
|
||||
return send(apiOperations.rollSkill, {
|
||||
pathParams: { skillId: skillId },
|
||||
query: undefined,
|
||||
body: body
|
||||
});
|
||||
}
|
||||
export async function updateCharacter(characterId, body) {
|
||||
return send(apiOperations.updateCharacter, {
|
||||
pathParams: { characterId: characterId },
|
||||
query: undefined,
|
||||
body: body
|
||||
});
|
||||
}
|
||||
export async function updateSkill(skillId, body) {
|
||||
return send(apiOperations.updateSkill, {
|
||||
pathParams: { skillId: skillId },
|
||||
query: undefined,
|
||||
body: body
|
||||
});
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>RpgRoller</title>
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="layout">
|
||||
<h1>RpgRoller</h1>
|
||||
<p id="health" class="status">Checking API status...</p>
|
||||
<p id="message" class="message"></p>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Authentication</h2>
|
||||
<form id="register-form" class="grid-form">
|
||||
<input id="register-username" type="text" placeholder="Username" required>
|
||||
<input id="register-display-name" type="text" placeholder="Display Name" required>
|
||||
<input id="register-password" type="password" placeholder="Password (min 8 chars)" required>
|
||||
<button type="submit">Register</button>
|
||||
</form>
|
||||
<form id="login-form" class="grid-form">
|
||||
<input id="login-username" type="text" placeholder="Username" required>
|
||||
<input id="login-password" type="password" placeholder="Password" required>
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
<button id="logout-button" type="button">Logout</button>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Campaigns</h2>
|
||||
<form id="campaign-form" class="grid-form">
|
||||
<input id="campaign-name" type="text" placeholder="Campaign name" required>
|
||||
<select id="campaign-ruleset" required></select>
|
||||
<button type="submit">Create Campaign</button>
|
||||
</form>
|
||||
<div class="inline-controls">
|
||||
<select id="campaign-select"></select>
|
||||
<button id="refresh-campaign-button" type="button">Refresh</button>
|
||||
</div>
|
||||
<p id="campaign-meta"></p>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Characters</h2>
|
||||
<form id="character-form" class="grid-form">
|
||||
<input id="character-name" type="text" placeholder="Character name" required>
|
||||
<button type="submit">Create Character</button>
|
||||
</form>
|
||||
<div class="inline-controls">
|
||||
<select id="character-select"></select>
|
||||
<button id="activate-character-button" type="button">Activate Character</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Skills</h2>
|
||||
<form id="skill-form" class="grid-form">
|
||||
<input id="skill-name" type="text" placeholder="Skill name" required>
|
||||
<input id="skill-expression" type="text" placeholder="Dice expression" required>
|
||||
<div class="inline-controls">
|
||||
<button id="create-skill-button" type="submit">Create Skill</button>
|
||||
<button id="update-skill-button" type="button">Update Selected Skill</button>
|
||||
</div>
|
||||
</form>
|
||||
<form id="roll-form" class="grid-form">
|
||||
<select id="skill-select"></select>
|
||||
<select id="roll-visibility">
|
||||
<option value="public">Public</option>
|
||||
<option value="private">Private</option>
|
||||
</select>
|
||||
<button type="submit">Roll Skill</button>
|
||||
</form>
|
||||
<p id="roll-result" class="result">No roll yet.</p>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Campaign Details</h2>
|
||||
<div id="campaign-details"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Campaign Log</h2>
|
||||
<ul id="campaign-log" class="log-list"></ul>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script type="module" src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
192
RpgRoller/wwwroot/js/rpgroller-api.js
Normal file
192
RpgRoller/wwwroot/js/rpgroller-api.js
Normal file
@@ -0,0 +1,192 @@
|
||||
window.rpgRollerApi = (() => {
|
||||
const sessionPrefix = "rpgroller.";
|
||||
const stateStream = {
|
||||
source: null,
|
||||
dotNetRef: null,
|
||||
campaignId: null,
|
||||
reconnectDelayMs: 1000,
|
||||
reconnectTimer: null,
|
||||
stopped: true
|
||||
};
|
||||
|
||||
function clearReconnectTimer() {
|
||||
if (stateStream.reconnectTimer) {
|
||||
clearTimeout(stateStream.reconnectTimer);
|
||||
stateStream.reconnectTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function invokeDotNet(method, ...args) {
|
||||
if (!stateStream.dotNetRef) {
|
||||
return;
|
||||
}
|
||||
|
||||
stateStream.dotNetRef.invokeMethodAsync(method, ...args).catch(() => {
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (stateStream.stopped || stateStream.reconnectTimer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const delay = stateStream.reconnectDelayMs;
|
||||
stateStream.reconnectTimer = setTimeout(() => {
|
||||
stateStream.reconnectTimer = null;
|
||||
if (stateStream.stopped) {
|
||||
return;
|
||||
}
|
||||
|
||||
stateStream.reconnectDelayMs = Math.min(stateStream.reconnectDelayMs * 2, 30000);
|
||||
connectStateStream();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
function connectStateStream() {
|
||||
if (stateStream.stopped || !stateStream.campaignId) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearReconnectTimer();
|
||||
invokeDotNet("OnConnectionStateChanged", "reconnecting");
|
||||
|
||||
const source = new EventSource(`/api/events/state?campaignId=${encodeURIComponent(stateStream.campaignId)}`);
|
||||
stateStream.source = source;
|
||||
|
||||
source.onopen = () => {
|
||||
stateStream.reconnectDelayMs = 1000;
|
||||
invokeDotNet("OnConnectionStateChanged", "connected");
|
||||
};
|
||||
|
||||
source.addEventListener("state", (event) => {
|
||||
try {
|
||||
const payload = JSON.parse(event.data);
|
||||
const version = typeof payload.version === "number" ? payload.version : 0;
|
||||
invokeDotNet("OnStateEventReceived", version);
|
||||
}
|
||||
catch {
|
||||
invokeDotNet("OnStateEventReceived", 0);
|
||||
}
|
||||
});
|
||||
|
||||
source.onerror = () => {
|
||||
if (stateStream.source === source) {
|
||||
source.close();
|
||||
stateStream.source = null;
|
||||
}
|
||||
|
||||
if (stateStream.stopped) {
|
||||
return;
|
||||
}
|
||||
|
||||
invokeDotNet("OnConnectionStateChanged", "reconnecting");
|
||||
scheduleReconnect();
|
||||
};
|
||||
}
|
||||
|
||||
function stopStateEvents() {
|
||||
stateStream.stopped = true;
|
||||
clearReconnectTimer();
|
||||
|
||||
if (stateStream.source) {
|
||||
stateStream.source.close();
|
||||
stateStream.source = null;
|
||||
}
|
||||
|
||||
stateStream.campaignId = null;
|
||||
stateStream.dotNetRef = null;
|
||||
}
|
||||
|
||||
window.addEventListener("offline", () => {
|
||||
invokeDotNet("OnConnectionStateChanged", "offline");
|
||||
});
|
||||
|
||||
window.addEventListener("online", () => {
|
||||
if (!stateStream.stopped) {
|
||||
connectStateStream();
|
||||
}
|
||||
});
|
||||
|
||||
async function request(method, url, body) {
|
||||
const options = {
|
||||
method,
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
Accept: "application/json"
|
||||
}
|
||||
};
|
||||
|
||||
if (body !== null && body !== undefined) {
|
||||
options.headers["Content-Type"] = "application/json";
|
||||
options.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(url, options);
|
||||
}
|
||||
catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 0,
|
||||
error: "Network error. Check your connection and retry."
|
||||
};
|
||||
}
|
||||
|
||||
let parsed = null;
|
||||
const text = await response.text();
|
||||
if (text) {
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
}
|
||||
catch {
|
||||
parsed = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
status: response.status,
|
||||
error: parsed && typeof parsed.error === "string" ? parsed.error : "Request failed."
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
status: response.status,
|
||||
data: parsed
|
||||
};
|
||||
}
|
||||
|
||||
function getSessionValue(key) {
|
||||
return sessionStorage.getItem(`${sessionPrefix}${key}`);
|
||||
}
|
||||
|
||||
function setSessionValue(key, value) {
|
||||
const qualifiedKey = `${sessionPrefix}${key}`;
|
||||
if (value === null || value === undefined || value === "") {
|
||||
sessionStorage.removeItem(qualifiedKey);
|
||||
return;
|
||||
}
|
||||
|
||||
sessionStorage.setItem(qualifiedKey, value);
|
||||
}
|
||||
|
||||
function startStateEvents(campaignId, dotNetRef) {
|
||||
stopStateEvents();
|
||||
stateStream.stopped = false;
|
||||
stateStream.dotNetRef = dotNetRef;
|
||||
stateStream.campaignId = campaignId;
|
||||
stateStream.reconnectDelayMs = 1000;
|
||||
connectStateStream();
|
||||
}
|
||||
|
||||
return {
|
||||
request,
|
||||
getSessionValue,
|
||||
setSessionValue,
|
||||
startStateEvents,
|
||||
stopStateEvents
|
||||
};
|
||||
})();
|
||||
@@ -1,66 +1,353 @@
|
||||
:root {
|
||||
--bg-top: #f7f0d8;
|
||||
--bg-bottom: #ecdfc4;
|
||||
--card: #fffaf0;
|
||||
--card-border: #c3b28b;
|
||||
--text: #2b2418;
|
||||
--muted: #6a5b3f;
|
||||
--accent: #385f3d;
|
||||
--accent-2: #9a402b;
|
||||
--warn: #b4681b;
|
||||
--danger: #8f2323;
|
||||
--focus: #1d3f72;
|
||||
--public: #2d6645;
|
||||
--private-self: #4f3a8f;
|
||||
--private-gm: #915119;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: linear-gradient(165deg, #f2f4f8 0%, #e6ebf5 100%);
|
||||
color: #1f2937;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.layout {
|
||||
max-width: 56rem;
|
||||
body {
|
||||
background:
|
||||
radial-gradient(circle at 15% 10%, rgba(255, 255, 255, 0.32), transparent 45%),
|
||||
linear-gradient(165deg, var(--bg-top), var(--bg-bottom));
|
||||
color: var(--text);
|
||||
font-family: "Trebuchet MS", "Lucida Sans Unicode", "Segoe UI", sans-serif;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
font-family: "Palatino Linotype", "Book Antiqua", Palatino, serif;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.rr-app {
|
||||
max-width: 78rem;
|
||||
margin: 0 auto;
|
||||
padding: 2.5rem 1.25rem;
|
||||
padding: 1rem 1rem 4.5rem;
|
||||
}
|
||||
|
||||
.loading-shell,
|
||||
.auth-shell {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
max-width: 70rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.auth-subtitle {
|
||||
margin-top: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.workspace-shell {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.panel {
|
||||
.workspace-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
border: 1px solid #c8d2e6;
|
||||
border-radius: 0.6rem;
|
||||
padding: 0.9rem;
|
||||
gap: 0.75rem;
|
||||
grid-template-columns: 1.1fr 1.2fr 1fr;
|
||||
background: linear-gradient(120deg, #f1e4c9, #efe0bf);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 0.8rem;
|
||||
padding: 0.85rem;
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.grid-form {
|
||||
.header-group {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.inline-controls {
|
||||
.brand h1 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.brand p,
|
||||
.context p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.controls {
|
||||
justify-items: end;
|
||||
}
|
||||
|
||||
.switch-group,
|
||||
.header-actions,
|
||||
.inline-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
gap: 0.45rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: color-mix(in srgb, var(--card) 94%, #ffffff 6%);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 0.8rem;
|
||||
padding: 0.85rem;
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.auth-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
label {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
button {
|
||||
font: inherit;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border-radius: 0.45rem;
|
||||
border: 1px solid #8e7b57;
|
||||
padding: 0.55rem 0.65rem;
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
background: #fffdf5;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
button {
|
||||
border: 0;
|
||||
border-radius: 0.4rem;
|
||||
background: #2563eb;
|
||||
color: #ffffff;
|
||||
background: linear-gradient(180deg, var(--accent), #2f4f34);
|
||||
color: #f8f7ef;
|
||||
border-color: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.status,
|
||||
.result {
|
||||
font-weight: 600;
|
||||
button.ghost {
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
border-color: #8e7b57;
|
||||
}
|
||||
|
||||
.message {
|
||||
min-height: 1.5rem;
|
||||
color: #1d4ed8;
|
||||
font-weight: 600;
|
||||
button.switch {
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
border-color: #8e7b57;
|
||||
}
|
||||
|
||||
button.switch.active {
|
||||
background: var(--accent-2);
|
||||
border-color: var(--accent-2);
|
||||
color: #fff9ef;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
button:focus-visible,
|
||||
input:focus-visible,
|
||||
select:focus-visible {
|
||||
outline: 3px solid var(--focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.status-message,
|
||||
.form-error,
|
||||
.field-error {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.status-message {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.status-message.success {
|
||||
color: var(--public);
|
||||
}
|
||||
|
||||
.status-message.error,
|
||||
.form-error,
|
||||
.field-error {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.play-screen {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 2fr) minmax(19rem, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.management-screen {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.character-picker {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.icon-tab {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: 0.2rem;
|
||||
min-width: 4.8rem;
|
||||
padding: 0.45rem;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
border-color: #8e7b57;
|
||||
}
|
||||
|
||||
.icon-tab.active {
|
||||
background: linear-gradient(145deg, #e9d4a4, #d7b672);
|
||||
border-color: #9e7328;
|
||||
}
|
||||
|
||||
.icon-tab-glyph {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
border: 1px solid #8e7b57;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.icon-tab-text {
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.character-sheet,
|
||||
.skills-section,
|
||||
.last-roll {
|
||||
border: 1px dashed #a89066;
|
||||
border-radius: 0.65rem;
|
||||
padding: 0.65rem;
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.skill-list {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.skill-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
background: #f8f1df;
|
||||
color: var(--text);
|
||||
border: 1px solid #b39f79;
|
||||
}
|
||||
|
||||
.skill-item.active {
|
||||
border-color: #7b5a1f;
|
||||
background: #ecdfc2;
|
||||
}
|
||||
|
||||
.roll-panel {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.roll-total {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 800;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.roll-dice-strip {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.die-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.1rem;
|
||||
height: 2.1rem;
|
||||
border: 2px solid #2a2418;
|
||||
border-radius: 0.45rem;
|
||||
background: #ffffff;
|
||||
color: #1f1a13;
|
||||
font-size: 1.45rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.die-chip.wild {
|
||||
border-width: 3px;
|
||||
border-color: #c79913;
|
||||
}
|
||||
|
||||
.die-chip.crit {
|
||||
background: #d8ffc2;
|
||||
color: #18490f;
|
||||
}
|
||||
|
||||
.die-chip.fumble {
|
||||
background: #ffb5a8;
|
||||
color: #661110;
|
||||
}
|
||||
|
||||
.die-chip.added {
|
||||
background: #dbffdf;
|
||||
color: #206029;
|
||||
}
|
||||
|
||||
.die-chip.removed {
|
||||
background: #fde0dd;
|
||||
color: #7f5f55;
|
||||
border-style: dashed;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.empty,
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.log-list {
|
||||
@@ -68,11 +355,213 @@ button {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.log-item {
|
||||
padding: 0.45rem 0.55rem;
|
||||
border-radius: 0.35rem;
|
||||
background: #f6f8fc;
|
||||
.log-entry {
|
||||
border: 1px solid #b8a37b;
|
||||
border-radius: 0.55rem;
|
||||
padding: 0.5rem;
|
||||
background: #f8f0de;
|
||||
}
|
||||
|
||||
.log-entry.private-self {
|
||||
border-color: #6252a8;
|
||||
background: #ece7ff;
|
||||
}
|
||||
|
||||
.log-entry.private-gm {
|
||||
border-color: #9a5f1e;
|
||||
background: #fff1db;
|
||||
}
|
||||
|
||||
.log-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
border-radius: 999px;
|
||||
font-size: 0.78rem;
|
||||
padding: 0.18rem 0.45rem;
|
||||
border: 1px solid transparent;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.badge.active {
|
||||
border-color: #8f5f12;
|
||||
background: #f6d28d;
|
||||
color: #5d3808;
|
||||
}
|
||||
|
||||
.badge.public {
|
||||
background: #e1f2e3;
|
||||
color: var(--public);
|
||||
}
|
||||
|
||||
.badge.private-self {
|
||||
background: #e8ddfb;
|
||||
color: var(--private-self);
|
||||
}
|
||||
|
||||
.badge.private-gm {
|
||||
background: #f9e2be;
|
||||
color: var(--private-gm);
|
||||
}
|
||||
|
||||
.badge.private-generic {
|
||||
background: #f4e8d4;
|
||||
color: #6f5832;
|
||||
}
|
||||
|
||||
.connection {
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.connection.ok {
|
||||
color: var(--public);
|
||||
}
|
||||
|
||||
.connection.warn {
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.connection.offline {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.skeleton-stack {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.skeleton-line {
|
||||
height: 0.85rem;
|
||||
border-radius: 0.4rem;
|
||||
background: linear-gradient(90deg, #dfd2b7, #efe3c9, #dfd2b7);
|
||||
background-size: 220% 100%;
|
||||
animation: shimmer 1.1s linear infinite;
|
||||
}
|
||||
|
||||
.skeleton-line.short {
|
||||
width: 65%;
|
||||
}
|
||||
|
||||
.health-banner {
|
||||
border: 1px solid #b77a29;
|
||||
background: #fff2db;
|
||||
border-radius: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.management-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.management-list li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border: 1px solid #b8a37b;
|
||||
border-radius: 0.55rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(35, 25, 9, 0.55);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
z-index: 20;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal-card {
|
||||
width: min(32rem, 100%);
|
||||
background: var(--card);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 0.85rem;
|
||||
padding: 0.9rem;
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.mobile-bottom-nav {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 30;
|
||||
padding: 0.55rem;
|
||||
display: none;
|
||||
gap: 0.45rem;
|
||||
background: rgba(241, 228, 201, 0.96);
|
||||
border-top: 1px solid var(--card-border);
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
border: 0;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
from {
|
||||
background-position: 220% 0;
|
||||
}
|
||||
|
||||
to {
|
||||
background-position: -220% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.workspace-header {
|
||||
position: static;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.play-screen {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.play-screen.mobile-log .character-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.play-screen.mobile-character .log-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.management-screen {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.auth-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.mobile-bottom-nav {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
40
TECH.md
40
TECH.md
@@ -3,20 +3,17 @@
|
||||
## 0) Current scaffold status
|
||||
|
||||
- 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)
|
||||
- Backend/full-stack project: `RpgRoller` (Minimal API + Blazor frontend host)
|
||||
- Frontend source: `RpgRoller/Components/*` + `RpgRoller/wwwroot/*`
|
||||
- Test project: `RpgRoller.Tests` (xUnit + `WebApplicationFactory` integration tests)
|
||||
- Test file split: concern-based API tests (`RpgRoller.Tests/Api/*`), service tests (`RpgRoller.Tests/Services/*`), and shared helpers (`RpgRoller.Tests/Support/*`)
|
||||
- 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 explicit service method parameters 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.
|
||||
- Current backend features: auth/session, campaign/character/skill management (including d6 wild-dice/fumble skill options), ruleset-aware rolls, filtered campaign logs, and SSE state updates.
|
||||
- Current frontend features: Blazor-based authenticated campaign workspace with live log updates, full roll workflow controls, and die-state visualization for roll outcomes.
|
||||
|
||||
## 1) Stack and baseline choices
|
||||
|
||||
@@ -24,8 +21,8 @@
|
||||
- EF Core with SQLite file persistence in current project (single-node deployment).
|
||||
- Game state is hydrated once on startup and then served from in-memory state; writes are persisted back to SQLite after successful mutations.
|
||||
- Cookie authentication (`HttpOnly`, `SameSite=Strict`, secure in production).
|
||||
- A minimal frontend framework supporting mixing 3D graphics with 2D elements, or a modern framework-less alternative (HTMl/CSS/TypeScript).
|
||||
- OpenAPI generated from backend and consumed by generated client.
|
||||
- Blazor frontend host with Razor components and minimal JS interop for browser APIs.
|
||||
- OpenAPI generated from backend as contract documentation.
|
||||
- xUnit integration-heavy test suite with isolated SQLite test databases and coverage gates.
|
||||
|
||||
## 2) Architecture patterns to keep
|
||||
@@ -94,22 +91,10 @@ This pattern is a strong baseline for low to medium scale and should be the defa
|
||||
|
||||
### 2.6 Frontend architecture
|
||||
|
||||
- modules split by concern:
|
||||
- API wrapper
|
||||
- Data loaders
|
||||
- UI composition
|
||||
- Feature-specific renderers/handlers
|
||||
- Shared utils and runtime dependency injection
|
||||
- Single runtime state object with deliberate clear/reset logic.
|
||||
- Refresh scheduler:
|
||||
- Serialized refreshes (no overlap)
|
||||
- Adaptive polling backoff
|
||||
- SSE-triggered immediate refresh for state mutations
|
||||
- Visibility-aware refresh suppression
|
||||
- API client is generated from OpenAPI operation ids, not handwritten endpoints.
|
||||
- Internationalization:
|
||||
- translation file validation at startup
|
||||
- language-specific FAQ markdown loading with fallback to default language
|
||||
- Blazor component tree rooted in `Components/App.razor` and `Components/Pages/Home.razor(.cs)`.
|
||||
- Browser API calls and SSE are handled via `wwwroot/js/rpgroller-api.js` interop.
|
||||
- UI state is maintained server-side per circuit with session/tab persistence for campaign + screen selection.
|
||||
- SSE-driven campaign refresh with reconnect backoff and explicit offline/manual-refresh fallback.
|
||||
|
||||
### 2.7 Testing strategy patterns
|
||||
|
||||
@@ -127,8 +112,7 @@ This pattern is a strong baseline for low to medium scale and should be the defa
|
||||
### 2.8 Tooling and contract discipline
|
||||
|
||||
- OpenAPI generated during build (`openapi/RpgRoller.json`).
|
||||
- Client generated from OpenAPI with required operation-id checks.
|
||||
- Separate lint + format + tests + coverage threshold checks.
|
||||
- Separate build + tests + coverage threshold checks.
|
||||
- Build configured with warnings as errors in CI/local script.
|
||||
|
||||
## 3) Concrete feature set
|
||||
@@ -158,7 +142,6 @@ Use this as a reusable "starter scope menu" for the new app:
|
||||
- trusted proxy/host settings explicit
|
||||
- Contract:
|
||||
- OpenAPI generation enabled in build
|
||||
- generated client wired into frontend
|
||||
- operation-id stability tested
|
||||
- Data integrity:
|
||||
- enforce critical invariants both app-side and DB-side
|
||||
@@ -179,7 +162,6 @@ Keep:
|
||||
- Shared service result abstraction.
|
||||
- Explicit middleware order.
|
||||
- SSE + ETag state sync.
|
||||
- Generated API client from OpenAPI.
|
||||
- DB-enforced invariants.
|
||||
- Regression tests for security-sensitive UI rendering.
|
||||
|
||||
|
||||
4
UX.md
4
UX.md
@@ -250,6 +250,9 @@ Actions:
|
||||
|
||||
- `Create Skill` button -> modal form
|
||||
- `Edit Skill` button -> modal form
|
||||
- d6 skill forms include:
|
||||
- `Wild dice` numeric field
|
||||
- `Allow fumble` toggle
|
||||
- Roll command panel:
|
||||
- Visibility selector (`public`, `private`)
|
||||
- `Roll Skill` primary action
|
||||
@@ -258,6 +261,7 @@ Last roll card:
|
||||
|
||||
- Result total
|
||||
- Breakdown
|
||||
- Die-by-die visualization with states: `critical`, `fumble`, `wild`, `removed`, `added`
|
||||
- Visibility
|
||||
- Timestamp
|
||||
|
||||
|
||||
@@ -893,11 +893,20 @@
|
||||
},
|
||||
"diceRollDefinition": {
|
||||
"type": "string"
|
||||
},
|
||||
"wildDice": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"allowFumble": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"diceRollDefinition"
|
||||
"diceRollDefinition",
|
||||
"wildDice",
|
||||
"allowFumble"
|
||||
]
|
||||
},
|
||||
"UpdateSkillRequest": {
|
||||
@@ -908,11 +917,20 @@
|
||||
},
|
||||
"diceRollDefinition": {
|
||||
"type": "string"
|
||||
},
|
||||
"wildDice": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"allowFumble": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"diceRollDefinition"
|
||||
"diceRollDefinition",
|
||||
"wildDice",
|
||||
"allowFumble"
|
||||
]
|
||||
},
|
||||
"SkillSummary": {
|
||||
@@ -931,13 +949,22 @@
|
||||
},
|
||||
"diceRollDefinition": {
|
||||
"type": "string"
|
||||
},
|
||||
"wildDice": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"allowFumble": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"characterId",
|
||||
"name",
|
||||
"diceRollDefinition"
|
||||
"diceRollDefinition",
|
||||
"wildDice",
|
||||
"allowFumble"
|
||||
]
|
||||
},
|
||||
"RollSkillRequest": {
|
||||
@@ -951,6 +978,38 @@
|
||||
"visibility"
|
||||
]
|
||||
},
|
||||
"RollDieResult": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"roll": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"crit": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"fumble": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"wild": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"removed": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"added": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"roll",
|
||||
"crit",
|
||||
"fumble",
|
||||
"wild",
|
||||
"removed",
|
||||
"added"
|
||||
]
|
||||
},
|
||||
"RollResult": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -984,6 +1043,12 @@
|
||||
"breakdown": {
|
||||
"type": "string"
|
||||
},
|
||||
"dice": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/RollDieResult"
|
||||
}
|
||||
},
|
||||
"timestampUtc": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
@@ -998,6 +1063,7 @@
|
||||
"visibility",
|
||||
"result",
|
||||
"breakdown",
|
||||
"dice",
|
||||
"timestampUtc"
|
||||
]
|
||||
},
|
||||
|
||||
29
package-lock.json
generated
29
package-lock.json
generated
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"name": "rpgroller",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "rpgroller",
|
||||
"version": "0.1.0",
|
||||
"devDependencies": {
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
15
package.json
15
package.json
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"name": "rpgroller",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build:frontend": "tsc -p ./tsconfig.frontend.json",
|
||||
"generate:api-client": "node ./scripts/generate-api-client.mjs && npm run build:frontend",
|
||||
"lint": "tsc -p ./tsconfig.frontend.json --noEmit && node ./scripts/lint-frontend.mjs",
|
||||
"format:check": "node ./scripts/format-check.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
param(
|
||||
[switch]$SkipNpmInstall,
|
||||
[switch]$SkipDotnetRestore,
|
||||
[switch]$SkipBuild
|
||||
)
|
||||
@@ -25,12 +24,6 @@ $repoRoot = Split-Path -Parent $scriptDir
|
||||
|
||||
Push-Location $repoRoot
|
||||
try {
|
||||
if (-not $SkipNpmInstall) {
|
||||
Invoke-Step -Name "Install frontend tooling (npm install)" -Action {
|
||||
npm install
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $SkipDotnetRestore) {
|
||||
Invoke-Step -Name "Restore .NET solution" -Action {
|
||||
dotnet restore RpgRoller.sln
|
||||
@@ -43,18 +36,6 @@ try {
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-Step -Name "Generate frontend API client from OpenAPI" -Action {
|
||||
npm run generate:api-client
|
||||
}
|
||||
|
||||
Invoke-Step -Name "Lint frontend" -Action {
|
||||
npm run lint
|
||||
}
|
||||
|
||||
Invoke-Step -Name "Check frontend formatting" -Action {
|
||||
npm run format:check
|
||||
}
|
||||
|
||||
Invoke-Step -Name "Run tests" -Action {
|
||||
if ($SkipBuild) {
|
||||
dotnet test RpgRoller.Tests/RpgRoller.Tests.csproj --verbosity normal --collect:"XPlat Code Coverage" --settings RpgRoller.Tests/coverlet.runsettings
|
||||
|
||||
@@ -54,91 +54,6 @@ function Resolve-ProfilePath {
|
||||
return [System.IO.Path]::GetFullPath((Join-Path $BaseDirectory $expanded))
|
||||
}
|
||||
|
||||
function Normalize-BasePath {
|
||||
param([string]$Value)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($Value)) {
|
||||
return ""
|
||||
}
|
||||
|
||||
$normalized = $Value.Trim()
|
||||
if (-not $normalized.StartsWith("/")) {
|
||||
$normalized = "/$normalized"
|
||||
}
|
||||
|
||||
if ($normalized.Length -gt 1) {
|
||||
$normalized = $normalized.TrimEnd("/")
|
||||
}
|
||||
|
||||
return $normalized
|
||||
}
|
||||
|
||||
function Infer-BasePathFromRemoteDir {
|
||||
param([string]$RemoteDir)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($RemoteDir)) {
|
||||
return ""
|
||||
}
|
||||
|
||||
$segments = @($RemoteDir -split "[/\\]" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
|
||||
if ($segments.Count -eq 0) {
|
||||
return ""
|
||||
}
|
||||
|
||||
$candidate = $segments[$segments.Count - 1]
|
||||
if ($candidate -in @("httpdocs", "wwwroot", "www", "public_html", "site")) {
|
||||
return ""
|
||||
}
|
||||
|
||||
return Normalize-BasePath $candidate
|
||||
}
|
||||
|
||||
function Resolve-AppBasePath {
|
||||
param([Parameter(Mandatory = $true)][hashtable]$Config)
|
||||
|
||||
if ($Config.ContainsKey("BasePath")) {
|
||||
$configured = Normalize-BasePath ([string]$Config.BasePath)
|
||||
if (-not [string]::IsNullOrWhiteSpace($configured)) {
|
||||
return $configured
|
||||
}
|
||||
}
|
||||
|
||||
return Infer-BasePathFromRemoteDir ([string]$Config.RemoteDir)
|
||||
}
|
||||
|
||||
function Set-FrontendAppBaseMeta {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$PublishDir,
|
||||
[Parameter(Mandatory = $true)][string]$BasePath
|
||||
)
|
||||
|
||||
$candidatePaths = @(
|
||||
(Join-Path $PublishDir "wwwroot\index.html"),
|
||||
(Join-Path $PublishDir "index.html")
|
||||
)
|
||||
|
||||
$indexPath = $candidatePaths | Where-Object { Test-Path $_ } | Select-Object -First 1
|
||||
if ([string]::IsNullOrWhiteSpace($indexPath)) {
|
||||
throw "Publish output is missing index.html. Checked: $($candidatePaths -join ", ")."
|
||||
}
|
||||
|
||||
$pattern = '<meta\s+name=["'']app-base["'']\s+content=["''][^"'']*["'']\s*/?>'
|
||||
$content = Get-Content -Path $indexPath -Raw
|
||||
if ($content -notmatch $pattern) {
|
||||
throw "Could not find <meta name=`"app-base`"> in '$indexPath'."
|
||||
}
|
||||
|
||||
$replacement = "<meta name=`"app-base`" content=`"$BasePath`">"
|
||||
$updated = [System.Text.RegularExpressions.Regex]::Replace(
|
||||
$content,
|
||||
$pattern,
|
||||
[System.Text.RegularExpressions.MatchEvaluator]{ param($match) $replacement },
|
||||
1
|
||||
)
|
||||
|
||||
Set-Content -Path $indexPath -Value $updated -Encoding UTF8
|
||||
}
|
||||
|
||||
function Read-PlainOrPrompt {
|
||||
param(
|
||||
[string]$Value,
|
||||
@@ -259,9 +174,7 @@ if (-not $selfContained) {
|
||||
}
|
||||
dotnet @publishArgs
|
||||
|
||||
$appBasePath = Resolve-AppBasePath -Config $config
|
||||
Set-FrontendAppBaseMeta -PublishDir $publishDir -BasePath $appBasePath
|
||||
Write-Host "2) Frontend app-base configured as '$appBasePath'." -ForegroundColor Cyan
|
||||
Write-Host "2) Skipping legacy index.html app-base rewrite (Blazor frontend)." -ForegroundColor Cyan
|
||||
|
||||
if ($recycleAppPool) {
|
||||
Require-ConfigValue $config "AppPoolName"
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(scriptDirectory, "..");
|
||||
|
||||
const directoriesToScan = [
|
||||
path.join(repoRoot, "RpgRoller", "frontend"),
|
||||
path.join(repoRoot, "RpgRoller", "wwwroot"),
|
||||
path.join(repoRoot, "openapi")
|
||||
];
|
||||
|
||||
const filesToScan = [
|
||||
path.join(repoRoot, "package.json"),
|
||||
path.join(repoRoot, "tsconfig.frontend.json"),
|
||||
path.join(repoRoot, "scripts", "generate-api-client.mjs"),
|
||||
path.join(repoRoot, "scripts", "lint-frontend.mjs"),
|
||||
path.join(repoRoot, "scripts", "format-check.mjs")
|
||||
];
|
||||
|
||||
async function collectFiles(directory) {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
const results = [];
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
const children = await collectFiles(fullPath);
|
||||
results.push(...children);
|
||||
}
|
||||
else {
|
||||
results.push(fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
const allFiles = [...filesToScan];
|
||||
for (const directory of directoriesToScan) {
|
||||
const directoryFiles = await collectFiles(directory);
|
||||
allFiles.push(...directoryFiles);
|
||||
}
|
||||
|
||||
const failures = [];
|
||||
for (const filePath of allFiles) {
|
||||
const text = await readFile(filePath, "utf8");
|
||||
const relativePath = path.relative(repoRoot, filePath);
|
||||
const lines = text.split(/\r?\n/);
|
||||
|
||||
for (let lineNumber = 0; lineNumber < lines.length; lineNumber += 1) {
|
||||
if (/[ \t]+$/.test(lines[lineNumber])) {
|
||||
failures.push(`${relativePath}:${lineNumber + 1} has trailing whitespace.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (text.includes("\t")) {
|
||||
failures.push(`${relativePath} contains tab characters.`);
|
||||
}
|
||||
|
||||
if (text.length > 0 && !text.endsWith("\n") && !text.endsWith("\r\n")) {
|
||||
failures.push(`${relativePath} is missing a trailing newline.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
throw new Error(failures.join("\n"));
|
||||
}
|
||||
|
||||
console.log("Frontend format checks passed.");
|
||||
@@ -1,360 +0,0 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(scriptDirectory, "..");
|
||||
const openApiPath = path.join(repoRoot, "openapi", "RpgRoller.json");
|
||||
const outputPath = path.join(repoRoot, "RpgRoller", "frontend", "generated", "api-client.ts");
|
||||
|
||||
function pascalCase(value) {
|
||||
return value
|
||||
.replace(/[^a-zA-Z0-9]+/g, " ")
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join("");
|
||||
}
|
||||
|
||||
function escapePathSegment(segment) {
|
||||
return segment.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"");
|
||||
}
|
||||
|
||||
function mapSimpleType(schema) {
|
||||
switch (schema?.type) {
|
||||
case "integer":
|
||||
case "number":
|
||||
return "number";
|
||||
case "boolean":
|
||||
return "boolean";
|
||||
case "string":
|
||||
return "string";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function schemaRefName(schema) {
|
||||
if (typeof schema?.$ref !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const refPrefix = "#/components/schemas/";
|
||||
if (!schema.$ref.startsWith(refPrefix)) {
|
||||
throw new Error(`Unsupported schema ref: ${schema.$ref}`);
|
||||
}
|
||||
|
||||
return schema.$ref.substring(refPrefix.length);
|
||||
}
|
||||
|
||||
function toTypeScriptType(schema, forProperty = false) {
|
||||
if (!schema || typeof schema !== "object") {
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
const refName = schemaRefName(schema);
|
||||
if (refName !== null) {
|
||||
return refName;
|
||||
}
|
||||
|
||||
const simpleType = mapSimpleType(schema);
|
||||
if (simpleType !== null) {
|
||||
return simpleType;
|
||||
}
|
||||
|
||||
if (schema.type === "array") {
|
||||
const itemType = toTypeScriptType(schema.items, true);
|
||||
return `Array<${itemType}>`;
|
||||
}
|
||||
|
||||
if (schema.type === "object") {
|
||||
const propertyEntries = Object.entries(schema.properties ?? {});
|
||||
if (propertyEntries.length === 0) {
|
||||
return "Record<string, unknown>";
|
||||
}
|
||||
|
||||
const required = new Set(Array.isArray(schema.required) ? schema.required : []);
|
||||
const fields = propertyEntries.map(([name, propertySchema]) => {
|
||||
const typeName = toTypeScriptType(propertySchema, true);
|
||||
const optional = required.has(name) ? "" : "?";
|
||||
return `${name}${optional}: ${typeName};`;
|
||||
});
|
||||
|
||||
if (forProperty) {
|
||||
return `{ ${fields.join(" ")} }`;
|
||||
}
|
||||
|
||||
return `{\n${fields.map((field) => ` ${field}`).join("\n")}\n}`;
|
||||
}
|
||||
|
||||
if (Array.isArray(schema.oneOf) && schema.oneOf.length > 0) {
|
||||
return schema.oneOf.map((option) => toTypeScriptType(option, true)).join(" | ");
|
||||
}
|
||||
|
||||
if (Array.isArray(schema.anyOf) && schema.anyOf.length > 0) {
|
||||
return schema.anyOf.map((option) => toTypeScriptType(option, true)).join(" | ");
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function schemaToInterface(name, schema) {
|
||||
const required = new Set(Array.isArray(schema.required) ? schema.required : []);
|
||||
const fields = Object.entries(schema.properties ?? {}).map(([propertyName, propertySchema]) => {
|
||||
const typeName = toTypeScriptType(propertySchema, true);
|
||||
const optional = required.has(propertyName) ? "" : "?";
|
||||
return ` ${propertyName}${optional}: ${typeName};`;
|
||||
});
|
||||
|
||||
if (fields.length === 0) {
|
||||
return `export type ${name} = Record<string, unknown>;`;
|
||||
}
|
||||
|
||||
return `export interface ${name} {\n${fields.join("\n")}\n}`;
|
||||
}
|
||||
|
||||
function collectParameters(pathItem, operation) {
|
||||
const rawParameters = [...(pathItem.parameters ?? []), ...(operation.parameters ?? [])];
|
||||
const deduped = [];
|
||||
const keys = new Set();
|
||||
|
||||
for (const parameter of rawParameters) {
|
||||
if (!parameter || typeof parameter !== "object" || typeof parameter.name !== "string") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = `${parameter.in}:${parameter.name}`;
|
||||
if (keys.has(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
keys.add(key);
|
||||
|
||||
const inferredType = mapSimpleType(parameter.schema) ?? "string";
|
||||
deduped.push({
|
||||
name: parameter.name,
|
||||
location: parameter.in,
|
||||
required: parameter.required === true,
|
||||
type: inferredType
|
||||
});
|
||||
}
|
||||
|
||||
return deduped;
|
||||
}
|
||||
|
||||
function resolveRequestBodyType(operation) {
|
||||
const bodySchema = operation.requestBody?.content?.["application/json"]?.schema;
|
||||
if (!bodySchema) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
type: toTypeScriptType(bodySchema, true),
|
||||
required: operation.requestBody.required === true
|
||||
};
|
||||
}
|
||||
|
||||
function resolveResponse(operation) {
|
||||
const responses = operation.responses ?? {};
|
||||
const successCodes = Object.keys(responses)
|
||||
.filter((code) => code.startsWith("2"))
|
||||
.sort();
|
||||
|
||||
if (successCodes.length === 0) {
|
||||
return { type: "void", expectsJson: false };
|
||||
}
|
||||
|
||||
for (const code of successCodes) {
|
||||
const jsonSchema = responses[code]?.content?.["application/json"]?.schema;
|
||||
if (jsonSchema) {
|
||||
return { type: toTypeScriptType(jsonSchema), expectsJson: true };
|
||||
}
|
||||
|
||||
if (code === "204") {
|
||||
return { type: "void", expectsJson: false };
|
||||
}
|
||||
}
|
||||
|
||||
return { type: "void", expectsJson: false };
|
||||
}
|
||||
|
||||
function collectOperations(document) {
|
||||
const operations = [];
|
||||
|
||||
for (const [pathKey, pathItem] of Object.entries(document.paths ?? {})) {
|
||||
for (const [method, operation] of Object.entries(pathItem ?? {})) {
|
||||
if (operation === null || typeof operation !== "object" || typeof operation.operationId !== "string") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (operation.operationId.length === 0) {
|
||||
throw new Error(`Missing operationId for ${method.toUpperCase()} ${pathKey}`);
|
||||
}
|
||||
|
||||
const parameters = collectParameters(pathItem, operation);
|
||||
const pathParameters = parameters.filter((parameter) => parameter.location === "path");
|
||||
const queryParameters = parameters.filter((parameter) => parameter.location === "query");
|
||||
const requestBody = resolveRequestBodyType(operation);
|
||||
const response = resolveResponse(operation);
|
||||
|
||||
operations.push({
|
||||
operationId: operation.operationId,
|
||||
method: method.toUpperCase(),
|
||||
path: pathKey,
|
||||
pathParameters,
|
||||
queryParameters,
|
||||
requestBody,
|
||||
response
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (operations.length === 0) {
|
||||
throw new Error("OpenAPI document does not define any operations.");
|
||||
}
|
||||
|
||||
return operations.sort((left, right) => left.operationId.localeCompare(right.operationId));
|
||||
}
|
||||
|
||||
function buildSchemaTypes(document) {
|
||||
const schemas = document.components?.schemas ?? {};
|
||||
const schemaEntries = Object.entries(schemas).sort(([left], [right]) => left.localeCompare(right));
|
||||
|
||||
const declarations = schemaEntries.map(([name, schema]) => {
|
||||
if (!schema || typeof schema !== "object") {
|
||||
return `export type ${name} = unknown;`;
|
||||
}
|
||||
|
||||
return schemaToInterface(name, schema);
|
||||
});
|
||||
|
||||
return declarations.join("\n\n");
|
||||
}
|
||||
|
||||
function buildFunctionSource(operation) {
|
||||
const pathParameters = operation.pathParameters;
|
||||
const queryParameters = operation.queryParameters;
|
||||
const body = operation.requestBody;
|
||||
|
||||
const parts = [];
|
||||
if (pathParameters.length > 0) {
|
||||
parts.push(...pathParameters.map((parameter) => `${parameter.name}: ${parameter.type}`));
|
||||
}
|
||||
|
||||
if (body) {
|
||||
const optional = body.required ? "" : "?";
|
||||
parts.push(`body${optional}: ${body.type}`);
|
||||
}
|
||||
|
||||
if (queryParameters.length > 0) {
|
||||
const queryType = queryParameters
|
||||
.map((parameter) => `${parameter.name}${parameter.required ? "" : "?"}: ${parameter.type};`)
|
||||
.join(" ");
|
||||
parts.push(`query: { ${queryType} }`);
|
||||
}
|
||||
|
||||
const signature = parts.join(", ");
|
||||
|
||||
const pathParameterAssignment = pathParameters.length === 0
|
||||
? "{}"
|
||||
: `{ ${pathParameters.map((parameter) => `${parameter.name}: ${parameter.name}`).join(", ")} }`;
|
||||
|
||||
const bodyArgument = body ? "body" : "undefined";
|
||||
const queryArgument = queryParameters.length > 0 ? "query" : "undefined";
|
||||
|
||||
return `export async function ${operation.operationId}(${signature}): Promise<${operation.response.type}> {\n return send<${operation.response.type}>(apiOperations.${operation.operationId}, {\n pathParams: ${pathParameterAssignment},\n query: ${queryArgument},\n body: ${bodyArgument}\n });\n}`;
|
||||
}
|
||||
|
||||
function buildClientSource(operations, schemaTypes) {
|
||||
const operationEntries = operations
|
||||
.map((operation) => ` ${operation.operationId}: { method: "${operation.method}", path: "${escapePathSegment(operation.path)}", expectsJson: ${operation.response.expectsJson ? "true" : "false"} }`)
|
||||
.join(",\n");
|
||||
|
||||
const helper = `
|
||||
type ApiOperation = {
|
||||
method: string;
|
||||
path: string;
|
||||
expectsJson: boolean;
|
||||
};
|
||||
|
||||
type RequestOptions = {
|
||||
pathParams?: Record<string, string | number | boolean>;
|
||||
query?: Record<string, string | number | boolean | undefined>;
|
||||
body?: unknown;
|
||||
};
|
||||
|
||||
export const apiOperations = {
|
||||
${operationEntries}
|
||||
} as const satisfies Record<string, ApiOperation>;
|
||||
`.trim();
|
||||
|
||||
const sendFunction = `
|
||||
function withPathParams(pathTemplate: string, pathParams: Record<string, string | number | boolean>): string {
|
||||
let pathValue = pathTemplate;
|
||||
for (const [key, value] of Object.entries(pathParams)) {
|
||||
pathValue = pathValue.replace(\`{\${key}}\`, encodeURIComponent(String(value)));
|
||||
}
|
||||
|
||||
return pathValue;
|
||||
}
|
||||
|
||||
function withQuery(pathValue: string, query: Record<string, string | number | boolean | undefined>): string {
|
||||
const entries = Object.entries(query).filter(([, value]) => value !== undefined);
|
||||
if (entries.length === 0) {
|
||||
return pathValue;
|
||||
}
|
||||
|
||||
const queryString = new URLSearchParams(entries.map(([key, value]) => [key, String(value)])).toString();
|
||||
return queryString.length === 0 ? pathValue : \`\${pathValue}?\${queryString}\`;
|
||||
}
|
||||
|
||||
async function send<TResult>(operation: ApiOperation, options: RequestOptions): Promise<TResult> {
|
||||
const resolvedPath = withQuery(withPathParams(operation.path, options.pathParams ?? {}), options.query ?? {});
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Accept": "application/json"
|
||||
};
|
||||
|
||||
let body: string | undefined;
|
||||
if (options.body !== undefined) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
body = JSON.stringify(options.body);
|
||||
}
|
||||
|
||||
const response = await fetch(resolvedPath, {
|
||||
method: operation.method,
|
||||
headers,
|
||||
body
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorPayload: unknown = await response.json().catch(() => ({ error: "Unknown API error." }));
|
||||
if (errorPayload && typeof errorPayload === "object" && "error" in errorPayload && typeof errorPayload.error === "string") {
|
||||
throw new Error(errorPayload.error);
|
||||
}
|
||||
|
||||
throw new Error(\`Request failed with status \${response.status}\`);
|
||||
}
|
||||
|
||||
if (!operation.expectsJson) {
|
||||
return undefined as TResult;
|
||||
}
|
||||
|
||||
return response.json() as Promise<TResult>;
|
||||
}
|
||||
`.trim();
|
||||
|
||||
const exports = operations.map(buildFunctionSource).join("\n\n");
|
||||
|
||||
return `/* This file is generated by scripts/generate-api-client.mjs. */\n\n${schemaTypes}\n\n${helper}\n\n${sendFunction}\n\n${exports}\n`;
|
||||
}
|
||||
|
||||
const openApiText = await readFile(openApiPath, "utf8");
|
||||
const document = JSON.parse(openApiText);
|
||||
const operations = collectOperations(document);
|
||||
const schemaTypes = buildSchemaTypes(document);
|
||||
const clientSource = buildClientSource(operations, schemaTypes);
|
||||
|
||||
await mkdir(path.dirname(outputPath), { recursive: true });
|
||||
await writeFile(outputPath, clientSource, "utf8");
|
||||
console.log(`Generated API client: ${path.relative(repoRoot, outputPath)}`);
|
||||
@@ -1,41 +0,0 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(scriptDirectory, "..");
|
||||
const openApiPath = path.join(repoRoot, "openapi", "RpgRoller.json");
|
||||
const appTsPath = path.join(repoRoot, "RpgRoller", "frontend", "app.ts");
|
||||
const generatedClientPath = path.join(repoRoot, "RpgRoller", "frontend", "generated", "api-client.ts");
|
||||
|
||||
const openApi = JSON.parse(await readFile(openApiPath, "utf8"));
|
||||
const generatedClient = await readFile(generatedClientPath, "utf8");
|
||||
const appSource = await readFile(appTsPath, "utf8");
|
||||
const errors = [];
|
||||
|
||||
if (!appSource.includes("from \"./generated/api-client.js\"")) {
|
||||
errors.push("Frontend app.ts must import the generated api-client module.");
|
||||
}
|
||||
|
||||
for (const [pathKey, pathItem] of Object.entries(openApi.paths ?? {})) {
|
||||
for (const [method, operation] of Object.entries(pathItem ?? {})) {
|
||||
if (operation === null || typeof operation !== "object") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof operation.operationId !== "string" || operation.operationId.length === 0) {
|
||||
errors.push(`Missing operationId for ${method.toUpperCase()} ${pathKey}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!generatedClient.includes(`apiOperations.${operation.operationId}`)) {
|
||||
errors.push(`Generated client is missing operation export for ${operation.operationId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error(errors.join("\n"));
|
||||
}
|
||||
|
||||
console.log("Frontend lint checks passed.");
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"strict": true,
|
||||
"noEmitOnError": true,
|
||||
"rootDir": "./RpgRoller/frontend",
|
||||
"outDir": "./RpgRoller/wwwroot",
|
||||
"newLine": "lf"
|
||||
},
|
||||
"include": [
|
||||
"RpgRoller/frontend/**/*.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user