301 lines
18 KiB
Markdown
301 lines
18 KiB
Markdown
# RpgRoller
|
|
|
|
RpgRoller is an ASP.NET Core and Blazor Server app for lightweight tabletop campaign play, character sheets, and dice workflows.
|
|
|
|
- `RpgRoller/`: web app, API endpoints, domain model, EF Core persistence, Blazor components, and static assets
|
|
- `RpgRoller.Tests/`: xUnit coverage for API behavior, services, hosting, payload budgets, and persistence and migration paths
|
|
- `RpgRoller.sln`: solution used by local development and repo scripts
|
|
- `POSTMORTEM.md`: architecture analysis of the May 2026 Firefox and RoboForm failure in the authenticated workspace
|
|
- `TASKS.md`: the completed execution log for the route-first authenticated shell rewrite
|
|
|
|
Test layout:
|
|
|
|
- `RpgRoller.Tests/Api/`: endpoint and host-facing integration tests
|
|
- `RpgRoller.Tests/Services/`: service and rules-engine tests
|
|
- `RpgRoller.Tests/Support/`: shared harnesses, builders, and test host helpers
|
|
|
|
## Code Organization
|
|
|
|
Backend:
|
|
|
|
- `RpgRoller/Program.cs`: app bootstrap, JSON options, compression, API and component mapping, and optional `PathBase`
|
|
- `RpgRoller/Hosting/`: service registration, startup initialization, SQLite path resolution, and schema upgrades
|
|
- `RpgRoller/Api/`: minimal API endpoint groups, request mappings, cookie and session helpers, and result mapping
|
|
- `RpgRoller/Services/`: gameplay and account workflows behind `IGameService`
|
|
- `RpgRoller/Services/GameService.cs`: facade over composed domain services
|
|
- `RpgRoller/Services/GameAuthService.cs`: registration, login, logout, session lookup, and `GetMe`
|
|
- `RpgRoller/Services/GameCampaignService.cs`: campaign creation, listing, roster reads, campaign options, and deletion
|
|
- `RpgRoller/Services/GameCharacterService.cs`: character creation, updates, activation, deletion, transfer, and owner-scoped reads
|
|
- `RpgRoller/Services/GameSkillService.cs`: skill-group CRUD, skill CRUD, sheet shaping, and ruleset validation
|
|
- `RpgRoller/Services/GameRollService.cs`: skill and custom rolls, compact log pages, roll detail, and campaign state snapshots
|
|
- `RpgRoller/Services/GameUserAdministrationService.cs`: username reads, admin user listing, role updates, and account deletion
|
|
- `RpgRoller/Services/GameStateStore.cs`, `GameStateCloneFactory.cs`, and `GamePersistenceService.cs`: in-memory runtime state, campaign-state version tracking, and SQLite load and save boundaries
|
|
- `RpgRoller/Services/GameAuthorization.cs`, `GameContextResolver.cs`, and `GameDtoMapper.cs`: shared authorization, session and campaign resolution, and backend read-model mapping
|
|
- `RpgRoller/Services/RollEngine.cs`, `StandardRollEngine.cs`, `D6RollEngine.cs`, `RolemasterRollEngine.cs`, `RollBreakdownFormatter.cs`, and `CampaignLogSummaryBuilder.cs`: ruleset-specific dice execution, breakdown formatting, and compact campaign-log summaries
|
|
- `RpgRoller/Services/SkillDefinitionValidator.cs`, `RoleSerializer.cs`, `RollVisibilityParser.cs`, and `CustomRollOptionsResolver.cs`: shared rules and parsing helpers
|
|
|
|
Frontend:
|
|
|
|
- `RpgRoller/Components/App.razor`: HTML shell that serves the static `/login` auth document or the per-page interactive authenticated route set based on request path
|
|
- `RpgRoller/Components/Routes.razor`: Blazor router and layout hookup
|
|
- `RpgRoller/Components/Layout/MainLayout.razor`: default layout
|
|
- `RpgRoller/Components/Pages/LoginPage.razor`: route marker for the static `/login` auth document
|
|
- `RpgRoller/Components/Pages/PlayPage.razor`, `CampaignsPage.razor`, and `AdminPage.razor`: authenticated route entry points for the interactive workspace
|
|
- `RpgRoller/Components/Pages/AuthenticatedPageBase.cs`: shared logout-to-`/login` redirect helper for authenticated route pages
|
|
- `RpgRoller/Components/Pages/Workspace.razor`: authenticated shell with shared header, health banner, toast stack, and route-owned body slot
|
|
- `RpgRoller/Components/Pages/Workspace.razor.cs`: shell composition root, coordinator wiring, route initialization entry point, JS-invokable state-event hooks, and menu item construction
|
|
- `RpgRoller/Components/Pages/WorkspaceRouteView.razor`: route-local first-render bootstrapper that initializes the interactive workspace after the page mounts
|
|
- `RpgRoller/Components/Pages/PlayWorkspaceContent.razor`, `CampaignsWorkspaceContent.razor`, and `AdminWorkspaceContent.razor`: route-owned authenticated page subtrees
|
|
- `RpgRoller/Components/Pages/CharacterManagementModals.razor`: shared create and edit character modals used by play and campaign-management routes
|
|
- `RpgRoller/Components/Pages/WorkspaceState.cs`: workspace UI state plus pure computed and formatting projections used directly by the Razor view
|
|
- `RpgRoller/Components/Pages/WorkspaceSessionCoordinator.cs`, `WorkspaceCampaignCoordinator.cs`, `WorkspaceCampaignScopeCoordinator.cs`, `WorkspacePlayCoordinator.cs`, `WorkspaceAdminCoordinator.cs`, `WorkspaceLiveStateController.cs`, `WorkspaceFeedbackService.cs`, and `WorkspaceToast.cs`: session bootstrap, campaign scope, play and log, admin, live update, and toast concerns used by `Workspace`
|
|
- `RpgRoller/Components/Pages/HomeControls/StaticAuthPage.razor`: plain HTML login and registration page used at `/login`
|
|
- `RpgRoller/Components/Pages/HomeControls/`: workspace child components, forms, header, panels, and modal controls
|
|
- `RpgRoller/Components/RpgRollerApiClient.cs`: browser API client for write actions
|
|
- `RpgRoller/Components/WorkspaceQueryService.cs`: browser-facing read client for workspace data
|
|
- `RpgRoller/wwwroot/js/rpgroller-api.js`: browser interop for auth forms, session storage, SSE wiring, and DOM helpers
|
|
- `RpgRoller/wwwroot/styles.css`: app styling and responsive layout
|
|
|
|
Current repo note:
|
|
|
|
- `POSTMORTEM.md` documents why the previous authenticated workspace architecture was fragile under Firefox plus RoboForm.
|
|
- `TASKS.md` records the route-first rewrite and the final Blazor configuration change that resolved the Firefox plus RoboForm crash.
|
|
|
|
## Runtime and Persistence
|
|
|
|
- Persistence uses EF Core with SQLite (`Microsoft.EntityFrameworkCore.Sqlite`).
|
|
- The default database file is `RpgRoller/App_Data/rpgroller.db`.
|
|
- `ConnectionStrings__RpgRoller` overrides the SQLite path for local runs, tests, or temporary environments.
|
|
- Startup applies pending EF Core migrations through `Database.Migrate()`.
|
|
- The app loads runtime state into memory during startup and persists successful state changes back to SQLite.
|
|
- `RpgRoller/App_Data/rpgroller.development.db` is a checked-in migration coverage fixture used by hosting tests that copy it to a temporary file before validation.
|
|
|
|
## Product Capabilities
|
|
|
|
- Supported campaign rulesets: D6 System, D&D 5e, and Rolemaster
|
|
- Account registration, login, session-based auth, and role-aware authorization
|
|
- Admin tools for user listing, role updates, account deletion, and direct SQLite database download
|
|
- Campaign creation, roster reads, participant-scoped visibility, and owner and admin deletion
|
|
- Character creation, activation, owner transfer, campaign reassignment or unlinking, and owner and admin deletion
|
|
- Skill groups with reusable defaults plus skill and skill-group create, edit, reassign, and delete flows
|
|
- Play workspace that lists the current user's characters, or the full active campaign roster when the user is that campaign's GM
|
|
- Campaign log paging, lazy-loaded roll detail, compact summaries, and live state refresh through SSE
|
|
- Custom roll submission from the play screen without creating a persisted skill
|
|
- Instant skill filtering in the character panel
|
|
- Campaign management owner labels based on display names
|
|
|
|
Rolemaster support:
|
|
|
|
- Standard expressions such as `d10`, `15d10`, `2d10+48`, and `d100-15`
|
|
- Open-ended percentile expressions such as `d100!+85`
|
|
- Conditional `FumbleRange` handling for open-ended percentile skills and skill-group defaults
|
|
- Persisted and validated automatic retry toggle for open-ended percentile skills; only eligible Rolemaster skills can enable it
|
|
- Rolemaster skill rolls open a modal prompt before rolling so the player can apply a one-shot situational modifier; the prompt autofocuses, supports Enter and Escape, and closes when clicking outside it
|
|
- One-shot situational modifiers are transient Rolemaster-only roll inputs; the temporary modifier is applied to both the first attempt and any automatic retry attempt
|
|
- Automatic retry windows for eligible open-ended skills: results `76-90` retry once with `+5`, and results `91-110` retry once with `+10`
|
|
- Open-ended high chaining and low-end subtraction with ordered die metadata in roll detail
|
|
- Compact log badges and summaries for open-ended, retry, and fumble-related events, including `Retry +5` and `Retry +10`
|
|
|
|
## Current Frontend Architecture
|
|
|
|
The frontend now uses a route-first authenticated shell that keeps the anonymous auth document outside the interactive Blazor subtree.
|
|
|
|
`/` is an auth-aware entry redirect:
|
|
|
|
- anonymous `GET /` redirects to `/login`
|
|
- authenticated `GET /` redirects to `/play`
|
|
- `RpgRoller/Components/App.razor` serves the static `/login` document or the interactive route set based on the request path, not auth state
|
|
|
|
Inside the authenticated app, `/play`, `/campaigns`, and `/admin` are real Blazor routes, and the hamburger menu navigates between those URLs. `Workspace.razor` is now a shared shell only. Each authenticated route owns its own main content subtree through a route-specific component.
|
|
|
|
Authenticated interactivity is route-local instead of global:
|
|
|
|
- `App.razor` no longer applies `@rendermode` to `Routes` or `HeadOutlet`
|
|
- `PlayPage.razor`, `CampaignsPage.razor`, and `AdminPage.razor` each opt into `InteractiveServerRenderMode(prerender: false)` directly
|
|
- Blazor startup is manual with `Blazor.start({ ssr: { disableDomPreservation: true } })` so the app can disable enhanced SSR DOM preservation during interactive attach
|
|
- Header route changes now use full document navigation so moving between authenticated routes remounts the target per-page interactive root instead of trying to reuse the previous page circuit
|
|
|
|
Firefox plus RoboForm resolution:
|
|
|
|
- the route-first rewrite reduced the authenticated surface area, but it was not the final fix
|
|
- the crash stopped only after the app stopped using global Blazor interactivity
|
|
- the working combination is:
|
|
- per-page `InteractiveServerRenderMode(prerender: false)` on `/play`, `/campaigns`, and `/admin`
|
|
- manual `Blazor.start({ ssr: { disableDomPreservation: true } })`
|
|
- full document navigation between authenticated routes with `forceLoad: true`
|
|
- earlier phased first-render shells and heavy diagnostics were investigative steps and have been removed
|
|
|
|
Interactive bootstrap is now route-local:
|
|
|
|
- `WorkspaceRouteView.razor` performs the first-render JS-dependent session initialization for the authenticated route that mounted
|
|
- `Workspace.razor.cs` no longer uses `OnAfterRenderAsync` as the shell bootstrap orchestrator
|
|
- play-specific post-render behavior is limited to page-local controls such as log auto-scroll and modal autofocus inside child components
|
|
|
|
Remaining architectural constraints are deliberate:
|
|
|
|
- `/login` stays plain HTML plus JavaScript so the anonymous auth path avoids Blazor form ownership entirely
|
|
- authenticated reads and writes still depend on JS interop-backed `fetch`, so first interactive initialization must still happen after mount
|
|
- live updates still use SSE and route-aware synchronization, with `/play` as the only route that keeps the play log and selected character sheet live
|
|
|
|
## Route-First Authenticated Shell
|
|
|
|
- `/` becomes an auth-aware entry point that redirects to `/login` or `/play`
|
|
- `/login` hosts the anonymous auth experience
|
|
- `/play`, `/campaigns`, and `/admin` become real authenticated routes
|
|
- the hamburger menu becomes route navigation instead of in-memory screen switching
|
|
- SSE and heavy play bootstrap stay scoped to `/play`
|
|
- the large `Workspace` component is split so each route owns a smaller, more stable subtree
|
|
|
|
This rewrite is complete. See `TASKS.md` for the execution history and milestone notes.
|
|
|
|
## Local Development
|
|
|
|
Prerequisites:
|
|
|
|
- .NET SDK 10.0+
|
|
- Node.js 22+
|
|
- Firefox
|
|
- geckodriver
|
|
|
|
Initial setup:
|
|
|
|
```bash
|
|
dotnet tool restore
|
|
npm ci
|
|
```
|
|
|
|
Run locally:
|
|
|
|
1. Start the app:
|
|
```bash
|
|
dotnet run --project RpgRoller/RpgRoller.csproj
|
|
```
|
|
2. Open `http://localhost:5000` or the URL printed in the console.
|
|
3. Expect `/` to redirect to `/login` when anonymous and to `/play` when a valid session cookie already exists.
|
|
|
|
Browser smoke helpers:
|
|
|
|
- Run the checked-in smoke suite against an isolated temporary SQLite database:
|
|
```bash
|
|
node ./scripts/run-selenium.js
|
|
```
|
|
- Run the Selenium smoke suite directly when the app is already running:
|
|
```bash
|
|
npm run e2e:smoke
|
|
```
|
|
|
|
VS Code launch profiles in `.vscode/launch.json`:
|
|
|
|
- `RpgRoller: Server`
|
|
- `RpgRoller: Server + Edge (F5)`
|
|
- `RpgRoller: Server + Firefox (F5)`
|
|
|
|
## Deployment
|
|
|
|
Deploy to the Linux server with:
|
|
|
|
```bash
|
|
bash ./scripts/deploy.sh
|
|
```
|
|
|
|
The script publishes the app locally, uploads a release to `/root/docker/rpgroller/releases/<UTC timestamp>`, updates `/root/docker/rpgroller/current`, rebuilds the `rpgroller` image, and recreates the `rpgroller` container. The SQLite database is preserved because the container keeps using the existing bind mount at `/root/docker/rpgroller/data`.
|
|
|
|
Reverse proxy requirements for production:
|
|
|
|
- Use `rpgroller.franktovar.de` as the only canonical host.
|
|
- Forward `X-Forwarded-For` and `X-Forwarded-Proto` so ASP.NET Core can mark the session cookie as secure behind TLS termination.
|
|
- Proxy `/_blazor` with WebSocket upgrade headers.
|
|
- Proxy `/api/events/state` as Server-Sent Events with buffering disabled, for example:
|
|
|
|
```nginx
|
|
server {
|
|
server_name rpgroller.franktovar.de;
|
|
|
|
location /_blazor {
|
|
proxy_pass http://127.0.0.1:8082;
|
|
proxy_http_version 1.1;
|
|
proxy_set_header Host $host;
|
|
proxy_set_header X-Real-IP $remote_addr;
|
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
proxy_set_header X-Forwarded-Proto $scheme;
|
|
proxy_set_header Upgrade $http_upgrade;
|
|
proxy_set_header Connection "upgrade";
|
|
proxy_read_timeout 300;
|
|
}
|
|
|
|
location /api/events/state {
|
|
proxy_pass http://127.0.0.1:8082;
|
|
proxy_http_version 1.1;
|
|
proxy_set_header Host $host;
|
|
proxy_set_header X-Real-IP $remote_addr;
|
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
proxy_set_header X-Forwarded-Proto $scheme;
|
|
proxy_buffering off;
|
|
proxy_cache off;
|
|
gzip off;
|
|
proxy_read_timeout 3600;
|
|
add_header X-Accel-Buffering no;
|
|
}
|
|
|
|
location / {
|
|
proxy_pass http://127.0.0.1:8082;
|
|
proxy_http_version 1.1;
|
|
proxy_set_header Host $host;
|
|
proxy_set_header X-Real-IP $remote_addr;
|
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
proxy_set_header X-Forwarded-Proto $scheme;
|
|
proxy_read_timeout 300;
|
|
}
|
|
}
|
|
```
|
|
|
|
Environment overrides:
|
|
|
|
- Set `ConnectionStrings__RpgRoller` to point at a custom SQLite database.
|
|
- Set `PathBase` to host the app under a sub-path such as `/rpgroller`.
|
|
|
|
Migration authoring:
|
|
|
|
```powershell
|
|
dotnet dotnet-ef migrations add <MigrationName> --project RpgRoller/RpgRoller.csproj --startup-project RpgRoller/RpgRoller.csproj
|
|
```
|
|
|
|
SQLite migration rule:
|
|
|
|
- Keep table-rebuild operations separate from unrelated schema or data changes so EF Core does not emit non-transactional migration warnings.
|
|
|
|
## Frontend Runtime
|
|
|
|
- The UI runs as route-local Blazor Server components for authenticated routes and as plain HTML plus JavaScript for the anonymous `/login` document.
|
|
- Static assets are linked through Blazor's `@Assets[...]` pipeline for fingerprinted cache-busting URLs.
|
|
- Workspace reads are resolved through API requests in `WorkspaceQueryService`; browser interop stays focused on auth forms, session storage, SSE wiring, and small DOM helpers.
|
|
- Interactive authenticated startup begins in `WorkspaceRouteView.razor` after first render because `RpgRollerApiClient` still depends on JS interop-backed `fetch`.
|
|
- Authenticated routes avoid global `Routes @rendermode` because upstream issue `dotnet/aspnetcore#58824` reports Firefox-specific failures with global interactivity and explicitly calls out per-page mode as the safer path.
|
|
- Authenticated route changes use full document navigations so each route remounts its own per-page interactive root.
|
|
- Live workspace refresh compares separate roster, per-character sheet, and log versions so unrelated changes do not trigger full reloads.
|
|
- Campaign log data is loaded in bounded slices: campaign summaries, one selected roster, one selected character sheet, and a 25-row incremental log window from `/api/campaigns/{campaignId}/log/page`.
|
|
- Log rows return compact summary data first and lazy-load full detail from `/api/rolls/{rollId}` when expanded.
|
|
- Newly appended local rolls auto-expand in the play workspace and reuse the roll response as the initial detail payload.
|
|
- Custom roll submission uses the selected character context; D6 uses baseline wild-die and fumble behavior, while D&D 5e and Rolemaster use the submitted expression directly.
|
|
- API JSON contracts use the source-generated `RpgRollerJsonSerializerContext`.
|
|
- HTTP JSON responses are gzip-compressed when the client advertises support.
|
|
- The OpenAPI contract source lives at `openapi/RpgRoller.json`.
|
|
|
|
## Test and Coverage
|
|
|
|
- Test command:
|
|
```powershell
|
|
dotnet test RpgRoller.Tests/RpgRoller.Tests.csproj --collect:"XPlat Code Coverage" --settings RpgRoller.Tests/coverlet.runsettings
|
|
```
|
|
- Coverage gate:
|
|
```powershell
|
|
pwsh ./scripts/check-coverage.ps1 -MinLineRate 0.90 -MinBranchRate 0.70
|
|
```
|
|
- Local parity script:
|
|
```powershell
|
|
pwsh ./scripts/ci-local.ps1
|
|
```
|
|
- `scripts/ci-local.ps1` writes coverage collector output to a unique temporary results directory outside the repo, reads coverage from there, removes that directory at the end of the run, and sweeps stray `coverage.cobertura.xml` files from `RpgRoller.Tests/TestResults`.
|
|
- Regression tests enforce payload budgets for character sheet reads, initial and incremental campaign log loads, roll mutation responses, and lazy-loaded Rolemaster roll detail payloads.
|
|
- `RpgRoller.Tests/coverlet.runsettings` measures the full `RpgRoller` backend assembly.
|