16 KiB
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 assetsRpgRoller.Tests/: xUnit coverage for API behavior, services, hosting, payload budgets, and persistence and migration pathsRpgRoller.sln: solution used by local development and repo scriptsPOSTMORTEM.md: architecture analysis of the May 2026 Firefox and RoboForm failure in the authenticated workspaceTASKS.md: the completed execution log for the route-first authenticated shell rewrite
Test layout:
RpgRoller.Tests/Api/: endpoint and host-facing integration testsRpgRoller.Tests/Services/: service and rules-engine testsRpgRoller.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 optionalPathBaseRpgRoller/Hosting/: service registration, startup initialization, SQLite path resolution, and schema upgradesRpgRoller/Api/: minimal API endpoint groups, request mappings, cookie and session helpers, and result mappingRpgRoller/Services/: gameplay and account workflows behindIGameServiceRpgRoller/Services/GameService.cs: facade over composed domain servicesRpgRoller/Services/GameAuthService.cs: registration, login, logout, session lookup, andGetMeRpgRoller/Services/GameCampaignService.cs: campaign creation, listing, roster reads, campaign options, and deletionRpgRoller/Services/GameCharacterService.cs: character creation, updates, activation, deletion, transfer, and owner-scoped readsRpgRoller/Services/GameSkillService.cs: skill-group CRUD, skill CRUD, sheet shaping, and ruleset validationRpgRoller/Services/GameRollService.cs: skill and custom rolls, compact log pages, roll detail, and campaign state snapshotsRpgRoller/Services/GameUserAdministrationService.cs: username reads, admin user listing, role updates, and account deletionRpgRoller/Services/GameStateStore.cs,GameStateCloneFactory.cs, andGamePersistenceService.cs: in-memory runtime state, campaign-state version tracking, and SQLite load and save boundariesRpgRoller/Services/GameAuthorization.cs,GameContextResolver.cs, andGameDtoMapper.cs: shared authorization, session and campaign resolution, and backend read-model mappingRpgRoller/Services/RollEngine.cs,StandardRollEngine.cs,D6RollEngine.cs,RolemasterRollEngine.cs,RollBreakdownFormatter.cs, andCampaignLogSummaryBuilder.cs: ruleset-specific dice execution, breakdown formatting, and compact campaign-log summariesRpgRoller/Services/SkillDefinitionValidator.cs,RoleSerializer.cs,RollVisibilityParser.cs, andCustomRollOptionsResolver.cs: shared rules and parsing helpers
Frontend:
RpgRoller/Components/App.razor: HTML shell that serves the static/loginauth document or the per-page interactive authenticated route set based on request pathRpgRoller/Components/Routes.razor: Blazor router and layout hookupRpgRoller/Components/Layout/MainLayout.razor: default layoutRpgRoller/Components/Pages/LoginPage.razor: route marker for the static/loginauth documentRpgRoller/Components/Pages/PlayPage.razor,CampaignsPage.razor, andAdminPage.razor: authenticated route entry points for the interactive workspaceRpgRoller/Components/Pages/AuthenticatedPageBase.cs: shared logout-to-/loginredirect helper for authenticated route pagesRpgRoller/Components/Pages/Workspace.razor: authenticated shell with shared header, health banner, toast stack, and route-owned body slotRpgRoller/Components/Pages/Workspace.razor.cs: shell composition root, coordinator wiring, route initialization entry point, JS-invokable state-event hooks, and menu item constructionRpgRoller/Components/Pages/WorkspaceRouteView.razor: route-local first-render bootstrapper that initializes the interactive workspace after the page mountsRpgRoller/Components/Pages/PlayWorkspaceContent.razor,CampaignsWorkspaceContent.razor, andAdminWorkspaceContent.razor: route-owned authenticated page subtreesRpgRoller/Components/Pages/CharacterManagementModals.razor: shared create and edit character modals used by play and campaign-management routesRpgRoller/Components/Pages/WorkspaceState.cs: workspace UI state plus pure computed and formatting projections used directly by the Razor viewRpgRoller/Components/Pages/WorkspaceSessionCoordinator.cs,WorkspaceCampaignCoordinator.cs,WorkspaceCampaignScopeCoordinator.cs,WorkspacePlayCoordinator.cs,WorkspaceAdminCoordinator.cs,WorkspaceLiveStateController.cs,WorkspaceFeedbackService.cs, andWorkspaceToast.cs: session bootstrap, campaign scope, play and log, admin, live update, and toast concerns used byWorkspaceRpgRoller/Components/Pages/HomeControls/StaticAuthPage.razor: plain HTML login and registration page used at/loginRpgRoller/Components/Pages/HomeControls/: workspace child components, forms, header, panels, and modal controlsRpgRoller/Components/RpgRollerApiClient.cs: browser API client for write actionsRpgRoller/Components/WorkspaceQueryService.cs: browser-facing read client for workspace dataRpgRoller/wwwroot/js/rpgroller-api.js: browser interop for auth forms, session storage, SSE wiring, and DOM helpersRpgRoller/wwwroot/styles.css: app styling and responsive layout
Current repo note:
POSTMORTEM.mddocuments why the previous authenticated workspace architecture was fragile under Firefox plus RoboForm.TASKS.mdrecords the route-first rewrite that addressed that architecture.
Runtime and Persistence
- Persistence uses EF Core with SQLite (
Microsoft.EntityFrameworkCore.Sqlite). - The default database file is
RpgRoller/App_Data/rpgroller.db. ConnectionStrings__RpgRolleroverrides 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.dbis 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
- Owner-scoped play workspace that lists only the current user's characters while preserving GM and admin management capabilities
- 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, andd100-15 - Open-ended percentile expressions such as
d100!+85 - Conditional
FumbleRangehandling 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-90retry once with+5, and results91-110retry 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 +5andRetry +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.razorserves the static/logindocument 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.razorno longer applies@rendermodetoRoutesorHeadOutletPlayPage.razor,CampaignsPage.razor, andAdminPage.razoreach opt intoInteractiveServerRenderMode(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
Interactive bootstrap is now route-local:
WorkspaceRouteView.razorperforms the first-render JS-dependent session initialization for the authenticated route that mountedWorkspace.razor.csno longer usesOnAfterRenderAsyncas 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:
/loginstays 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
/playas 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/loginor/play/loginhosts the anonymous auth experience/play,/campaigns, and/adminbecome 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
Workspacecomponent 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:
dotnet tool restore
npm ci
Run locally:
- Start the app:
dotnet run --project RpgRoller/RpgRoller.csproj - Open
http://localhost:5000or the URL printed in the console. - Expect
/to redirect to/loginwhen anonymous and to/playwhen a valid session cookie already exists.
Browser smoke helpers:
- Run the checked-in smoke suite against an isolated temporary SQLite database:
node ./scripts/run-selenium.js - Run the Selenium smoke suite directly when the app is already running:
npm run e2e:smoke
VS Code launch profiles in .vscode/launch.json:
RpgRoller: ServerRpgRoller: Server + Edge (F5)RpgRoller: Server + Firefox (F5)
Environment overrides:
- Set
ConnectionStrings__RpgRollerto point at a custom SQLite database. - Set
PathBaseto host the app under a sub-path such as/rpgroller.
Migration authoring:
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
/logindocument. - 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.razorafter first render becauseRpgRollerApiClientstill depends on JS interop-backedfetch. - Workspace startup diagnostics now log route initialization, route-content render phases, and browser-side workspace mutation snapshots to help isolate the remaining Firefox startup crash documented in
POSTMORTEM.md. - Pre-Blazor diagnostics also watch the static
#rr-interactive-hostcontainer before_framework/blazor.web.jsconnects, so extension-driven DOM mutations can be compared against the first failing interactive batch. - Authenticated routes now avoid global
Routes @rendermodebecause upstream issuedotnet/aspnetcore#58824reports Firefox-specific failures with global interactivity and explicitly calls out per-page mode as the safer path. - Authenticated routes now mount through phased interactive batches: minimal shell first, then a simple header placeholder, then route skeletons, and only then the real header and control-heavy route content after route initialization succeeds.
- 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:
dotnet test RpgRoller.Tests/RpgRoller.Tests.csproj --collect:"XPlat Code Coverage" --settings RpgRoller.Tests/coverlet.runsettings - Coverage gate:
pwsh ./scripts/check-coverage.ps1 -MinLineRate 0.90 -MinBranchRate 0.70 - Local parity script:
pwsh ./scripts/ci-local.ps1 scripts/ci-local.ps1writes 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 straycoverage.cobertura.xmlfiles fromRpgRoller.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.runsettingsmeasures the fullRpgRollerbackend assembly.