4 Commits

56 changed files with 2995 additions and 2613 deletions

31
FAQ.md
View File

@@ -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
View 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.

View File

@@ -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

View File

@@ -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.

View File

@@ -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}");

View 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);
}
}

View File

@@ -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");

View File

@@ -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);

View 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);
}
}

View File

@@ -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())

View File

@@ -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");

View File

@@ -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);
});

View 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>

View File

@@ -0,0 +1,4 @@
@inherits LayoutComponentBase
@attribute [ExcludeFromCodeCoverage]
@Body

View 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>
}

File diff suppressed because it is too large Load Diff

View 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>

View 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

View File

@@ -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(

View File

@@ -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);
});

View File

@@ -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

View File

@@ -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;

View File

@@ -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
};
}

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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);
}

View File

@@ -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("");
}

View File

@@ -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;
}

View File

@@ -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;
};

View File

@@ -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
});
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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);
}

View File

@@ -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("");
}

View File

@@ -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;
}

View File

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

View File

@@ -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
});
}

View File

@@ -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>

View 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
};
})();

View File

@@ -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
View File

@@ -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
View File

@@ -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

View File

@@ -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
View File

@@ -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"
}
}
}
}

View File

@@ -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"
}
}

View File

@@ -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

View File

@@ -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"

View File

@@ -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.");

View File

@@ -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)}`);

View File

@@ -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.");

View File

@@ -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"
]
}