2026-05-04 22:11:20 +02:00
2026-04-03 00:39:42 +02:00
2026-05-04 20:27:16 +02:00
2026-04-26 20:11:58 +02:00
2026-04-26 20:13:56 +02:00
2026-04-04 23:47:34 +02:00
2026-04-05 00:42:49 +02:00
2026-02-24 21:30:22 +01:00

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
  • 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, 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:

dotnet tool restore
npm ci

Run locally:

  1. Start the app:
    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:
    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: Server
  • RpgRoller: Server + Edge (F5)
  • RpgRoller: Server + Firefox (F5)

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:

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:
    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.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.
Description
No description provided
Readme 6.4 MiB
Languages
C# 76.8%
HTML 9.2%
JavaScript 8%
CSS 4%
PowerShell 1.5%
Other 0.5%