25 Commits

Author SHA1 Message Date
ba9536de12 Fix campaigns rerender after mutations 2026-05-05 01:10:04 +02:00
777befdbf0 fix: sync custom roll visibility 2026-05-05 00:56:41 +02:00
6b18051073 fix: restore visibility select behavior 2026-05-05 00:48:15 +02:00
f8b09be399 fix: unify custom roll visibility 2026-05-05 00:44:54 +02:00
f01d100740 fix: unify workspace header layout 2026-05-05 00:25:58 +02:00
c427e717d5 refactor: remove crash workaround scaffolding 2026-05-05 00:20:37 +02:00
c628957163 fix: use per-page blazor startup 2026-05-04 23:58:26 +02:00
56e0ec1e79 fix: phase authenticated startup 2026-05-04 23:02:39 +02:00
f86ac43153 chore: add pre-blazor crash diagnostics 2026-05-04 22:53:14 +02:00
e60b4b5867 chore: add workspace crash diagnostics 2026-05-04 22:43:57 +02:00
a69c6284d7 fix: stabilize route startup render 2026-05-04 22:27:14 +02:00
12612e05fa fix: scope startup by route 2026-05-04 22:11:20 +02:00
73dc4a9cd4 refactor: finish route-first shell 2026-05-04 21:58:22 +02:00
9c3f7c039e refactor: split workspace routes 2026-05-04 21:45:44 +02:00
def2a3f680 Implement milestone 2 route navigation 2026-05-04 21:23:45 +02:00
c13a2ce7c7 Replace Playwright smoke tests with Selenium 2026-05-04 20:54:10 +02:00
b97437fda3 updated agents 2026-05-04 20:27:16 +02:00
b9fba1bbbc Refactor frontend entry to login and play routes 2026-05-04 20:23:53 +02:00
a7f6163c4b Overhaul frontend rewrite documentation 2026-05-04 20:04:40 +02:00
8d08b857ab Add workspace compatibility postmortem 2026-05-04 19:26:07 +02:00
e0b7d27ba7 Stage workspace controls after bootstrap 2026-05-04 19:03:47 +02:00
da813583bd Delay workspace render until session init completes 2026-05-04 18:12:10 +02:00
231b0ac9a0 Remove workspace session-token coupling 2026-05-03 00:08:47 +02:00
1f19bf7bfd Restore workspace prerender and auth errors 2026-05-02 23:53:40 +02:00
2d2ed561cc Isolate anonymous auth page from Blazor 2026-05-02 23:31:49 +02:00
61 changed files with 3659 additions and 1580 deletions

View File

@@ -4,7 +4,7 @@ Also see the other related technical documentation in the docs folder.
## Tools
These tools are installed and available: Python3, MiKTeX, Tesseract, Playwright
These tools are installed and available: Python3, geckodriver, Selenium
## Rules
@@ -16,9 +16,9 @@ These tools are installed and available: Python3, MiKTeX, Tesseract, Playwright
- If there's documnentation present, always keep it updated.
- After every iteration, evaluate if the test coverage would fall below 100%, and write tests if necessary.
- After every iteration, run `dotnet jb cleanupcode --build=False '$file1' '$file2' ...` for every C# file you touched.
- After every frontend change, verify the results using an ephemeral Playwright run.
- For ad hoc verification in this repo, do not default to `npx playwright test` with a temp spec outside the repo.
- After every frontend change, verify the results using a geckodriver+Selenium run.
- When browser verification needs the app running, launch the app against a temporary copy of `src\RolemasterDb.App\rolemaster.db` so verification does not mutate the canonical DB.
### Git
- Never change the .gitignore file without consent.

419
POSTMORTEM.md Normal file
View File

@@ -0,0 +1,419 @@
# POSTMORTEM
## Executive Summary
RpgRoller failed in Firefox with RoboForm enabled because the authenticated workspace was built as a highly reactive Blazor Server surface that performs several structural rerenders immediately after login while assuming stable ownership of the rendered DOM.
RoboForm was the trigger, not the root cause.
The root cause was architectural:
- the app mixed static HTML auth, interactive Blazor Server UI, browser-managed session state, JavaScript fetch calls, and SSE live updates into one startup path
- the authenticated workspace bootstrap was driven from `OnAfterRenderAsync` and intentionally caused follow-up renders
- the root shell in [App.razor](/home/frank/Code/RpgRoller/RpgRoller/Components/App.razor) still branches on request-time `HttpContext` and session-cookie state
- the workspace render tree is large, form-heavy, and sensitive to DOM mutation during early batches
That combination made the app fragile under browser extensions that legitimately modify login and form-related DOM.
## Incident Summary
### User-visible symptoms
- Firefox in a normal profile crashed the Blazor circuit immediately after or around login
- the browser console reported:
- `Error: There was an error applying batch ...`
- `TypeError: can't access property "insertBefore", n.parentNode is null`
- the server logged `RemoteRenderer[100]` and terminated the circuit
- the UI often degraded into `Loading user...`, `Offline fallback`, or a partially rendered play screen before failing
### Scope
- The failure reproduced only in a normal Firefox profile with RoboForm enabled
- The failure did not reproduce in a private Firefox window
- The failure did not reproduce after disabling RoboForm
- The failure was not tied to a specific username or database row
### Trigger vs. Root Cause
Trigger:
- RoboForm mutated form-related DOM in the page
Root cause:
- the app architecture depended on Blazor Server retaining stable ownership of a DOM subtree that was undergoing immediate, multi-batch structural changes during startup
## Current Architecture
### Root shell
The application entry point is [Program.cs](/home/frank/Code/RpgRoller/RpgRoller/Program.cs):
- `AddRazorComponents().AddInteractiveServerComponents()`
- `MapRazorComponents<App>().AddInteractiveServerRenderMode()`
- `AddScoped<RpgRollerApiClient>()`
- `AddScoped<WorkspaceQueryService>()`
The root component is [App.razor](/home/frank/Code/RpgRoller/RpgRoller/Components/App.razor). It does two different things at `/`:
- if the incoming request has no valid session cookie, it renders [StaticAuthPage.razor](/home/frank/Code/RpgRoller/RpgRoller/Components/Pages/HomeControls/StaticAuthPage.razor) as plain HTML
- otherwise it boots the Blazor app with `<Routes @rendermode="InteractiveServer(prerender: false)" />`
This decision is made from request-time state:
- `HttpContext`
- request path
- session cookie
- `IGameService.GetUserBySession`
### Auth flow
The auth page is no longer a Blazor form.
[StaticAuthPage.razor](/home/frank/Code/RpgRoller/RpgRoller/Components/Pages/HomeControls/StaticAuthPage.razor) renders plain HTML forms, and [rpgroller-api.js](/home/frank/Code/RpgRoller/RpgRoller/wwwroot/js/rpgroller-api.js) binds submit handlers that:
- validate in JS
- call `/api/auth/register` or `/api/auth/login` via `fetch`
- on login, force a full `window.location.assign("/")`
This means the app has one browser experience before login and a different ownership model after login.
### Authenticated workspace
The authenticated `/` route is [Home.razor](/home/frank/Code/RpgRoller/RpgRoller/Components/Pages/Home.razor), which only renders `<Workspace LoggedOut="OnLoggedOutAsync" />`.
The real composition root is [Workspace.razor.cs](/home/frank/Code/RpgRoller/RpgRoller/Components/Pages/Workspace.razor.cs). It wires:
- `WorkspaceSessionCoordinator`
- `WorkspaceCampaignScopeCoordinator`
- `WorkspaceCampaignCoordinator`
- `WorkspacePlayCoordinator`
- `WorkspaceAdminCoordinator`
- `WorkspaceLiveStateController`
- `WorkspaceFeedbackService`
The current startup path is driven by `OnAfterRenderAsync`:
1. first interactive render occurs
2. `Session.InitializeAsync()` runs
3. `StateHasChanged()` is invoked
4. later renders enable more controls such as the character controls and custom roll composer
### State channels
The workspace state is not owned by one subsystem. It is spread across four channels:
1. Blazor component state in [WorkspaceState.cs](/home/frank/Code/RpgRoller/RpgRoller/Components/Pages/WorkspaceState.cs)
2. browser `sessionStorage` via `rpgroller-api.js`
3. browser `fetch` requests through [RpgRollerApiClient.cs](/home/frank/Code/RpgRoller/RpgRoller/Components/RpgRollerApiClient.cs) and [WorkspaceQueryService.cs](/home/frank/Code/RpgRoller/RpgRoller/Components/WorkspaceQueryService.cs)
4. SSE live updates from `/api/events/state` via [StateEventEndpoints.cs](/home/frank/Code/RpgRoller/RpgRoller/Api/StateEventEndpoints.cs)
### Backend state model
The backend is not stateless HTTP over a database. [GameService.cs](/home/frank/Code/RpgRoller/RpgRoller/Services/GameService.cs) builds an in-memory runtime state from SQLite at startup using [GameStateStore.cs](/home/frank/Code/RpgRoller/RpgRoller/Services/GameStateStore.cs), then serves reads and writes against that state.
This matters because the frontend already has multiple live state concepts:
- the Blazor circuit
- the JS app state
- the SSE stream
- the server-side runtime store
That complexity is manageable only if the UI ownership boundaries stay clean. They did not.
## Architectural Timeline
### February 25, 2026
Blazor was introduced as the frontend host:
- `a8ee637` `Scaffold Blazor frontend host and root components`
- `35c60c4` `Replace frontend with Blazor UX implementation`
This established Blazor Server as the UI owner.
### February 26 to April 5, 2026
The workspace became denser and more interactive:
- `c3aa0d4` `Overhaul workspace UX for denser play workflow`
- `bf3a6fa` `Persist roll visibility preference across workspace reloads`
- `54aabc6` `Unify play management and admin screens in workspace`
- `6ea91ee` `Add targeted workspace live refresh`
- `e42c0fb` `Load campaign logs incrementally`
- `9e6e6fe` `Add custom campaign roll composer`
- `4af1c87` through `b291d05` extracted coordinators and simplified the composition root
This refactor improved code organization, but it also increased the number of reactive moving parts:
- more persistent UI state in `sessionStorage`
- more conditional screen branching inside one workspace root
- more input-heavy controls on the default play screen
- a live SSE side channel that can trigger refreshes after startup
### May 2 to May 4, 2026
This period contains direct evidence of mitigation attempts after the Firefox failure surfaced:
- `2d2ed56` `Isolate anonymous auth page from Blazor`
- `1f19bf7` `Restore workspace prerender and auth errors`
- `231b0ac` `Remove workspace session-token coupling`
- `da81358` `Delay workspace render until session init completes`
- `e0b7d27` `Stage workspace controls after bootstrap`
These commits are valuable evidence because they show the app was being repaired at the symptom boundary:
- first by removing auth from Blazor ownership
- then by changing prerender behavior
- then by removing `HttpContext`-captured session coupling from workspace queries
- then by staging workspace startup
None of those changes removed the underlying architectural fragility: a Blazor Server workspace that still reshapes a large, extension-visible DOM over several early render batches.
## Root Causes
### 1. DOM ownership was not treated as a hard architectural boundary
The app used Blazor Server for a form-heavy authenticated workspace, while also operating in a browser environment where password managers are expected to inject or wrap form controls.
In principle that can work, but only when the rendered DOM is stable enough that third-party mutation does not race against structural rerenders.
RpgRoller violated that assumption:
- immediate post-login renders reshaped the workspace
- input-bearing controls were mounted during startup
- later state syncs continued changing the same subtree
That made the DOM ownership contract weak.
### 2. Startup was centered on `OnAfterRenderAsync` instead of a stable initial model
[Workspace.razor.cs](/home/frank/Code/RpgRoller/RpgRoller/Components/Pages/Workspace.razor.cs) drives initialization from `OnAfterRenderAsync`, then explicitly schedules more rerenders.
That has two consequences:
- the first visible authenticated frame is not the final intended frame
- the renderer must apply several batches while the browser is already free to run extensions against the DOM
This is a poor fit for DOM-mutating extensions.
### 3. The root shell still depends on request-time `HttpContext`
[App.razor](/home/frank/Code/RpgRoller/RpgRoller/Components/App.razor) still uses `HttpContext` and the session cookie to decide whether to render:
- static auth HTML
- or the interactive app
Even after removing the old workspace session-token accessor, the root shell still relies on request-only state to choose the subtree for `/`.
This is a fragile architectural seam because the app is half request-rendered and half interactive, with the split encoded in the component tree itself.
### 4. Too many reactive state channels were active during the same startup window
During or shortly after login, the workspace may react to:
- `sessionStorage` reads
- API reads through `fetch`
- Blazor rerenders from `StateHasChanged`
- SSE connection state transitions
- SSE state events
That is too much coordination for a large render tree if DOM stability is required.
### 5. The workspace root remained too large and too structurally dynamic
[Workspace.razor](/home/frank/Code/RpgRoller/RpgRoller/Components/Pages/Workspace.razor) controls:
- header
- play screen
- campaign management
- admin screen
- toasts
- character modals
- Rolemaster modal
The play screen itself contains multiple conditional branches and input surfaces.
Even though code-behind files and coordinators improved organization, the rendered root still has a large rerender blast radius.
### 6. Documentation drift hid the architecture change
The current [README.md](/home/frank/Code/RpgRoller/README.md) still describes:
- `Home.razor` as a gateway that switches between loading, auth, and authenticated workspace views
- `WorkspaceQueryService` as “server-side read model access”
Neither description matches the current code:
- `Home.razor` now just renders `Workspace`
- `App.razor` became the real gateway
- `WorkspaceQueryService` now calls browser `fetch` through `RpgRollerApiClient`
This kind of drift usually means the architecture has moved faster than the design was reevaluated.
## Why the Failure Was Hard to Fix Incrementally
The failure was not caused by one broken line. It emerged from several acceptable local decisions that interacted badly:
- static auth was added to avoid Blazor auth-page failures
- workspace prerender behavior was changed to satisfy session bootstrap
- direct server-side workspace reads were removed to avoid `HttpContext` coupling
- render staging was added to reduce early DOM churn
Each change improved one seam while leaving the overall architecture intact.
That is why the issue kept moving:
- first the auth page crashed
- then login worked but workspace bootstrap stalled
- then the play view partially rendered but later crashed
The architecture allowed the failure to migrate between phases instead of disappearing.
## Evidence From Recent Fix Attempts
### `2d2ed56` on May 2, 2026
`Isolate anonymous auth page from Blazor`
What it changed:
- added `StaticAuthPage.razor`
- moved login/register handling into `rpgroller-api.js`
- changed `App.razor` to serve static auth HTML when unauthenticated
What it revealed:
- removing Blazor from the auth page improved the anonymous path
- the underlying crash still existed in the authenticated workspace path
### `1f19bf7` on May 2, 2026
`Restore workspace prerender and auth errors`
What it changed:
- adjusted render mode behavior again
- kept better auth error reporting
What it revealed:
- workspace startup was still entangled with earlier render-mode decisions
- the app was using render-mode changes as a corrective mechanism rather than as a stable architecture choice
### `231b0ac` on May 3, 2026
`Remove workspace session-token coupling`
What it changed:
- deleted `WorkspaceSessionTokenAccessor`
- changed `WorkspaceQueryService` to use API calls instead of direct service access
What it revealed:
- the previous architecture had leaked request-time session access into interactive workspace startup
- removing that coupling was necessary, but not sufficient, because the DOM ownership problem remained
### `da81358` on May 4, 2026
`Delay workspace render until session init completes`
What it changed:
- replaced the early workspace UI with a loading shell
What it revealed:
- broad render suppression was too blunt
- it masked, rather than removed, the actual failing rerender path
### `e0b7d27` on May 4, 2026
`Stage workspace controls after bootstrap`
What it changed:
- restored the base workspace
- deferred some input-heavy controls to later batches
What it revealed:
- the crash moved later in startup
- the base play view could survive, but later structural updates still failed
Taken together, these commits are evidence that the app was being pushed toward compatibility through localized mitigations, while the larger architecture still tolerated unstable startup ownership.
## What Actually Failed
The practical failure mode was:
1. login succeeded
2. the authenticated workspace circuit started
3. early render batches built or reshaped a large DOM subtree
4. RoboForm touched form-related DOM inside that subtree
5. Blazor attempted to apply a later batch using DOM assumptions that were no longer true
6. the browser-side batch apply failed with `insertBefore ... parentNode is null`
7. the server terminated the circuit
The `Offline fallback` label was mostly a consequence:
- once the circuit failed, live-state coordination could not complete cleanly
- the connection-state UI then reflected that degraded state
## Findings
### Primary finding
The authenticated workspace should not have been architected as a multi-batch, structurally dynamic, form-heavy startup surface if compatibility with password managers and other DOM-mutating extensions is a requirement.
### Secondary findings
- `App.razor` became a hidden architecture boundary without being treated as one
- the workspace composition root is still too structurally broad
- frontend ownership is split between Blazor and handwritten JS in a way that complicates startup reasoning
- live updates were added as another reactivity source before the UI ownership model was made robust
- documentation no longer described the actual architecture, making corrective design work harder
## Remediation Directions
These are architectural directions, not the implementation plan.
### 1. Choose a single ownership model for the authenticated shell
The authenticated shell should not be partly “request-decided” and partly “interactive-decided” in a component tree that still relies on request-time state.
### 2. Stop using `OnAfterRenderAsync` as the main workspace bootstrap orchestrator
The authenticated workspace needs a stable initial render contract with fewer structural follow-up diffs.
### 3. Reduce startup-state multiplicity
The startup path should not require simultaneous coordination across:
- Blazor state
- `sessionStorage`
- `fetch`
- SSE
at least not before the UI is stable.
### 4. Shrink the render blast radius
The workspace root should own less structural branching. The more isolated the screen and control subtrees are, the less likely a third-party DOM mutation is to invalidate a broad diff.
### 5. Treat extension compatibility as a design requirement
Password managers are not an edge case for login and form-driven applications. The UI architecture must assume that form controls can be wrapped, annotated, or moved by browser software.
### 6. Realign documentation with the code
The design notes and README need to describe the actual architecture before a stable fix plan is made. Otherwise future changes will continue to optimize around an outdated mental model.
## Conclusion
RpgRoller did not fail because RoboForm existed. It failed because the apps frontend architecture evolved into a shape where the authenticated workspace depended on fragile early render batches, mixed ownership boundaries, and multiple overlapping state channels.
RoboForm exposed that weakness reliably.
The correct next step is not another isolated workaround. The correct next step is to redesign the authenticated shell and workspace startup path around stable DOM ownership and simpler state flow.

133
README.md
View File

@@ -1,10 +1,12 @@
# RpgRoller
RpgRoller is an ASP.NET Core + Blazor Server app for lightweight tabletop campaign play, character sheets, and dice workflows.
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/migration paths
- `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:
@@ -16,41 +18,48 @@ Test layout:
Backend:
- `RpgRoller/Program.cs`: app bootstrap, JSON options, compression, API/component mapping, and optional `PathBase`
- `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/session helpers, and result mapping
- `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/custom rolls, compact log pages, roll detail, and campaign state snapshots
- `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/save boundaries
- `RpgRoller/Services/GameAuthorization.cs`, `GameContextResolver.cs`, and `GameDtoMapper.cs`: shared authorization, session/campaign resolution, and backend read-model mapping
- `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/`: Blazor app shell, routes, layout, page components, and query/client helpers
- `RpgRoller/Components/Pages/Home.razor`: gateway that switches between loading, auth, and authenticated workspace views, and force-reloads after login so the authenticated play workspace is built from the fresh session cookie
- `RpgRoller/Components/Pages/Home.razor.cs`: gateway/session orchestration for `Home`
- `RpgRoller/Components/Pages/Workspace.razor`: authenticated workspace UI
- `RpgRoller/Components/Pages/Workspace.razor.cs`: workspace composition root, coordinator wiring, lifecycle, and JS-invokable entry points
- `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/log, admin, live update, and toast concerns used by `Workspace`
- `RpgRoller/Components/Pages/HomeControls/`: workspace and auth child components, forms, header, panels, and modal controls
- `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`: server-side read model access for workspace data
- `RpgRoller/wwwroot/js/rpgroller-api.js`: browser interop for session storage, SSE wiring, and DOM helpers
- `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:
- `TASKS.md` records the completed decomposition work and the final execution notes for this refactor.
- This README describes the code as it exists today. It does not treat blueprint items in `TASKS.md` as finished unless they are already present in the repo.
- `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
@@ -66,10 +75,10 @@ Current repo note:
- 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/admin deletion
- Character creation, activation, owner transfer, campaign reassignment or unlinking, and owner/admin deletion
- 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/admin management capabilities
- 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
@@ -87,39 +96,92 @@ Rolemaster support:
- 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+
- PowerShell 7+
- Node.js 22+
- Firefox
- geckodriver
Initial setup:
```powershell
```bash
dotnet tool restore
npm ci
npm exec playwright install chromium
```
Run locally:
1. Start the app:
```powershell
```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.
Playwright helpers:
Browser smoke helpers:
- Run the checked-in smoke suite against an isolated temporary SQLite database:
```powershell
pwsh ./scripts/run-playwright.ps1
```bash
node ./scripts/run-selenium.js
```
- Run Playwright directly when the app is already running:
```powershell
npm run e2e
- 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`:
@@ -145,14 +207,17 @@ SQLite migration rule:
## Frontend Runtime
- The UI runs as Blazor Server with interactive components.
- 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 server-side through `WorkspaceQueryService`; browser interop stays limited to browser-only concerns.
- 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/fumble behavior, while D&D 5e and Rolemaster use the submitted expression directly.
- 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`.

View File

@@ -3,15 +3,59 @@ namespace RpgRoller.Tests;
public sealed class FrontendHostTests(WebApplicationFactory<Program> factory) : ApiTestBase(factory)
{
[Fact]
public async Task RootPath_ServesBlazorFrontendShell()
public async Task RootPath_RedirectsToLogin_WhenAnonymous()
{
using var factory = CreateFactory(1);
using var client = factory.CreateClient(new() { AllowAutoRedirect = false });
var response = await client.GetAsync("/");
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
Assert.Equal("/login", response.Headers.Location?.OriginalString);
}
[Fact]
public async Task RootPath_RedirectsToPlay_WhenAuthenticated()
{
using var factory = CreateFactory(1);
using var client = factory.CreateClient(new() { AllowAutoRedirect = false });
await RegisterAsync(client, "alice", "Password123", "Alice");
await LoginAsync(client, "alice", "Password123");
var response = await client.GetAsync("/");
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
Assert.Equal("/play", response.Headers.Location?.OriginalString);
}
[Fact]
public async Task LoginPath_ServesStaticAuthMarkup()
{
using var factory = CreateFactory(1);
using var client = factory.CreateClient(new() { AllowAutoRedirect = false });
var response = await client.GetAsync("/login");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var html = await response.Content.ReadAsStringAsync();
Assert.Contains("Register or log in to join a campaign session.", html);
Assert.Contains("data-auth-page", html);
Assert.DoesNotContain("_framework/blazor.web.js", html);
}
[Theory]
[InlineData("/play")]
[InlineData("/campaigns")]
[InlineData("/admin")]
public async Task AuthenticatedRoutes_ServeInteractiveShell(string path)
{
using var factory = CreateFactory(1);
using var client = factory.CreateClient(new() { AllowAutoRedirect = false });
var response = await client.GetAsync(path);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var html = await response.Content.ReadAsStringAsync();
Assert.Contains("_framework/blazor.web.js", html);
Assert.Contains("Connecting...", html);
Assert.Contains("autostart=\"false\"", html);
Assert.Contains("disableDomPreservation", html);
Assert.DoesNotContain("data-auth-page", html);
}
}

View File

@@ -1,218 +1,74 @@
using Microsoft.AspNetCore.Http;
using System.Text.Json;
using Microsoft.JSInterop;
using RpgRoller.Components;
namespace RpgRoller.Tests;
public sealed class WorkspaceQueryServiceTests
{
private sealed class StubGameService : IGameService
private sealed class StubJsRuntime(Func<string, object?[]?, Type, object?> handler) : IJSRuntime
{
public IReadOnlyList<RulesetDefinition> GetRulesets()
public ValueTask<TValue> InvokeAsync<TValue>(string identifier, object?[]? args)
{
throw new NotSupportedException();
return ValueTask.FromResult((TValue)handler(identifier, args, typeof(TValue))!);
}
public ServiceResult<UserSummary> Register(string username, string password, string displayName)
public ValueTask<TValue> InvokeAsync<TValue>(string identifier, CancellationToken cancellationToken,
object?[]? args)
{
throw new NotSupportedException();
return InvokeAsync<TValue>(identifier, args);
}
public ServiceResult<(UserSummary User, string SessionToken)> Login(string username, string password)
{
throw new NotSupportedException();
}
public void Logout(string sessionToken)
{
throw new NotSupportedException();
}
public UserSummary? GetUserBySession(string sessionToken)
{
throw new NotSupportedException();
}
public ServiceResult<MeResponse> GetMe(string sessionToken)
{
return GetMeHandler(sessionToken);
}
public ServiceResult<CampaignSummary> CreateCampaign(string sessionToken, string name, string rulesetId)
{
throw new NotSupportedException();
}
public ServiceResult<IReadOnlyList<CampaignSummary>> GetCampaigns(string sessionToken)
{
return GetCampaignsHandler(sessionToken);
}
public ServiceResult<IReadOnlyList<CampaignOption>> GetCharacterCampaignOptions(string sessionToken)
{
throw new NotSupportedException();
}
public ServiceResult<CampaignRoster> GetCampaign(string sessionToken, Guid campaignId)
{
throw new NotSupportedException();
}
public ServiceResult<bool> DeleteCampaign(string sessionToken, Guid campaignId)
{
throw new NotSupportedException();
}
public ServiceResult<IReadOnlyList<string>> GetUsernames(string sessionToken)
{
throw new NotSupportedException();
}
public ServiceResult<IReadOnlyList<AdminUserSummary>> GetUsers(string sessionToken)
{
throw new NotSupportedException();
}
public ServiceResult<AdminUserSummary> UpdateUserRoles(string sessionToken, Guid userId, IReadOnlyList<string> roles)
{
throw new NotSupportedException();
}
public ServiceResult<bool> DeleteUser(string sessionToken, Guid userId)
{
throw new NotSupportedException();
}
public ServiceResult<CharacterSummary> CreateCharacter(string sessionToken, string name, Guid campaignId)
{
throw new NotSupportedException();
}
public ServiceResult<CharacterSummary> UpdateCharacter(string sessionToken, Guid characterId, string name, Guid? campaignId, string? ownerUsername = null)
{
throw new NotSupportedException();
}
public ServiceResult<bool> DeleteCharacter(string sessionToken, Guid characterId)
{
throw new NotSupportedException();
}
public ServiceResult<bool> ActivateCharacter(string sessionToken, Guid characterId)
{
throw new NotSupportedException();
}
public ServiceResult<IReadOnlyList<CharacterSummary>> GetOwnCharacters(string sessionToken)
{
throw new NotSupportedException();
}
public ServiceResult<SkillGroupSummary> CreateSkillGroup(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, int? fumbleRange = null)
{
throw new NotSupportedException();
}
public ServiceResult<SkillGroupSummary> UpdateSkillGroup(string sessionToken, Guid skillGroupId, string name, string diceRollDefinition, int wildDice, bool allowFumble, int? fumbleRange = null)
{
throw new NotSupportedException();
}
public ServiceResult<bool> DeleteSkillGroup(string sessionToken, Guid skillGroupId)
{
throw new NotSupportedException();
}
public ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null, bool rolemasterAutoRetry = false)
{
throw new NotSupportedException();
}
public ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null, bool rolemasterAutoRetry = false)
{
throw new NotSupportedException();
}
public ServiceResult<bool> DeleteSkill(string sessionToken, Guid skillId)
{
throw new NotSupportedException();
}
public ServiceResult<CharacterSheet> GetCharacterSheet(string sessionToken, Guid characterId)
{
throw new NotSupportedException();
}
public ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility, int situationalModifier = 0)
{
throw new NotSupportedException();
}
public ServiceResult<RollResult> RollCustom(string sessionToken, Guid characterId, string expression, string visibility)
{
throw new NotSupportedException();
}
public ServiceResult<IReadOnlyList<CampaignLogEntry>> GetCampaignLog(string sessionToken, Guid campaignId)
{
throw new NotSupportedException();
}
public ServiceResult<CampaignLogPage> GetCampaignLogPage(string sessionToken, Guid campaignId, Guid? afterRollId = null, int? limit = null)
{
throw new NotSupportedException();
}
public ServiceResult<CampaignRollDetail> GetRollDetail(string sessionToken, Guid rollId)
{
throw new NotSupportedException();
}
public ServiceResult<CampaignStateSnapshot> GetCampaignStateSnapshot(string sessionToken, Guid campaignId)
{
throw new NotSupportedException();
}
public Func<string, ServiceResult<MeResponse>> GetMeHandler { get; init; } = _ => ServiceResult<MeResponse>.Failure("unexpected_call", "Unexpected GetMe call.");
public Func<string, ServiceResult<IReadOnlyList<CampaignSummary>>> GetCampaignsHandler { get; init; } = _ => ServiceResult<IReadOnlyList<CampaignSummary>>.Failure("unexpected_call", "Unexpected GetCampaigns call.");
}
[Fact]
public void SessionTokenAccessor_ReadsSessionCookieFromHttpContext()
public async Task GetCampaignsAsync_UsesCampaignsApiEndpoint()
{
var httpContext = new DefaultHttpContext();
httpContext.Request.Headers.Cookie = "rpgroller_session=session-token";
var queryService = new WorkspaceQueryService(new RpgRollerApiClient(
new StubJsRuntime((identifier, args, returnType) =>
{
Assert.Equal("rpgRollerApi.request", identifier);
Assert.Equal("GET", args![0]);
Assert.Equal("/api/campaigns", args[1]);
Assert.Null(args[2]);
var accessor = new HttpContextAccessor { HttpContext = httpContext };
var sessionTokenAccessor = new WorkspaceSessionTokenAccessor(accessor);
Assert.Equal("session-token", sessionTokenAccessor.GetRequiredSessionToken());
return CreateJsApiResponse(args: new
{
ok = true,
status = 200,
data = new[]
{
new CampaignSummary(Guid.NewGuid(), "Alpha", "d6", new CampaignGmSummary(Guid.NewGuid(), "GM"),
1)
}
}, returnType);
})));
[Fact]
public async Task GetCampaignsAsync_UsesCapturedSessionToken()
{
var service = new StubGameService
{
GetCampaignsHandler = sessionToken =>
{
Assert.Equal("server-session", sessionToken);
return ServiceResult<IReadOnlyList<CampaignSummary>>.Success([new(Guid.NewGuid(), "Alpha", "d6", new(Guid.NewGuid(), "GM"), 1)]);
}
};
var queryService = new WorkspaceQueryService(service, CreateSessionTokenAccessor("server-session"));
var campaigns = await queryService.GetCampaignsAsync();
Assert.Single(campaigns);
var campaign = Assert.Single(campaigns);
Assert.Equal("Alpha", campaign.Name);
}
[Fact]
public async Task GetMeAsync_MapsUnauthorizedServiceResultToApiRequestException()
public async Task GetMeAsync_MapsUnauthorizedApiResponseToApiRequestException()
{
var service = new StubGameService { GetMeHandler = _ => ServiceResult<MeResponse>.Failure("unauthorized", "You must be logged in.") };
var queryService = new WorkspaceQueryService(new RpgRollerApiClient(
new StubJsRuntime((identifier, args, returnType) =>
{
Assert.Equal("rpgRollerApi.request", identifier);
Assert.Equal("GET", args![0]);
Assert.Equal("/api/me", args[1]);
return CreateJsApiResponse(args: new
{
ok = false,
status = 401,
error = "You must be logged in.",
code = "unauthorized",
data = (object?)null
}, returnType);
})));
var queryService = new WorkspaceQueryService(service, CreateSessionTokenAccessor("expired-session"));
var exception = await Assert.ThrowsAsync<ApiRequestException>(queryService.GetMeAsync);
Assert.Equal(401, exception.StatusCode);
@@ -220,10 +76,11 @@ public sealed class WorkspaceQueryServiceTests
Assert.Equal("unauthorized", exception.ErrorCode);
}
private static WorkspaceSessionTokenAccessor CreateSessionTokenAccessor(string sessionToken)
private static object CreateJsApiResponse(object args, Type returnType)
{
var httpContext = new DefaultHttpContext();
httpContext.Request.Headers.Cookie = $"rpgroller_session={sessionToken}";
return new(new HttpContextAccessor { HttpContext = httpContext });
var json = JsonSerializer.Serialize(args);
return JsonSerializer.Deserialize(json, returnType, JsonOptions)!;
}
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
}

View File

@@ -28,7 +28,8 @@ public sealed class WorkspaceStateTests
public void SkillDefinitionLabel_FormatsD6RolemasterAndDefaultRulesets()
{
var skill = new CharacterSheetSkill(Guid.NewGuid(), null, "Awareness", "d100!+15", 1, true, 5, true);
var state = new WorkspaceState { SelectedCampaign = new(Guid.NewGuid(), "Alpha", "d6", new(Guid.NewGuid(), "GM"), []) };
var state = new WorkspaceState
{ SelectedCampaign = new(Guid.NewGuid(), "Alpha", "d6", new(Guid.NewGuid(), "GM"), []) };
Assert.Equal("d100!+15, wild 1, fumble on", state.SkillDefinitionLabel(skill));
@@ -49,7 +50,8 @@ public sealed class WorkspaceStateTests
var state = new WorkspaceState
{
User = new(userId, "user", "User", []),
SelectedCampaign = new(Guid.NewGuid(), "Alpha", "d6", new(Guid.NewGuid(), "GM"), [ownedCharacter, secondOwnedCharacter, otherCharacter]),
SelectedCampaign = new(Guid.NewGuid(), "Alpha", "d6", new(Guid.NewGuid(), "GM"),
[ownedCharacter, secondOwnedCharacter, otherCharacter]),
SelectedCharacterId = secondOwnedCharacter.Id,
ActiveCharacterId = ownedCharacter.Id,
SelectedCharacterSkills = [new(Guid.NewGuid(), null, "Stealth", "2D+1", 1, true, null, false)],
@@ -69,33 +71,26 @@ public sealed class WorkspaceStateTests
}
[Fact]
public void ScreenAndConnectionFlags_ReflectCurrentState()
public void CampaignAndConnectionFlags_ReflectCurrentState()
{
var adminId = Guid.NewGuid();
var state = new WorkspaceState
{
User = new(adminId, "admin", "Admin", [UserRoles.Admin]),
SelectedCampaign = new(Guid.NewGuid(), "Alpha", "d6", new(adminId, "Admin"), []),
CurrentScreen = "admin",
ConnectionState = "reconnecting"
};
Assert.True(state.IsAdminScreen);
Assert.False(state.IsPlayScreen);
Assert.True(state.IsCurrentUserAdmin);
Assert.True(state.IsCurrentUserGm);
Assert.True(state.CanDeleteSelectedCampaign);
Assert.True(state.IsSelectedCampaignD6);
Assert.Equal("Reconnecting", state.ConnectionStateLabel);
Assert.Equal("warn", state.ConnectionStateCssClass);
Assert.Equal("rr-app", state.AppCssClass);
state.CurrentScreen = "play";
state.ConnectionState = "connected";
Assert.True(state.IsPlayScreen);
Assert.Equal("Connected", state.ConnectionStateLabel);
Assert.Equal("ok", state.ConnectionStateCssClass);
Assert.Equal("rr-app app-play", state.AppCssClass);
}
}

View File

@@ -0,0 +1,173 @@
using Microsoft.JSInterop;
using RpgRoller.Components;
using RpgRoller.Components.Pages;
namespace RpgRoller.Tests;
public sealed class WorkspaceCampaignCoordinatorTests
{
[Fact]
public async Task OnCampaignCreatedAsync_RequestsRefreshAfterReloadingWorkspaceState()
{
var calls = new List<string>();
var coordinator = CreateCoordinator(
reloadCampaignsAsync: campaignId =>
{
calls.Add($"reloadCampaigns:{campaignId:D}");
return Task.CompletedTask;
},
reloadCharacterCampaignOptionsAsync: () =>
{
calls.Add("reloadCharacterCampaignOptions");
return Task.CompletedTask;
},
refreshCampaignScopeAsync: () =>
{
calls.Add("refreshCampaignScope");
return Task.CompletedTask;
},
syncStateEventsAsync: () =>
{
calls.Add("syncStateEvents");
return Task.CompletedTask;
},
requestRefreshAsync: () =>
{
calls.Add("requestRefresh");
return Task.CompletedTask;
});
var campaignId = Guid.NewGuid();
await coordinator.OnCampaignCreatedAsync(campaignId);
Assert.Equal([
$"reloadCampaigns:{campaignId:D}",
"reloadCharacterCampaignOptions",
"refreshCampaignScope",
"syncStateEvents",
"requestRefresh"
], calls);
}
[Fact]
public async Task OnCharacterCreatedAsync_RequestsRefreshAfterReloadingWorkspaceState()
{
var calls = new List<string>();
var coordinator = CreateCoordinator(
reloadCampaignsAsync: campaignId =>
{
calls.Add($"reloadCampaigns:{campaignId:D}");
return Task.CompletedTask;
},
reloadCharacterCampaignOptionsAsync: () =>
{
calls.Add("reloadCharacterCampaignOptions");
return Task.CompletedTask;
},
refreshCampaignScopeAsync: () =>
{
calls.Add("refreshCampaignScope");
return Task.CompletedTask;
},
syncStateEventsAsync: () =>
{
calls.Add("syncStateEvents");
return Task.CompletedTask;
},
requestRefreshAsync: () =>
{
calls.Add("requestRefresh");
return Task.CompletedTask;
});
var campaignId = Guid.NewGuid();
await coordinator.OnCharacterCreatedAsync(campaignId);
Assert.Equal([
$"reloadCampaigns:{campaignId:D}",
"reloadCharacterCampaignOptions",
"refreshCampaignScope",
"syncStateEvents",
"requestRefresh"
], calls);
}
[Fact]
public async Task OnCharacterUpdatedAsync_RequestsRefreshAfterReloadingWorkspaceState()
{
var calls = new List<string>();
var coordinator = CreateCoordinator(
reloadCampaignsAsync: campaignId =>
{
calls.Add($"reloadCampaigns:{campaignId:D}");
return Task.CompletedTask;
},
reloadCharacterCampaignOptionsAsync: () =>
{
calls.Add("reloadCharacterCampaignOptions");
return Task.CompletedTask;
},
refreshCampaignScopeAsync: () =>
{
calls.Add("refreshCampaignScope");
return Task.CompletedTask;
},
syncStateEventsAsync: () =>
{
calls.Add("syncStateEvents");
return Task.CompletedTask;
},
requestRefreshAsync: () =>
{
calls.Add("requestRefresh");
return Task.CompletedTask;
});
var campaignId = Guid.NewGuid();
await coordinator.OnCharacterUpdatedAsync(campaignId);
Assert.Equal([
$"reloadCampaigns:{campaignId:D}",
"reloadCharacterCampaignOptions",
"refreshCampaignScope",
"syncStateEvents",
"requestRefresh"
], calls);
}
private static WorkspaceCampaignCoordinator CreateCoordinator(
Func<Guid?, Task>? reloadCampaignsAsync = null,
Func<Task>? reloadCharacterCampaignOptionsAsync = null,
Func<Task>? refreshCampaignScopeAsync = null,
Func<Task>? syncStateEventsAsync = null,
Func<Task>? requestRefreshAsync = null)
{
var state = new WorkspaceState();
var feedback = new WorkspaceFeedbackService(state, () => Task.CompletedTask);
return new WorkspaceCampaignCoordinator(
state,
feedback,
new StubJsRuntime(),
new RpgRollerApiClient(new StubJsRuntime()),
() => Task.CompletedTask,
reloadCampaignsAsync ?? (_ => Task.CompletedTask),
reloadCharacterCampaignOptionsAsync ?? (() => Task.CompletedTask),
refreshCampaignScopeAsync ?? (() => Task.CompletedTask),
syncStateEventsAsync ?? (() => Task.CompletedTask),
requestRefreshAsync ?? (() => Task.CompletedTask));
}
private sealed class StubJsRuntime : IJSRuntime
{
public ValueTask<TValue> InvokeAsync<TValue>(string identifier, object?[]? args)
{
return ValueTask.FromResult(default(TValue)!);
}
public ValueTask<TValue> InvokeAsync<TValue>(string identifier, CancellationToken cancellationToken,
object?[]? args)
{
return InvokeAsync<TValue>(identifier, args);
}
}
}

View File

@@ -0,0 +1,22 @@
using Microsoft.AspNetCore.Http.HttpResults;
using RpgRoller.Services;
namespace RpgRoller.Api;
public static class FrontendEntryEndpoints
{
public static void MapFrontendEntryEndpoints(this IEndpointRouteBuilder app)
{
app.MapGet("/", RedirectRootRequest);
}
private static RedirectHttpResult RedirectRootRequest(HttpContext context, IGameService game)
{
var redirectPath = context.TryReadSessionTokenFromCookie(out var sessionToken) &&
game.GetUserBySession(sessionToken) is not null
? "/play"
: "/login";
return TypedResults.Redirect(context.Request.PathBase.Add(redirectPath).Value!);
}
}

Binary file not shown.

View File

@@ -1,3 +1,4 @@
@using RpgRoller.Components.Pages.HomeControls
@attribute [ExcludeFromCodeCoverage]
<!DOCTYPE html>
@@ -12,19 +13,54 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Baloo+2:wght@400;500;600;700&family=Nunito:wght@400;600;700&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Noto+Color+Emoji&display=swap" rel="stylesheet">
<HeadOutlet @rendermode="InteractiveServer"/>
@if (UseInteractiveApp)
{
<HeadOutlet/>
}
</head>
<body>
<Routes @rendermode="InteractiveServer"/>
@if (UseStaticAuthPage)
{
<StaticAuthPage StatusMessage="@AuthStatusMessage" StatusIsError="@AuthStatusIsError"/>
}
else
{
<Routes/>
}
<script src="js/rpgroller-api.js"></script>
<script src="_framework/blazor.web.js"></script>
@if (UseInteractiveApp)
{
<script src="_framework/blazor.web.js" autostart="false"></script>
<script>
Blazor.start({
ssr: {
disableDomPreservation: true
}
});
</script>
}
</body>
</html>
@code {
[CascadingParameter] private HttpContext? HttpContext { get; set; }
[CascadingParameter]
private HttpContext? HttpContext { get; set; }
private bool UseInteractiveApp => !UseStaticAuthPage;
private bool UseStaticAuthPage => IsLoginRequest;
private bool IsLoginRequest
{
get
{
var path = HttpContext?.Request.Path.Value;
return string.Equals(path, "/login", StringComparison.Ordinal);
}
}
private string? AuthStatusMessage => ReadAuthQueryValue("message");
private bool AuthStatusIsError => string.Equals(ReadAuthQueryValue("kind"), "error", StringComparison.OrdinalIgnoreCase);
private string BaseHref
{
@@ -38,4 +74,13 @@
}
}
private string? ReadAuthQueryValue(string key)
{
if (!IsLoginRequest || HttpContext is null)
return null;
var value = HttpContext.Request.Query[key];
return value.Count > 0 ? value[0] : null;
}
}

View File

@@ -0,0 +1,12 @@
@page "/admin"
@rendermode @(new InteractiveServerRenderMode(prerender: false))
@inherits AuthenticatedPageBase
<Workspace Route="WorkspaceRoute.Admin" LoggedOut="OnLoggedOutAsync">
<ChildContent Context="workspace">
<WorkspaceRouteView Workspace="workspace">
<ChildContent Context="readyWorkspace">
<AdminWorkspaceContent Workspace="readyWorkspace"/>
</ChildContent>
</WorkspaceRouteView>
</ChildContent>
</Workspace>

View File

@@ -0,0 +1,8 @@
using System.Diagnostics.CodeAnalysis;
namespace RpgRoller.Components.Pages;
[ExcludeFromCodeCoverage]
public partial class AdminPage
{
}

View File

@@ -0,0 +1,70 @@
@using Microsoft.AspNetCore.Components
<main class="management-screen">
@if (Workspace.State.IsCurrentUserAdmin)
{
<section class="card">
<div class="section-head">
<h2>Database</h2>
</div>
<p class="muted">Download the current SQLite file for backup or offline inspection.</p>
<div class="management-actions">
<a class="action-link" href="@Workspace.AdminDatabaseDownloadUrl" download>Download SQLite database</a>
</div>
</section>
}
<section class="card">
<div class="section-head">
<h2>User Management</h2>
</div>
@if (IsAdminDataLoading)
{
<p class="empty">Loading users...</p>
}
else if (!Workspace.State.IsCurrentUserAdmin)
{
<p class="empty">Admin role is required to manage users.</p>
}
else if (Workspace.State.AdminUsers.Count == 0)
{
<p class="empty">No users found.</p>
}
else
{
<ul class="management-list">
@foreach (var user in Workspace.State.AdminUsers)
{
<li>
<div>
<strong>@user.Username</strong>
<p class="muted">@user.DisplayName</p>
<p class="muted">Roles: @(user.Roles.Count == 0 ? "none" : string.Join(", ", user.Roles))</p>
</div>
<div class="skill-chip-actions">
<button type="button"
class="chip-button"
disabled="@(Workspace.State.IsMutating || user.Id == Workspace.State.User?.Id)"
@onclick="() => Workspace.Admin.ToggleAdminRoleAsync(user)">
<span aria-hidden="true" class="emoji">🛡️</span>
<span class="sr-only">Toggle admin role for @user.Username</span>
</button>
<button type="button"
class="chip-button"
disabled="@(Workspace.State.IsMutating || user.Id == Workspace.State.User?.Id)"
@onclick="() => Workspace.Admin.DeleteUserAsync(user)">
<span aria-hidden="true" class="emoji">🗑️</span>
<span class="sr-only">Delete user @user.Username</span>
</button>
</div>
</li>
}
</ul>
}
</section>
</main>
@code {
[Parameter, EditorRequired] public WorkspacePageContext Workspace { get; set; } = null!;
private bool IsAdminDataLoading => !Workspace.HasSessionInitialized || Workspace.State.IsAdminDataLoading;
}

View File

@@ -0,0 +1,29 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.WebUtilities;
namespace RpgRoller.Components.Pages;
[ExcludeFromCodeCoverage]
public abstract class AuthenticatedPageBase : ComponentBase
{
protected Task OnLoggedOutAsync(string? message)
{
if (string.IsNullOrWhiteSpace(message))
{
Navigation.NavigateTo("/login", forceLoad: true);
return Task.CompletedTask;
}
var query = new Dictionary<string, string?>
{
["message"] = message,
["kind"] = message.Contains("expired", StringComparison.OrdinalIgnoreCase) ? "error" : "success"
};
Navigation.NavigateTo(QueryHelpers.AddQueryString("/login", query), forceLoad: true);
return Task.CompletedTask;
}
[Inject] protected NavigationManager Navigation { get; set; } = null!;
}

View File

@@ -0,0 +1,12 @@
@page "/campaigns"
@rendermode @(new InteractiveServerRenderMode(prerender: false))
@inherits AuthenticatedPageBase
<Workspace Route="WorkspaceRoute.Campaigns" LoggedOut="OnLoggedOutAsync">
<ChildContent Context="workspace">
<WorkspaceRouteView Workspace="workspace">
<ChildContent Context="readyWorkspace">
<CampaignsWorkspaceContent Workspace="readyWorkspace"/>
</ChildContent>
</WorkspaceRouteView>
</ChildContent>
</Workspace>

View File

@@ -0,0 +1,8 @@
using System.Diagnostics.CodeAnalysis;
namespace RpgRoller.Components.Pages;
[ExcludeFromCodeCoverage]
public partial class CampaignsPage
{
}

View File

@@ -0,0 +1,31 @@
@using Microsoft.AspNetCore.Components
@using RpgRoller.Components.Pages.HomeControls
<CampaignManagementPanel
Campaigns="Workspace.State.Campaigns"
SelectedCampaignId="Workspace.State.SelectedCampaignId"
SelectedCampaign="Workspace.State.SelectedCampaign"
Rulesets="Workspace.State.Rulesets"
IsMutating="Workspace.State.IsMutating"
OwnerLabel="Workspace.State.OwnerLabel"
CanEditCharacter="Workspace.Campaigns.CanEditCharacter"
CanDeleteCharacter="Workspace.Campaigns.CanDeleteCharacter"
CanDeleteCampaign="Workspace.State.CanDeleteSelectedCampaign"
CampaignSelectionChanged="OnCampaignSelectionChangedAsync"
CampaignCreated="Workspace.Campaigns.OnCampaignCreatedAsync"
DeleteCampaignRequested="Workspace.Campaigns.DeleteSelectedCampaignAsync"
CreateCharacterRequested="Workspace.Campaigns.OpenCreateCharacterModal"
EditCharacterRequested="Workspace.Campaigns.OpenEditCharacterModal"
DeleteCharacterRequested="Workspace.Campaigns.DeleteCharacterAsync"/>
<CharacterManagementModals Workspace="Workspace"/>
@code {
[Parameter, EditorRequired] public WorkspacePageContext Workspace { get; set; } = null!;
private async Task OnCampaignSelectionChangedAsync(ChangeEventArgs args)
{
await Workspace.Campaigns.OnCampaignSelectionChangedAsync(args);
await Workspace.RequestRefreshAsync();
}
}

View File

@@ -0,0 +1,40 @@
@using Microsoft.AspNetCore.Components
@using RpgRoller.Components.Pages.HomeControls
<CharacterFormModal
Visible="Workspace.State.ShowCreateCharacterModal"
Title="Create Character"
SubmitLabel="Create Character"
NameInputId="character-create-name"
CampaignInputId="character-create-campaign"
OwnerUsernameInputId="character-create-owner"
InitialModel="Workspace.State.CreateCharacterInitialModel"
FormVersion="Workspace.State.CreateCharacterFormVersion"
EditingCharacterId="null"
CampaignOptions="Workspace.State.CharacterCampaignOptions"
IsMutating="Workspace.State.IsMutating"
AllowOwnerEdit="false"
AvailableUsernames="Workspace.State.KnownUsernames"
CharacterSaved="Workspace.Campaigns.OnCharacterCreatedAsync"
CancelRequested="Workspace.Campaigns.CloseCharacterModals"/>
<CharacterFormModal
Visible="Workspace.State.ShowEditCharacterModal"
Title="Edit Character"
SubmitLabel="Save Character"
NameInputId="character-edit-name"
CampaignInputId="character-edit-campaign"
OwnerUsernameInputId="character-edit-owner"
InitialModel="Workspace.State.EditCharacterInitialModel"
FormVersion="Workspace.State.EditCharacterFormVersion"
EditingCharacterId="Workspace.State.EditingCharacterId"
CampaignOptions="Workspace.State.CharacterCampaignOptions"
IsMutating="Workspace.State.IsMutating"
AllowOwnerEdit="Workspace.State.CanEditCharacterOwner"
AvailableUsernames="Workspace.State.KnownUsernames"
CharacterSaved="Workspace.Campaigns.OnCharacterUpdatedAsync"
CancelRequested="Workspace.Campaigns.CloseCharacterModals"/>
@code {
[Parameter, EditorRequired] public WorkspacePageContext Workspace { get; set; } = null!;
}

View File

@@ -1,27 +0,0 @@
@page "/"
@using RpgRoller.Components.Pages.HomeControls
@switch (CurrentView)
{
case HomeViewMode.Loading:
<div class="rr-app">
<main class="loading-shell" aria-busy="true" aria-live="polite">
<h1>RpgRoller</h1>
<p>Connecting...</p>
</main>
</div>
break;
case HomeViewMode.Anonymous:
<div class="rr-app">
<AuthSection
StatusMessage="StatusMessage"
StatusIsError="StatusIsError"
LoggedIn="OnLoggedInAsync"/>
</div>
break;
case HomeViewMode.Workspace:
<Workspace LoggedOut="OnLoggedOutAsync"/>
break;
}

View File

@@ -1,83 +0,0 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Components;
using RpgRoller.Contracts;
namespace RpgRoller.Components.Pages;
[ExcludeFromCodeCoverage]
public partial class Home
{
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender || HasInitialized)
return;
HasInitialized = true;
await InitializeAsync();
await InvokeAsync(StateHasChanged);
}
private async Task InitializeAsync()
{
try
{
_ = await ApiClient.RequestAsync<MeResponse>("GET", "/api/me");
CurrentView = HomeViewMode.Workspace;
ClearStatus();
}
catch (ApiRequestException ex) when (ex.StatusCode == 401)
{
CurrentView = HomeViewMode.Anonymous;
ClearStatus();
}
catch (ApiRequestException ex)
{
CurrentView = HomeViewMode.Anonymous;
SetStatus(ex.Message, true);
}
}
private Task OnLoggedInAsync()
{
ClearStatus();
Navigation.NavigateTo("/", forceLoad: true);
return Task.CompletedTask;
}
private Task OnLoggedOutAsync(string? message)
{
CurrentView = HomeViewMode.Anonymous;
if (string.IsNullOrWhiteSpace(message))
{
ClearStatus();
return InvokeAsync(StateHasChanged);
}
var isError = message.Contains("expired", StringComparison.OrdinalIgnoreCase);
SetStatus(message, isError);
return InvokeAsync(StateHasChanged);
}
private void SetStatus(string message, bool isError)
{
StatusMessage = message;
StatusIsError = isError;
}
private void ClearStatus()
{
StatusMessage = null;
StatusIsError = false;
}
private HomeViewMode CurrentView { get; set; } = HomeViewMode.Loading;
private string? StatusMessage { get; set; }
private bool StatusIsError { get; set; }
private bool HasInitialized { get; set; }
[Inject]
private RpgRollerApiClient ApiClient { get; set; } = null!;
[Inject]
private NavigationManager Navigation { get; set; } = null!;
}

View File

@@ -17,12 +17,12 @@
{
<p class="header-campaign">Campaign: <strong>@(CampaignName ?? "No campaign selected")</strong></p>
}
<div class="header-connection-cell">
@if (ShowConnectionState)
{
<div class="header-connection-cell">
<p class="connection @ConnectionStateCssClass">@ConnectionStateLabel</p>
</div>
}
</div>
<a href="" class="logout-link" @onclick:preventDefault="true" @onclick="LogoutRequested">Logout</a>
@if (MenuItems.Count > 0)
{

View File

@@ -12,44 +12,31 @@ public partial class AppHeader
return item.OnSelected?.Invoke() ?? Task.CompletedTask;
}
[Parameter]
public string Title { get; set; } = "RpgRoller";
[Parameter] public string Title { get; set; } = "RpgRoller";
[Parameter]
public UserSummary? User { get; set; }
[Parameter] public UserSummary? User { get; set; }
[Parameter]
public bool ShowCampaign { get; set; }
[Parameter] public bool ShowCampaign { get; set; }
[Parameter]
public string? CampaignName { get; set; }
[Parameter] public string? CampaignName { get; set; }
[Parameter]
public bool ShowConnectionState { get; set; } = true;
[Parameter] public bool ShowConnectionState { get; set; } = true;
[Parameter]
public string ConnectionStateLabel { get; set; } = "Offline fallback";
[Parameter] public string ConnectionStateLabel { get; set; } = "Offline fallback";
[Parameter]
public string ConnectionStateCssClass { get; set; } = "offline";
[Parameter] public string ConnectionStateCssClass { get; set; } = "offline";
[Parameter]
public bool IsMenuOpen { get; set; }
[Parameter] public bool IsMenuOpen { get; set; }
[Parameter]
public string MenuButtonId { get; set; } = "screen-menu-button";
[Parameter] public string MenuButtonId { get; set; } = "screen-menu-button";
[Parameter]
public string MenuId { get; set; } = "screen-menu";
[Parameter] public string MenuId { get; set; } = "screen-menu";
[Parameter]
public IReadOnlyList<AppHeaderMenuItem> MenuItems { get; set; } = [];
[Parameter] public IReadOnlyList<AppHeaderMenuItem> MenuItems { get; set; } = [];
[Parameter]
public EventCallback ToggleMenuRequested { get; set; }
[Parameter] public EventCallback ToggleMenuRequested { get; set; }
[Parameter]
public EventCallback LogoutRequested { get; set; }
[Parameter] public EventCallback LogoutRequested { get; set; }
}
public sealed class AppHeaderMenuItem

View File

@@ -29,7 +29,8 @@ public partial class CampaignLogPanel
catch (JSDisconnectedException)
{
}
catch (InvalidOperationException ex) when (ex.Message.Contains("statically rendered", StringComparison.OrdinalIgnoreCase))
catch (InvalidOperationException ex) when (ex.Message.Contains("statically rendered",
StringComparison.OrdinalIgnoreCase))
{
}
}
@@ -58,7 +59,8 @@ public partial class CampaignLogPanel
IsSubmittingCustomRoll = true;
try
{
var roll = await ApiClient.RequestAsync<RollResult>("POST", $"/api/characters/{SelectedCharacterId.Value}/custom-rolls", new
var roll = await ApiClient.RequestAsync<RollResult>("POST",
$"/api/characters/{SelectedCharacterId.Value}/custom-rolls", new
{
expression,
visibility = NormalizedRollVisibility
@@ -71,7 +73,8 @@ public partial class CampaignLogPanel
await JS.InvokeVoidAsync("rpgRollerApi.clearInputValue", CustomRollInputRef);
await InvokeAsync(StateHasChanged);
}
catch (ApiRequestException ex) when (string.Equals(ex.ErrorCode, "invalid_expression", StringComparison.Ordinal))
catch (ApiRequestException ex) when
(string.Equals(ex.ErrorCode, "invalid_expression", StringComparison.Ordinal))
{
SetCustomRollError(ex.Message);
await InvokeAsync(StateHasChanged);
@@ -93,7 +96,8 @@ public partial class CampaignLogPanel
private static IReadOnlyList<EventBadgeView> GetEventBadges(CampaignLogListEntry entry)
{
return (entry.EventBadges ?? []).Select(ToEventBadgeView).Where(badge => badge is not null).Cast<EventBadgeView>().ToArray();
return (entry.EventBadges ?? []).Select(ToEventBadgeView).Where(badge => badge is not null)
.Cast<EventBadgeView>().ToArray();
}
private static bool HasSummary(CampaignLogListEntry entry)
@@ -130,11 +134,9 @@ public partial class CampaignLogPanel
return string.Join(" ", classes);
}
[Inject]
private IJSRuntime JS { get; set; } = null!;
[Inject] private IJSRuntime JS { get; set; } = null!;
[Inject]
private RpgRollerApiClient ApiClient { get; set; } = null!;
[Inject] private RpgRollerApiClient ApiClient { get; set; } = null!;
private ElementReference LogPanelRef { get; set; }
private ElementReference LogFeedRef { get; set; }
@@ -145,54 +147,44 @@ public partial class CampaignLogPanel
private FormState<CustomRollFormModel> CustomRollState { get; } = new();
private bool IsSubmittingCustomRoll { get; set; }
[Parameter]
public bool IsCampaignDataLoading { get; set; }
[Parameter] public bool IsCampaignDataLoading { get; set; }
[Parameter]
public IReadOnlyList<CampaignLogListEntry> CampaignLog { get; set; } = [];
[Parameter] public IReadOnlyList<CampaignLogListEntry> CampaignLog { get; set; } = [];
[Parameter]
public Guid? ExpandedRollId { get; set; }
[Parameter] public Guid? ExpandedRollId { get; set; }
[Parameter]
public Guid? FreshRollId { get; set; }
[Parameter] public Guid? FreshRollId { get; set; }
[Parameter]
public EventCallback<Guid> ToggleRollDetailRequested { get; set; }
[Parameter] public EventCallback<Guid> ToggleRollDetailRequested { get; set; }
[Parameter]
public Func<Guid, CampaignRollDetail?> ResolveRollDetail { get; set; } = _ => null;
[Parameter] public Func<Guid, CampaignRollDetail?> ResolveRollDetail { get; set; } = _ => null;
[Parameter]
public Func<Guid, bool> IsRollDetailLoading { get; set; } = _ => false;
[Parameter] public Func<Guid, bool> IsRollDetailLoading { get; set; } = _ => false;
[Parameter]
public Func<Guid, string?> GetRollDetailError { get; set; } = _ => null;
[Parameter] public Func<Guid, string?> GetRollDetailError { get; set; } = _ => null;
[Parameter]
public Guid? SelectedCharacterId { get; set; }
[Parameter] public Guid? SelectedCharacterId { get; set; }
[Parameter]
public string? SelectedCharacterName { get; set; }
[Parameter] public string? SelectedCharacterName { get; set; }
[Parameter]
public string SelectedCampaignRulesetId { get; set; } = string.Empty;
[Parameter] public string SelectedCampaignRulesetId { get; set; } = string.Empty;
[Parameter]
public string RollVisibility { get; set; } = "public";
[Parameter] public string RollVisibility { get; set; } = "public";
[Parameter]
public bool IsMutating { get; set; }
[Parameter] public Func<string>? ResolveRollVisibility { get; set; }
[Parameter]
public EventCallback<RollResult> CustomRollCreated { get; set; }
[Parameter] public bool IsMutating { get; set; }
[Parameter]
public EventCallback<string> ErrorOccurred { get; set; }
[Parameter] public EventCallback<RollResult> CustomRollCreated { get; set; }
[Parameter] public EventCallback<string> ErrorOccurred { get; set; }
private bool HasCustomRollError => CustomRollState.Errors.ContainsKey("expression");
private string? CustomRollErrorMessage => CustomRollState.Errors.GetValueOrDefault("expression");
private bool IsCustomRollDisabled => IsCampaignDataLoading || IsMutating || IsSubmittingCustomRoll || !SelectedCharacterId.HasValue;
private bool IsCustomRollDisabled =>
IsCampaignDataLoading || IsMutating || IsSubmittingCustomRoll || !SelectedCharacterId.HasValue;
private string CustomRollInputCssClass => HasCustomRollError ? "custom-roll-input error" : "custom-roll-input";
private string? CustomRollInputTitle => HasCustomRollError ? CustomRollErrorMessage : null;
private string CustomRollErrorElementId => "custom-roll-expression-error";
@@ -206,17 +198,27 @@ public partial class CampaignLogPanel
_ => "Enter a roll expression"
};
private string CustomRollStatusText => SelectedCharacterId.HasValue && !string.IsNullOrWhiteSpace(SelectedCharacterName) ? $"For {SelectedCharacterName} • {RollVisibilityLabel}" : "Select a character to enable";
private string CustomRollStatusText =>
SelectedCharacterId.HasValue && !string.IsNullOrWhiteSpace(SelectedCharacterName)
? $"For {SelectedCharacterName} • Uses {RollVisibilityLabel.ToLowerInvariant()} visibility"
: $"Select a character to enable • {RollVisibilityLabel} visibility selected";
private string CustomRollHelpText => SelectedCampaignRulesetId.ToLowerInvariant() switch
{
RulesetFormHelpers.RulesetIds.D6 => "Uses the campaign ruleset. D6 custom rolls use one wild die and allow fumbles.",
RulesetFormHelpers.RulesetIds.Rolemaster => $"{RulesetFormHelpers.RolemasterExampleText()}. Logged for the selected character.",
RulesetFormHelpers.RulesetIds.D6 =>
"Uses the campaign ruleset. D6 custom rolls use one wild die and allow fumbles.",
RulesetFormHelpers.RulesetIds.Rolemaster =>
$"{RulesetFormHelpers.RolemasterExampleText()}. Logged for the selected character.",
_ => "Uses the selected campaign ruleset and current visibility."
};
private string RollVisibilityLabel => string.Equals(NormalizedRollVisibility, "private", StringComparison.OrdinalIgnoreCase) ? "Private" : "Public";
private string NormalizedRollVisibility => string.Equals(RollVisibility, "private", StringComparison.OrdinalIgnoreCase) ? "private" : "public";
private string RollVisibilityLabel =>
string.Equals(NormalizedRollVisibility, "private", StringComparison.OrdinalIgnoreCase) ? "Private" : "Public";
private string NormalizedRollVisibility =>
string.Equals(ResolveRollVisibility?.Invoke() ?? RollVisibility, "private", StringComparison.OrdinalIgnoreCase)
? "private"
: "public";
private string CustomRollExpression
{

View File

@@ -59,7 +59,9 @@
</div>
<div class="chip-toolbar">
<label class="visibility-control" for="roll-visibility">Visibility</label>
<select id="roll-visibility" value="@(RollVisibility == "private" ? "private" : "public")" @onchange="OnRollVisibilityChangedAsync">
<select id="roll-visibility"
value="@(RollVisibility == "private" ? "private" : "public")"
@onchange="OnRollVisibilityChangedAsync">
<option value="public">Public</option>
<option value="private">Private</option>
</select>

View File

@@ -9,7 +9,9 @@ public partial class CharacterPanel
{
private void OpenCreateSkillModal(Guid? skillGroupId = null)
{
var selectedGroup = skillGroupId.HasValue ? SelectedCharacterSkillGroups.FirstOrDefault(group => group.Id == skillGroupId.Value) : null;
var selectedGroup = skillGroupId.HasValue
? SelectedCharacterSkillGroups.FirstOrDefault(group => group.Id == skillGroupId.Value)
: null;
CreateSkillInitialModel = new()
{
@@ -176,7 +178,11 @@ public partial class CharacterPanel
try
{
var selectedCharacterId = SelectedCharacterId!.Value;
var createdGroup = await ApiClient.RequestAsync<SkillGroupSummary>("POST", $"/api/characters/{selectedCharacterId}/skill-groups", new CreateSkillGroupRequest(SkillGroupState.Model.Name.Trim(), SkillGroupState.Model.DiceRollDefinition.Trim(), SkillGroupState.Model.WildDice, SkillGroupState.Model.AllowFumble, SkillGroupState.Model.FumbleRange));
var createdGroup = await ApiClient.RequestAsync<SkillGroupSummary>("POST",
$"/api/characters/{selectedCharacterId}/skill-groups",
new CreateSkillGroupRequest(SkillGroupState.Model.Name.Trim(),
SkillGroupState.Model.DiceRollDefinition.Trim(), SkillGroupState.Model.WildDice,
SkillGroupState.Model.AllowFumble, SkillGroupState.Model.FumbleRange));
CloseSkillGroupModals();
await SkillGroupCreated.InvokeAsync(createdGroup.Id);
}
@@ -230,7 +236,11 @@ public partial class CharacterPanel
try
{
var editingSkillGroupId = EditingSkillGroupId!.Value;
var updatedGroup = await ApiClient.RequestAsync<SkillGroupSummary>("PUT", $"/api/skill-groups/{editingSkillGroupId}", new UpdateSkillGroupRequest(SkillGroupState.Model.Name.Trim(), SkillGroupState.Model.DiceRollDefinition.Trim(), SkillGroupState.Model.WildDice, SkillGroupState.Model.AllowFumble, SkillGroupState.Model.FumbleRange));
var updatedGroup = await ApiClient.RequestAsync<SkillGroupSummary>("PUT",
$"/api/skill-groups/{editingSkillGroupId}",
new UpdateSkillGroupRequest(SkillGroupState.Model.Name.Trim(),
SkillGroupState.Model.DiceRollDefinition.Trim(), SkillGroupState.Model.WildDice,
SkillGroupState.Model.AllowFumble, SkillGroupState.Model.FumbleRange));
CloseSkillGroupModals();
await SkillGroupUpdated.InvokeAsync(updatedGroup.Id);
}
@@ -276,7 +286,8 @@ public partial class CharacterPanel
return true;
var filter = SkillFilterText.Trim();
return skill.Name.Contains(filter, StringComparison.OrdinalIgnoreCase) || skill.DiceRollDefinition.Contains(filter, StringComparison.OrdinalIgnoreCase);
return skill.Name.Contains(filter, StringComparison.OrdinalIgnoreCase) ||
skill.DiceRollDefinition.Contains(filter, StringComparison.OrdinalIgnoreCase);
}
private static string InitialsFor(string value)
@@ -317,9 +328,13 @@ public partial class CharacterPanel
private bool IsD6Ruleset => RulesetFormHelpers.IsD6(SelectedCampaignRulesetId);
private bool IsRolemasterRuleset => RulesetFormHelpers.IsRolemaster(SelectedCampaignRulesetId);
private bool IsSkillGroupRolemasterOpenEnded => RulesetFormHelpers.IsRolemasterOpenEndedExpression(SkillGroupState.Model.DiceRollDefinition);
private string SkillGroupExpressionHelpText => IsRolemasterRuleset ? $"{RulesetFormHelpers.RolemasterExampleText()}. Negative modifiers are allowed." : "Enter the default expression for skills created in this group.";
private bool IsSkillGroupRolemasterOpenEnded =>
RulesetFormHelpers.IsRolemasterOpenEndedExpression(SkillGroupState.Model.DiceRollDefinition);
private string SkillGroupExpressionHelpText => IsRolemasterRuleset
? $"{RulesetFormHelpers.RolemasterExampleText()}. Negative modifiers are allowed."
: "Enter the default expression for skills created in this group.";
private bool ShowCreateSkillModal { get; set; }
private bool ShowEditSkillModal { get; set; }
@@ -335,78 +350,53 @@ public partial class CharacterPanel
private bool IsSubmittingSkillGroup { get; set; }
private string SkillFilterText { get; set; } = string.Empty;
[Inject]
private RpgRollerApiClient ApiClient { get; set; } = null!;
[Inject] private RpgRollerApiClient ApiClient { get; set; } = null!;
[Parameter]
public bool IsCampaignDataLoading { get; set; }
[Parameter] public bool IsCampaignDataLoading { get; set; }
[Parameter]
public CampaignRoster? SelectedCampaign { get; set; }
[Parameter] public CampaignRoster? SelectedCampaign { get; set; }
[Parameter]
public Guid? SelectedCharacterId { get; set; }
[Parameter] public Guid? SelectedCharacterId { get; set; }
[Parameter]
public CharacterSummary? SelectedCharacter { get; set; }
[Parameter] public CharacterSummary? SelectedCharacter { get; set; }
[Parameter]
public bool IsMutating { get; set; }
[Parameter] public bool IsMutating { get; set; }
[Parameter]
public IReadOnlyList<CharacterSheetSkill> SelectedCharacterSkills { get; set; } = [];
[Parameter] public IReadOnlyList<CharacterSheetSkill> SelectedCharacterSkills { get; set; } = [];
[Parameter]
public IReadOnlyList<CharacterSheetSkillGroup> SelectedCharacterSkillGroups { get; set; } = [];
[Parameter] public IReadOnlyList<CharacterSheetSkillGroup> SelectedCharacterSkillGroups { get; set; } = [];
[Parameter]
public string SelectedCampaignRulesetId { get; set; } = string.Empty;
[Parameter] public string SelectedCampaignRulesetId { get; set; } = string.Empty;
[Parameter]
public string RollVisibility { get; set; } = "public";
[Parameter] public string RollVisibility { get; set; } = "public";
[Parameter]
public EventCallback<string> RollVisibilityChanged { get; set; }
[Parameter] public EventCallback<string> RollVisibilityChanged { get; set; }
[Parameter]
public Func<Guid, string> OwnerLabel { get; set; } = _ => string.Empty;
[Parameter] public Func<Guid, string> OwnerLabel { get; set; } = _ => string.Empty;
[Parameter]
public Func<CharacterSheetSkill, string> SkillDefinitionLabel { get; set; } = _ => string.Empty;
[Parameter] public Func<CharacterSheetSkill, string> SkillDefinitionLabel { get; set; } = _ => string.Empty;
[Parameter]
public Func<CharacterSummary, bool> CanEditCharacter { get; set; } = _ => false;
[Parameter] public Func<CharacterSummary, bool> CanEditCharacter { get; set; } = _ => false;
[Parameter]
public Func<CharacterSheetSkill, bool> CanEditSkill { get; set; } = _ => false;
[Parameter] public Func<CharacterSheetSkill, bool> CanEditSkill { get; set; } = _ => false;
[Parameter]
public EventCallback<Guid> CharacterSelected { get; set; }
[Parameter] public EventCallback<Guid> CharacterSelected { get; set; }
[Parameter]
public EventCallback<CharacterSummary> EditCharacterRequested { get; set; }
[Parameter] public EventCallback<CharacterSummary> EditCharacterRequested { get; set; }
[Parameter]
public EventCallback<Guid> SkillCreated { get; set; }
[Parameter] public EventCallback<Guid> SkillCreated { get; set; }
[Parameter]
public EventCallback<Guid> SkillUpdated { get; set; }
[Parameter] public EventCallback<Guid> SkillUpdated { get; set; }
[Parameter]
public EventCallback<Guid> SkillGroupCreated { get; set; }
[Parameter] public EventCallback<Guid> SkillGroupCreated { get; set; }
[Parameter]
public EventCallback<Guid> SkillGroupUpdated { get; set; }
[Parameter] public EventCallback<Guid> SkillGroupUpdated { get; set; }
[Parameter]
public EventCallback<Guid> SkillDeleted { get; set; }
[Parameter] public EventCallback<Guid> SkillDeleted { get; set; }
[Parameter]
public EventCallback<Guid> SkillGroupDeleted { get; set; }
[Parameter] public EventCallback<Guid> SkillGroupDeleted { get; set; }
[Parameter]
public EventCallback<string> ErrorOccurred { get; set; }
[Parameter] public EventCallback<string> ErrorOccurred { get; set; }
[Parameter]
public EventCallback<CharacterSheetSkill> RollRequested { get; set; }
[Parameter] public EventCallback<CharacterSheetSkill> RollRequested { get; set; }
}

View File

@@ -61,36 +61,25 @@ public partial class RolemasterSkillRollModal
private string CurrentModifierText { get; set; } = string.Empty;
private ElementReference ModifierInputElement { get; set; }
[Parameter]
public bool Visible { get; set; }
[Parameter] public bool Visible { get; set; }
[Parameter]
public string SkillName { get; set; } = string.Empty;
[Parameter] public string SkillName { get; set; } = string.Empty;
[Parameter]
public string Expression { get; set; } = string.Empty;
[Parameter] public string Expression { get; set; } = string.Empty;
[Parameter]
public string ModifierText { get; set; } = string.Empty;
[Parameter] public string ModifierText { get; set; } = string.Empty;
[Parameter]
public EventCallback<string> ModifierTextChanged { get; set; }
[Parameter] public EventCallback<string> ModifierTextChanged { get; set; }
[Parameter]
public string? ErrorMessage { get; set; }
[Parameter] public string? ErrorMessage { get; set; }
[Parameter]
public bool IsMutating { get; set; }
[Parameter] public bool IsMutating { get; set; }
[Parameter]
public bool IsSubmitting { get; set; }
[Parameter] public bool IsSubmitting { get; set; }
[Parameter]
public string ModifierInputId { get; set; } = "rolemaster-situational-modifier";
[Parameter] public string ModifierInputId { get; set; } = "rolemaster-situational-modifier";
[Parameter]
public EventCallback<string> ConfirmRequested { get; set; }
[Parameter] public EventCallback<string> ConfirmRequested { get; set; }
[Parameter]
public EventCallback CancelRequested { get; set; }
[Parameter] public EventCallback CancelRequested { get; set; }
}

View File

@@ -85,7 +85,10 @@ public partial class SkillFormModal
{
SkillSummary skill;
if (EditingSkillId.HasValue)
skill = await ApiClient.RequestAsync<SkillSummary>("PUT", $"/api/skills/{EditingSkillId.Value}", new UpdateSkillRequest(FormState.Model.Name.Trim(), FormState.Model.DiceRollDefinition.Trim(), FormState.Model.WildDice, FormState.Model.AllowFumble, skillGroupId, FormState.Model.FumbleRange, FormState.Model.RolemasterAutoRetry));
skill = await ApiClient.RequestAsync<SkillSummary>("PUT", $"/api/skills/{EditingSkillId.Value}",
new UpdateSkillRequest(FormState.Model.Name.Trim(), FormState.Model.DiceRollDefinition.Trim(),
FormState.Model.WildDice, FormState.Model.AllowFumble, skillGroupId,
FormState.Model.FumbleRange, FormState.Model.RolemasterAutoRetry));
else
{
if (!SelectedCharacterId.HasValue)
@@ -94,7 +97,11 @@ public partial class SkillFormModal
return;
}
skill = await ApiClient.RequestAsync<SkillSummary>("POST", $"/api/characters/{SelectedCharacterId.Value}/skills", new CreateSkillRequest(FormState.Model.Name.Trim(), FormState.Model.DiceRollDefinition.Trim(), FormState.Model.WildDice, FormState.Model.AllowFumble, skillGroupId, FormState.Model.FumbleRange, FormState.Model.RolemasterAutoRetry));
skill = await ApiClient.RequestAsync<SkillSummary>("POST",
$"/api/characters/{SelectedCharacterId.Value}/skills",
new CreateSkillRequest(FormState.Model.Name.Trim(), FormState.Model.DiceRollDefinition.Trim(),
FormState.Model.WildDice, FormState.Model.AllowFumble, skillGroupId,
FormState.Model.FumbleRange, FormState.Model.RolemasterAutoRetry));
}
await SkillSaved.InvokeAsync(skill.Id);
@@ -147,12 +154,15 @@ public partial class SkillFormModal
private bool IsD6Ruleset => RulesetFormHelpers.IsD6(RulesetId);
private bool IsRolemasterRuleset => RulesetFormHelpers.IsRolemaster(RulesetId);
private bool IsRolemasterOpenEndedSelected => RulesetFormHelpers.IsRolemasterOpenEndedExpression(FormState.Model.DiceRollDefinition);
private string ExpressionHelpText => IsRolemasterRuleset ? $"{RulesetFormHelpers.RolemasterExampleText()}. Negative modifiers are allowed." : "Enter the dice expression used for this skill.";
private bool IsRolemasterOpenEndedSelected =>
RulesetFormHelpers.IsRolemasterOpenEndedExpression(FormState.Model.DiceRollDefinition);
[Inject]
private RpgRollerApiClient ApiClient { get; set; } = null!;
private string ExpressionHelpText => IsRolemasterRuleset
? $"{RulesetFormHelpers.RolemasterExampleText()}. Negative modifiers are allowed."
: "Enter the dice expression used for this skill.";
[Inject] private RpgRollerApiClient ApiClient { get; set; } = null!;
private FormState<SkillFormModel> FormState { get; } = new();
private int AppliedFormVersion { get; set; } = -1;
@@ -160,60 +170,41 @@ public partial class SkillFormModal
private bool PendingNameFocus { get; set; }
private ElementReference NameInputElement { get; set; }
[Parameter]
public bool Visible { get; set; }
[Parameter] public bool Visible { get; set; }
[Parameter]
public string RulesetId { get; set; } = string.Empty;
[Parameter] public string RulesetId { get; set; } = string.Empty;
[Parameter]
public string Title { get; set; } = "Skill";
[Parameter] public string Title { get; set; } = "Skill";
[Parameter]
public string SubmitLabel { get; set; } = "Save";
[Parameter] public string SubmitLabel { get; set; } = "Save";
[Parameter]
public string NameInputId { get; set; } = "skill-name";
[Parameter] public string NameInputId { get; set; } = "skill-name";
[Parameter]
public string ExpressionInputId { get; set; } = "skill-expression";
[Parameter] public string ExpressionInputId { get; set; } = "skill-expression";
[Parameter]
public string SkillGroupInputId { get; set; } = "skill-group";
[Parameter] public string SkillGroupInputId { get; set; } = "skill-group";
[Parameter]
public string WildDiceInputId { get; set; } = "skill-wild";
[Parameter] public string WildDiceInputId { get; set; } = "skill-wild";
[Parameter]
public string AllowFumbleInputId { get; set; } = "skill-fumble";
[Parameter] public string AllowFumbleInputId { get; set; } = "skill-fumble";
[Parameter]
public string FumbleRangeInputId { get; set; } = "skill-fumble-range";
[Parameter] public string FumbleRangeInputId { get; set; } = "skill-fumble-range";
[Parameter]
public SkillFormModel InitialModel { get; set; } = new();
[Parameter] public SkillFormModel InitialModel { get; set; } = new();
[Parameter]
public int FormVersion { get; set; }
[Parameter] public int FormVersion { get; set; }
[Parameter]
public Guid? SelectedCharacterId { get; set; }
[Parameter] public Guid? SelectedCharacterId { get; set; }
[Parameter]
public Guid? EditingSkillId { get; set; }
[Parameter] public Guid? EditingSkillId { get; set; }
[Parameter]
public IReadOnlyList<CharacterSheetSkillGroup> AvailableSkillGroups { get; set; } = [];
[Parameter] public IReadOnlyList<CharacterSheetSkillGroup> AvailableSkillGroups { get; set; } = [];
[Parameter]
public bool IsMutating { get; set; }
[Parameter] public bool IsMutating { get; set; }
[Parameter]
public bool AutoFocusName { get; set; }
[Parameter] public bool AutoFocusName { get; set; }
[Parameter]
public EventCallback<Guid> SkillSaved { get; set; }
[Parameter] public EventCallback<Guid> SkillSaved { get; set; }
[Parameter]
public EventCallback CancelRequested { get; set; }
[Parameter] public EventCallback CancelRequested { get; set; }
}

View File

@@ -0,0 +1,55 @@
<div class="rr-app" data-auth-page>
<main class="auth-shell">
<h1>RpgRoller</h1>
<p class="auth-subtitle">Register or log in to join a campaign session.</p>
<p class="status-message @(StatusIsError ? "error" : "success")"
data-auth-status
aria-live="polite"
hidden="@string.IsNullOrWhiteSpace(StatusMessage)">@StatusMessage</p>
<div class="auth-grid">
<section class="card auth-card">
<h2>Register</h2>
<p class="form-error" data-form-error hidden></p>
<form class="form-grid" data-auth-form="register" novalidate>
<label for="register-username">Username</label>
<input id="register-username" name="username" autocomplete="username"/>
<p class="field-error" data-field-error="username" hidden></p>
<label for="register-display-name">Display name</label>
<input id="register-display-name" name="displayName" autocomplete="name"/>
<p class="field-error" data-field-error="displayName" hidden></p>
<label for="register-password">Password</label>
<input id="register-password" name="password" type="password" autocomplete="new-password"/>
<p class="field-error" data-field-error="password" hidden></p>
<button type="submit" data-submit-label="Register" data-submitting-label="Registering...">Register</button>
</form>
</section>
<section class="card auth-card">
<h2>Login</h2>
<p class="form-error" data-form-error hidden></p>
<form class="form-grid" data-auth-form="login" novalidate>
<label for="login-username">Username</label>
<input id="login-username" name="username" autocomplete="username"/>
<p class="field-error" data-field-error="username" hidden></p>
<label for="login-password">Password</label>
<input id="login-password" name="password" type="password" autocomplete="current-password"/>
<p class="field-error" data-field-error="password" hidden></p>
<button type="submit" data-submit-label="Login" data-submitting-label="Logging in...">Login</button>
</form>
</section>
</div>
</main>
</div>
@code {
[Parameter]
public string? StatusMessage { get; set; }
[Parameter]
public bool StatusIsError { get; set; }
}

View File

@@ -0,0 +1 @@
@page "/login"

View File

@@ -0,0 +1,12 @@
@page "/play"
@rendermode @(new InteractiveServerRenderMode(prerender: false))
@inherits AuthenticatedPageBase
<Workspace Route="WorkspaceRoute.Play" LoggedOut="OnLoggedOutAsync">
<ChildContent Context="workspace">
<WorkspaceRouteView Workspace="workspace">
<ChildContent Context="readyWorkspace">
<PlayWorkspaceContent Workspace="readyWorkspace"/>
</ChildContent>
</WorkspaceRouteView>
</ChildContent>
</Workspace>

View File

@@ -0,0 +1,8 @@
using System.Diagnostics.CodeAnalysis;
namespace RpgRoller.Components.Pages;
[ExcludeFromCodeCoverage]
public partial class PlayPage
{
}

View File

@@ -0,0 +1,94 @@
@using Microsoft.AspNetCore.Components
@using RpgRoller.Components.Pages.HomeControls
<main class="play-screen @(Workspace.State.MobilePanel == "log" ? "mobile-log" : "mobile-character")">
<CharacterPanel
IsCampaignDataLoading="@IsCampaignDataLoading"
SelectedCampaign="Workspace.State.PlaySelectedCampaign"
SelectedCharacterId="Workspace.State.PlaySelectedCharacterId"
SelectedCharacter="Workspace.State.PlaySelectedCharacter"
IsMutating="Workspace.State.IsMutating"
SelectedCharacterSkills="Workspace.State.PlaySelectedCharacterSkills"
SelectedCharacterSkillGroups="Workspace.State.PlaySelectedCharacterSkillGroups"
SelectedCampaignRulesetId="@(Workspace.State.PlaySelectedCampaign?.RulesetId ?? string.Empty)"
RollVisibility="Workspace.State.RollVisibility"
RollVisibilityChanged="OnRollVisibilityChangedAsync"
OwnerLabel="Workspace.State.OwnerLabel"
SkillDefinitionLabel="Workspace.State.SkillDefinitionLabel"
CanEditCharacter="Workspace.Campaigns.CanEditCharacter"
CanEditSkill="Workspace.Play.CanEditSkill"
CharacterSelected="Workspace.Play.SelectCharacterAsync"
EditCharacterRequested="Workspace.Campaigns.OpenEditCharacterModal"
SkillCreated="Workspace.Play.OnSkillCreatedAsync"
SkillUpdated="Workspace.Play.OnSkillUpdatedAsync"
SkillGroupCreated="Workspace.Play.OnSkillGroupCreatedAsync"
SkillGroupUpdated="Workspace.Play.OnSkillGroupUpdatedAsync"
SkillDeleted="Workspace.Play.OnSkillDeletedAsync"
SkillGroupDeleted="Workspace.Play.OnSkillGroupDeletedAsync"
ErrorOccurred="Workspace.Play.OnCharacterPanelErrorAsync"
RollRequested="Workspace.Play.RollSkillAsync"/>
<CampaignLogPanel
IsCampaignDataLoading="@IsCampaignDataLoading"
CampaignLog="Workspace.State.PlayVisibleCampaignLog"
ExpandedRollId="Workspace.State.ExpandedCampaignLogRollId"
FreshRollId="Workspace.State.FreshCampaignLogRollId"
SelectedCharacterId="Workspace.State.PlaySelectedCharacterId"
SelectedCharacterName="@(Workspace.State.PlaySelectedCharacter?.Name)"
SelectedCampaignRulesetId="@(Workspace.State.PlaySelectedCampaign?.RulesetId ?? string.Empty)"
RollVisibility="Workspace.State.RollVisibility"
ResolveRollVisibility="ResolveRollVisibility"
IsMutating="Workspace.State.IsMutating"
ToggleRollDetailRequested="Workspace.Play.ToggleRollDetailAsync"
ResolveRollDetail="Workspace.Play.ResolveRollDetail"
IsRollDetailLoading="Workspace.Play.IsRollDetailLoading"
GetRollDetailError="Workspace.Play.GetRollDetailError"
CustomRollCreated="Workspace.Play.OnCustomRollCreatedAsync"
ErrorOccurred="Workspace.Play.OnCampaignLogPanelErrorAsync"/>
</main>
<nav class="mobile-bottom-nav" aria-label="Play panel selector">
<button type="button" class="switch @(Workspace.State.MobilePanel == "character" ? "active" : string.Empty)"
@onclick='() => Workspace.Scope.SetMobilePanelAsync("character")'>
Character
</button>
<button type="button" class="switch @(Workspace.State.MobilePanel == "log" ? "active" : string.Empty)"
@onclick='() => Workspace.Scope.SetMobilePanelAsync("log")'>
Log
</button>
</nav>
<CharacterManagementModals Workspace="Workspace"/>
<RolemasterSkillRollModal
Visible="Workspace.State.ShowRolemasterSkillRollModal"
SkillName="@(Workspace.State.PendingRolemasterSkillRoll?.Name ?? string.Empty)"
Expression="@(Workspace.State.PendingRolemasterSkillRoll?.DiceRollDefinition ?? string.Empty)"
ModifierText="@Workspace.State.PendingRolemasterSituationalModifier"
ModifierTextChanged="@(text => Workspace.State.PendingRolemasterSituationalModifier = text)"
ErrorMessage="@Workspace.State.PendingRolemasterSkillRollError"
IsMutating="Workspace.State.IsMutating"
IsSubmitting="Workspace.State.IsSubmittingRolemasterSkillRoll"
ConfirmRequested="Workspace.Play.SubmitRolemasterSkillRollAsync"
CancelRequested="Workspace.Play.CancelRolemasterSkillRollAsync"/>
@code {
[Parameter, EditorRequired] public WorkspacePageContext Workspace { get; set; } = null!;
private bool IsCampaignDataLoading => !Workspace.HasSessionInitialized || Workspace.State.IsCampaignDataLoading;
private async Task OnRollVisibilityChangedAsync(string visibility)
{
var normalizedVisibility = string.Equals(visibility, "private", StringComparison.OrdinalIgnoreCase)
? "private"
: "public";
Workspace.State.RollVisibility = normalizedVisibility;
await Workspace.Session.OnRollVisibilityChangedAsync(visibility);
await InvokeAsync(StateHasChanged);
}
private string ResolveRollVisibility()
{
return Workspace.State.RollVisibility;
}
}

View File

@@ -1,5 +1,5 @@
@using RpgRoller.Components.Pages.HomeControls
<div class="@State.AppCssClass">
<div class="@AppCssClass">
<p class="sr-only" aria-live="polite">@State.LiveAnnouncement</p>
@if (State.HasHealthIssue)
@@ -16,9 +16,9 @@
<div class="workspace-shell">
<AppHeader
User="State.User"
ShowCampaign="true"
ShowCampaign="@ShowCampaignInHeader"
CampaignName="@State.SelectedCampaignName"
ShowConnectionState="true"
ShowConnectionState="@ShowConnectionStateInHeader"
ConnectionStateLabel="@State.ConnectionStateLabel"
ConnectionStateCssClass="@State.ConnectionStateCssClass"
IsMenuOpen="State.IsScreenMenuOpen"
@@ -28,146 +28,9 @@
ToggleMenuRequested="ToggleScreenMenu"
LogoutRequested="Session.LogoutAsync"/>
@if (State.IsPlayScreen)
@if (ChildContent is not null)
{
<main class="play-screen @(State.MobilePanel == "log" ? "mobile-log" : "mobile-character")">
<CharacterPanel
IsCampaignDataLoading="State.IsCampaignDataLoading"
SelectedCampaign="State.PlaySelectedCampaign"
SelectedCharacterId="State.PlaySelectedCharacterId"
SelectedCharacter="State.PlaySelectedCharacter"
IsMutating="State.IsMutating"
SelectedCharacterSkills="State.PlaySelectedCharacterSkills"
SelectedCharacterSkillGroups="State.PlaySelectedCharacterSkillGroups"
SelectedCampaignRulesetId="@(State.PlaySelectedCampaign?.RulesetId ?? string.Empty)"
RollVisibility="State.RollVisibility"
RollVisibilityChanged="Session.OnRollVisibilityChangedAsync"
OwnerLabel="State.OwnerLabel"
SkillDefinitionLabel="State.SkillDefinitionLabel"
CanEditCharacter="Campaigns.CanEditCharacter"
CanEditSkill="Play.CanEditSkill"
CharacterSelected="Play.SelectCharacterAsync"
EditCharacterRequested="Campaigns.OpenEditCharacterModal"
SkillCreated="Play.OnSkillCreatedAsync"
SkillUpdated="Play.OnSkillUpdatedAsync"
SkillGroupCreated="Play.OnSkillGroupCreatedAsync"
SkillGroupUpdated="Play.OnSkillGroupUpdatedAsync"
SkillDeleted="Play.OnSkillDeletedAsync"
SkillGroupDeleted="Play.OnSkillGroupDeletedAsync"
ErrorOccurred="Play.OnCharacterPanelErrorAsync"
RollRequested="Play.RollSkillAsync"/>
<CampaignLogPanel
IsCampaignDataLoading="State.IsCampaignDataLoading"
CampaignLog="State.PlayVisibleCampaignLog"
ExpandedRollId="State.ExpandedCampaignLogRollId"
FreshRollId="State.FreshCampaignLogRollId"
SelectedCharacterId="State.PlaySelectedCharacterId"
SelectedCharacterName="@(State.PlaySelectedCharacter?.Name)"
SelectedCampaignRulesetId="@(State.PlaySelectedCampaign?.RulesetId ?? string.Empty)"
RollVisibility="State.RollVisibility"
IsMutating="State.IsMutating"
ToggleRollDetailRequested="Play.ToggleRollDetailAsync"
ResolveRollDetail="Play.ResolveRollDetail"
IsRollDetailLoading="Play.IsRollDetailLoading"
GetRollDetailError="Play.GetRollDetailError"
CustomRollCreated="Play.OnCustomRollCreatedAsync"
ErrorOccurred="Play.OnCampaignLogPanelErrorAsync"/>
</main>
<nav class="mobile-bottom-nav" aria-label="Play panel selector">
<button type="button" class="switch @(State.MobilePanel == "character" ? "active" : string.Empty)"
@onclick='() => Scope.SetMobilePanelAsync("character")'>
Character
</button>
<button type="button" class="switch @(State.MobilePanel == "log" ? "active" : string.Empty)"
@onclick='() => Scope.SetMobilePanelAsync("log")'>
Log
</button>
</nav>
}
else if (State.IsManagementScreen)
{
<CampaignManagementPanel
Campaigns="State.Campaigns"
SelectedCampaignId="State.SelectedCampaignId"
SelectedCampaign="State.SelectedCampaign"
Rulesets="State.Rulesets"
IsMutating="State.IsMutating"
OwnerLabel="State.OwnerLabel"
CanEditCharacter="Campaigns.CanEditCharacter"
CanDeleteCharacter="Campaigns.CanDeleteCharacter"
CanDeleteCampaign="State.CanDeleteSelectedCampaign"
CampaignSelectionChanged="Campaigns.OnCampaignSelectionChangedAsync"
CampaignCreated="Campaigns.OnCampaignCreatedAsync"
DeleteCampaignRequested="Campaigns.DeleteSelectedCampaignAsync"
CreateCharacterRequested="Campaigns.OpenCreateCharacterModal"
EditCharacterRequested="Campaigns.OpenEditCharacterModal"
DeleteCharacterRequested="Campaigns.DeleteCharacterAsync"/>
}
else if (State.IsAdminScreen)
{
<main class="management-screen">
@if (State.IsCurrentUserAdmin)
{
<section class="card">
<div class="section-head">
<h2>Database</h2>
</div>
<p class="muted">Download the current SQLite file for backup or offline inspection.</p>
<div class="management-actions">
<a class="action-link" href="@AdminDatabaseDownloadUrl" download>Download SQLite database</a>
</div>
</section>
}
<section class="card">
<div class="section-head">
<h2>User Management</h2>
</div>
@if (State.IsAdminDataLoading)
{
<p class="empty">Loading users...</p>
}
else if (!State.IsCurrentUserAdmin)
{
<p class="empty">Admin role is required to manage users.</p>
}
else if (State.AdminUsers.Count == 0)
{
<p class="empty">No users found.</p>
}
else
{
<ul class="management-list">
@foreach (var user in State.AdminUsers)
{
<li>
<div>
<strong>@user.Username</strong>
<p class="muted">@user.DisplayName</p>
<p class="muted">Roles: @(user.Roles.Count == 0 ? "none" : string.Join(", ", user.Roles))</p>
</div>
<div class="skill-chip-actions">
<button type="button"
class="chip-button"
disabled="@(State.IsMutating || user.Id == State.User?.Id)"
@onclick="() => Admin.ToggleAdminRoleAsync(user)">
<span aria-hidden="true" class="emoji">🛡️</span>
<span class="sr-only">Toggle admin role for @user.Username</span>
</button>
<button type="button"
class="chip-button"
disabled="@(State.IsMutating || user.Id == State.User?.Id)"
@onclick="() => Admin.DeleteUserAsync(user)">
<span aria-hidden="true" class="emoji">🗑️</span>
<span class="sr-only">Delete user @user.Username</span>
</button>
</div>
</li>
}
</ul>
}
</section>
</main>
@ChildContent(PageContext)
}
</div>
@@ -183,49 +46,3 @@
</div>
}
</div>
<CharacterFormModal
Visible="State.ShowCreateCharacterModal"
Title="Create Character"
SubmitLabel="Create Character"
NameInputId="character-create-name"
CampaignInputId="character-create-campaign"
OwnerUsernameInputId="character-create-owner"
InitialModel="State.CreateCharacterInitialModel"
FormVersion="State.CreateCharacterFormVersion"
EditingCharacterId="null"
CampaignOptions="State.CharacterCampaignOptions"
IsMutating="State.IsMutating"
AllowOwnerEdit="false"
AvailableUsernames="State.KnownUsernames"
CharacterSaved="Campaigns.OnCharacterCreatedAsync"
CancelRequested="Campaigns.CloseCharacterModals"/>
<CharacterFormModal
Visible="State.ShowEditCharacterModal"
Title="Edit Character"
SubmitLabel="Save Character"
NameInputId="character-edit-name"
CampaignInputId="character-edit-campaign"
OwnerUsernameInputId="character-edit-owner"
InitialModel="State.EditCharacterInitialModel"
FormVersion="State.EditCharacterFormVersion"
EditingCharacterId="State.EditingCharacterId"
CampaignOptions="State.CharacterCampaignOptions"
IsMutating="State.IsMutating"
AllowOwnerEdit="State.CanEditCharacterOwner"
AvailableUsernames="State.KnownUsernames"
CharacterSaved="Campaigns.OnCharacterUpdatedAsync"
CancelRequested="Campaigns.CloseCharacterModals"/>
<RolemasterSkillRollModal
Visible="State.ShowRolemasterSkillRollModal"
SkillName="@(State.PendingRolemasterSkillRoll?.Name ?? string.Empty)"
Expression="@(State.PendingRolemasterSkillRoll?.DiceRollDefinition ?? string.Empty)"
ModifierText="@State.PendingRolemasterSituationalModifier"
ModifierTextChanged="@(text => State.PendingRolemasterSituationalModifier = text)"
ErrorMessage="@State.PendingRolemasterSkillRollError"
IsMutating="State.IsMutating"
IsSubmitting="State.IsSubmittingRolemasterSkillRoll"
ConfirmRequested="Play.SubmitRolemasterSkillRollAsync"
CancelRequested="Play.CancelRolemasterSkillRollAsync"/>

View File

@@ -9,14 +9,9 @@ namespace RpgRoller.Components.Pages;
[ExcludeFromCodeCoverage]
public partial class Workspace : IAsyncDisposable
{
protected override async Task OnAfterRenderAsync(bool firstRender)
protected override void OnParametersSet()
{
State.HasInteractiveRenderStarted = true;
if (!firstRender)
return;
await Session.InitializeAsync();
await InvokeAsync(StateHasChanged);
State.IsScreenMenuOpen = false;
}
[JSInvokable]
@@ -82,41 +77,102 @@ public partial class Workspace : IAsyncDisposable
State.IsScreenMenuOpen = !State.IsScreenMenuOpen;
}
private Task NavigateToRouteAsync(string route)
{
State.IsScreenMenuOpen = false;
Navigation.NavigateTo(route, forceLoad: true);
return Task.CompletedTask;
}
private Task RedirectToPlayAsync()
{
if (IsPlayRoute)
return Task.CompletedTask;
Navigation.NavigateTo("/play", forceLoad: true);
return Task.CompletedTask;
}
private Task RequestRefreshAsync()
{
return InvokeAsync(StateHasChanged);
}
private Task InitializeRouteAsync()
{
return InitializationTask ??= InitializeRouteCoreAsync();
}
private async Task InitializeRouteCoreAsync()
{
if (HasSessionInitialized)
return;
State.HasInteractiveRenderStarted = true;
await Session.InitializeAsync();
HasSessionInitialized = true;
await RequestRefreshAsync();
}
private static bool IsStaticRenderInteropException(InvalidOperationException exception)
{
return exception.Message.Contains("statically rendered", StringComparison.OrdinalIgnoreCase);
}
[Inject]
private IJSRuntime JS { get; set; } = null!;
[Inject] private IJSRuntime JS { get; set; } = null!;
[Inject]
private RpgRollerApiClient ApiClient { get; set; } = null!;
[Inject] private RpgRollerApiClient ApiClient { get; set; } = null!;
[Inject]
private WorkspaceQueryService WorkspaceQuery { get; set; } = null!;
[Inject] private WorkspaceQueryService WorkspaceQuery { get; set; } = null!;
[Inject]
private NavigationManager Navigation { get; set; } = null!;
[Inject] private NavigationManager Navigation { get; set; } = null!;
[Parameter]
public EventCallback<string?> LoggedOut { get; set; }
[Parameter] public EventCallback<string?> LoggedOut { get; set; }
[Parameter] public WorkspaceRoute Route { get; set; } = WorkspaceRoute.Play;
[Parameter] public RenderFragment<WorkspacePageContext>? ChildContent { get; set; }
private WorkspaceState State { get; } = new();
private bool HasSessionInitialized { get; set; }
private bool IsPlayRoute => Route == WorkspaceRoute.Play;
private bool IsCampaignsRoute => Route == WorkspaceRoute.Campaigns;
private bool IsAdminRoute => Route == WorkspaceRoute.Admin;
private string AppCssClass => IsPlayRoute ? "rr-app app-play" : "rr-app";
private bool ShowCampaignInHeader => !IsAdminRoute;
private bool ShowConnectionStateInHeader => IsPlayRoute;
private WorkspaceCampaignScopeCoordinator Scope => m_Scope ??= new(State, Feedback, JS, WorkspaceQuery, Play.EnsureSelectedCharacterActiveAsync, Play.RefreshSelectedCharacterSheetAsync, Play.RefreshCampaignLogAsync, Play.ResetCampaignLogDetailState, Play.ResetCampaignStateTracking, ClearAuthenticatedState, StopStateEventsAsync, message => LoggedOut.InvokeAsync(message));
private WorkspacePageContext PageContext => new(State, Play, Campaigns, Admin, Scope, Session,
InitializeRouteAsync, HasSessionInitialized, RequestRefreshAsync, AdminDatabaseDownloadUrl, HeaderMenuItems,
IsPlayRoute, IsCampaignsRoute, IsAdminRoute);
private WorkspaceLiveStateController Live => m_Live ??= new(State, Feedback, StartStateEventsCoreAsync, StopStateEventsCoreAsync, Scope.RefreshCampaignRosterAsync, Play.RefreshSelectedCharacterSheetAsync, Play.RefreshCampaignLogAsync, () => InvokeAsync(StateHasChanged));
private WorkspaceCampaignScopeCoordinator Scope => m_Scope ??= new(State, Feedback, JS, WorkspaceQuery,
() => IsPlayRoute, Play.EnsureSelectedCharacterActiveAsync, Play.RefreshSelectedCharacterSheetAsync,
Play.RefreshCampaignLogAsync, Play.ResetCampaignLogDetailState, Play.ResetCampaignStateTracking,
ClearAuthenticatedState,
StopStateEventsAsync, message => LoggedOut.InvokeAsync(message));
private WorkspacePlayCoordinator Play => m_Play ??= new(State, Feedback, ApiClient, WorkspaceQuery, CanEditCharacter, () => InvokeAsync(StateHasChanged));
private WorkspaceLiveStateController Live => m_Live ??=
new(State, Feedback, () => IsPlayRoute, () => IsAdminRoute, StartStateEventsCoreAsync,
StopStateEventsCoreAsync, Scope.RefreshCampaignRosterAsync, Play.RefreshSelectedCharacterSheetAsync,
Play.RefreshCampaignLogAsync, RequestRefreshAsync);
private WorkspaceCampaignCoordinator Campaigns => m_Campaigns ??= new(State, Feedback, JS, ApiClient, Session.LoadKnownUsernamesAsync, Scope.ReloadCampaignsAsync, Scope.ReloadCharacterCampaignOptionsAsync, Scope.RefreshCampaignScopeAsync, Live.SyncStateEventsAsync);
private WorkspacePlayCoordinator Play => m_Play ??= new(State, Feedback, () => IsPlayRoute, ApiClient,
WorkspaceQuery,
CanEditCharacter, RequestRefreshAsync);
private WorkspaceAdminCoordinator Admin => m_Admin ??= new(State, Feedback, JS, ApiClient, WorkspaceQuery, ClearAuthenticatedState, StopStateEventsAsync, message => LoggedOut.InvokeAsync(message));
private WorkspaceCampaignCoordinator Campaigns => m_Campaigns ??= new(State, Feedback, JS, ApiClient,
Session.LoadKnownUsernamesAsync, Scope.ReloadCampaignsAsync, Scope.ReloadCharacterCampaignOptionsAsync,
Scope.RefreshCampaignScopeAsync, Live.SyncStateEventsAsync, RequestRefreshAsync);
private WorkspaceFeedbackService Feedback => m_Feedback ??= new(State, () => InvokeAsync(StateHasChanged));
private WorkspaceAdminCoordinator Admin => m_Admin ??= new(State, Feedback, JS, ApiClient, WorkspaceQuery,
ClearAuthenticatedState, StopStateEventsAsync, message => LoggedOut.InvokeAsync(message));
private WorkspaceSessionCoordinator Session => m_Session ??= new(State, Feedback, JS, ApiClient, WorkspaceQuery, Scope.ReloadCampaignsAsync, Scope.ReloadCharacterCampaignOptionsAsync, Scope.RefreshCampaignScopeAsync, Live.SyncStateEventsAsync, Live.StopStateEventsAsync, EnsureAdminUsersLoadedAsync, Play.ResetCampaignLogDetailState, () => InvokeAsync(StateHasChanged), message => LoggedOut.InvokeAsync(message));
private WorkspaceFeedbackService Feedback => m_Feedback ??= new(State, RequestRefreshAsync);
private WorkspaceSessionCoordinator Session => m_Session ??= new(State, Feedback, JS, ApiClient, WorkspaceQuery,
() => IsAdminRoute, RedirectToPlayAsync,
Scope.ReloadCampaignsAsync, Scope.ReloadCharacterCampaignOptionsAsync, Scope.RefreshCampaignScopeAsync,
RequestRefreshAsync, Live.SyncStateEventsAsync, Live.StopStateEventsAsync, EnsureAdminUsersLoadedAsync,
Play.ResetCampaignLogDetailState, message => LoggedOut.InvokeAsync(message));
private IReadOnlyList<AppHeaderMenuItem> HeaderMenuItems
{
@@ -127,14 +183,14 @@ public partial class Workspace : IAsyncDisposable
new()
{
Label = "Play",
IsActive = State.IsPlayScreen,
OnSelected = () => Session.SwitchScreenAsync("play")
IsActive = IsPlayRoute,
OnSelected = () => NavigateToRouteAsync("/play")
},
new()
{
Label = "Campaign Management",
IsActive = State.IsManagementScreen,
OnSelected = () => Session.SwitchScreenAsync("management")
IsActive = IsCampaignsRoute,
OnSelected = () => NavigateToRouteAsync("/campaigns")
}
};
@@ -143,8 +199,8 @@ public partial class Workspace : IAsyncDisposable
items.Add(new()
{
Label = "Admin",
IsActive = State.IsAdminScreen,
OnSelected = () => Session.SwitchScreenAsync(ScreenAdmin)
IsActive = IsAdminRoute,
OnSelected = () => NavigateToRouteAsync("/admin")
});
}
@@ -155,7 +211,6 @@ public partial class Workspace : IAsyncDisposable
private string AdminDatabaseDownloadUrl => Navigation.ToAbsoluteUri("api/admin/database").ToString();
private DotNetObjectReference<Workspace>? DotNetRef { get; set; }
private const string ScreenAdmin = "admin";
private WorkspaceAdminCoordinator? m_Admin;
private WorkspaceCampaignCoordinator? m_Campaigns;
private WorkspaceFeedbackService? m_Feedback;
@@ -164,4 +219,5 @@ public partial class Workspace : IAsyncDisposable
private WorkspaceCampaignScopeCoordinator? m_Scope;
private WorkspaceSessionCoordinator? m_Session;
private Task? InitializationTask { get; set; }
}

View File

@@ -6,7 +6,17 @@ using RpgRoller.Contracts;
namespace RpgRoller.Components.Pages;
[ExcludeFromCodeCoverage]
public sealed class WorkspaceCampaignCoordinator(WorkspaceState state, WorkspaceFeedbackService feedback, IJSRuntime js, RpgRollerApiClient apiClient, Func<Task> loadKnownUsernamesAsync, Func<Guid?, Task> reloadCampaignsAsync, Func<Task> reloadCharacterCampaignOptionsAsync, Func<Task> refreshCampaignScopeAsync, Func<Task> syncStateEventsAsync)
public sealed class WorkspaceCampaignCoordinator(
WorkspaceState state,
WorkspaceFeedbackService feedback,
IJSRuntime js,
RpgRollerApiClient apiClient,
Func<Task> loadKnownUsernamesAsync,
Func<Guid?, Task> reloadCampaignsAsync,
Func<Task> reloadCharacterCampaignOptionsAsync,
Func<Task> refreshCampaignScopeAsync,
Func<Task> syncStateEventsAsync,
Func<Task> requestRefreshAsync)
{
public async Task OnCampaignSelectionChangedAsync(ChangeEventArgs args)
{
@@ -27,6 +37,7 @@ public sealed class WorkspaceCampaignCoordinator(WorkspaceState state, Workspace
await refreshCampaignScopeAsync();
await syncStateEventsAsync();
feedback.SetStatus("Campaign created.", false);
await requestRefreshAsync();
}
public void OpenCreateCharacterModal()
@@ -34,7 +45,8 @@ public sealed class WorkspaceCampaignCoordinator(WorkspaceState state, Workspace
state.CreateCharacterInitialModel = new()
{
Name = string.Empty,
CampaignId = state.SelectedCampaignId?.ToString() ?? state.CharacterCampaignOptions.FirstOrDefault()?.Id.ToString() ?? string.Empty,
CampaignId = state.SelectedCampaignId?.ToString() ??
state.CharacterCampaignOptions.FirstOrDefault()?.Id.ToString() ?? string.Empty,
OwnerUsername = string.Empty
};
@@ -77,6 +89,7 @@ public sealed class WorkspaceCampaignCoordinator(WorkspaceState state, Workspace
await refreshCampaignScopeAsync();
await syncStateEventsAsync();
feedback.SetStatus("Character created.", false);
await requestRefreshAsync();
}
public async Task OnCharacterUpdatedAsync(Guid? campaignId)
@@ -87,6 +100,7 @@ public sealed class WorkspaceCampaignCoordinator(WorkspaceState state, Workspace
await refreshCampaignScopeAsync();
await syncStateEventsAsync();
feedback.SetStatus(campaignId.HasValue ? "Character updated." : "Character unlinked from campaign.", false);
await requestRefreshAsync();
}
public async Task DeleteSelectedCampaignAsync()
@@ -115,6 +129,7 @@ public sealed class WorkspaceCampaignCoordinator(WorkspaceState state, Workspace
finally
{
state.IsMutating = false;
await requestRefreshAsync();
}
}
@@ -144,12 +159,14 @@ public sealed class WorkspaceCampaignCoordinator(WorkspaceState state, Workspace
finally
{
state.IsMutating = false;
await requestRefreshAsync();
}
}
public bool CanEditCharacter(CharacterSummary character)
{
return state.User is not null && (character.OwnerUserId == state.User.Id || state.IsCurrentUserGm || state.IsCurrentUserAdmin);
return state.User is not null &&
(character.OwnerUserId == state.User.Id || state.IsCurrentUserGm || state.IsCurrentUserAdmin);
}
public bool CanDeleteCharacter(CharacterSummary character)

View File

@@ -4,7 +4,20 @@ using Microsoft.JSInterop;
namespace RpgRoller.Components.Pages;
[ExcludeFromCodeCoverage]
public sealed class WorkspaceCampaignScopeCoordinator(WorkspaceState state, WorkspaceFeedbackService feedback, IJSRuntime js, WorkspaceQueryService workspaceQuery, Func<Task> ensureSelectedCharacterActiveAsync, Func<Task> refreshSelectedCharacterSheetAsync, Func<Guid?, Task> refreshCampaignLogAsync, Action resetCampaignLogDetailState, Action resetCampaignStateTracking, Action clearAuthenticatedState, Func<Task> stopStateEventsAsync, Func<string?, Task> onLoggedOutAsync)
public sealed class WorkspaceCampaignScopeCoordinator(
WorkspaceState state,
WorkspaceFeedbackService feedback,
IJSRuntime js,
WorkspaceQueryService workspaceQuery,
Func<bool> isPlayRoute,
Func<Task> ensureSelectedCharacterActiveAsync,
Func<Task> refreshSelectedCharacterSheetAsync,
Func<Guid?, Task> refreshCampaignLogAsync,
Action resetCampaignLogDetailState,
Action resetCampaignStateTracking,
Action clearAuthenticatedState,
Func<Task> stopStateEventsAsync,
Func<string?, Task> onLoggedOutAsync)
{
public async Task ReloadCampaignsAsync(Guid? preferredCampaignId)
{
@@ -24,13 +37,15 @@ public sealed class WorkspaceCampaignScopeCoordinator(WorkspaceState state, Work
else if (!state.SelectedCampaignId.HasValue || !campaignIds.Contains(state.SelectedCampaignId.Value))
state.SelectedCampaignId = state.Campaigns[0].Id;
await js.InvokeVoidAsync("rpgRollerApi.setSessionValue", CampaignSessionKey, state.SelectedCampaignId?.ToString());
await js.InvokeVoidAsync("rpgRollerApi.setSessionValue", CampaignSessionKey,
state.SelectedCampaignId?.ToString());
}
public async Task ReloadCharacterCampaignOptionsAsync()
{
var campaignOptions = await workspaceQuery.GetCharacterCampaignOptionsAsync();
state.CharacterCampaignOptions = campaignOptions.OrderBy(campaign => campaign.Name, StringComparer.OrdinalIgnoreCase).ToList();
state.CharacterCampaignOptions =
campaignOptions.OrderBy(campaign => campaign.Name, StringComparer.OrdinalIgnoreCase).ToList();
}
public async Task RefreshCampaignRosterAsync()
@@ -45,7 +60,8 @@ public sealed class WorkspaceCampaignScopeCoordinator(WorkspaceState state, Work
state.SelectedCampaign = await workspaceQuery.GetCampaignAsync(state.SelectedCampaignId.Value);
SyncSelectedCharacter();
if (state.IsPlayScreen && state.PlaySelectedCharacterId.HasValue && state.SelectedCharacterId != state.PlaySelectedCharacterId)
if (isPlayRoute() && state.PlaySelectedCharacterId.HasValue &&
state.SelectedCharacterId != state.PlaySelectedCharacterId)
state.SelectedCharacterId = state.PlaySelectedCharacterId;
await ensureSelectedCharacterActiveAsync();
@@ -71,10 +87,23 @@ public sealed class WorkspaceCampaignScopeCoordinator(WorkspaceState state, Work
try
{
await RefreshCampaignRosterAsync();
if (isPlayRoute())
{
await refreshSelectedCharacterSheetAsync();
await refreshCampaignLogAsync(null);
resetCampaignStateTracking();
}
else
{
state.SelectedCharacterSkills = [];
state.SelectedCharacterSkillGroups = [];
state.CampaignLog = [];
state.ConnectionState = "offline";
state.CurrentCampaignState = null;
state.CampaignLogCursor = null;
resetCampaignLogDetailState();
}
}
catch (ApiRequestException ex) when (ex.StatusCode == 401)
{
clearAuthenticatedState();

View File

@@ -4,7 +4,17 @@ using RpgRoller.Contracts;
namespace RpgRoller.Components.Pages;
[ExcludeFromCodeCoverage]
public sealed class WorkspaceLiveStateController(WorkspaceState state, WorkspaceFeedbackService feedback, Func<Guid, Task> startStateEventsAsync, Func<Task> stopStateEventsCoreAsync, Func<Task> refreshCampaignRosterAsync, Func<Task> refreshSelectedCharacterSheetAsync, Func<Guid?, Task> refreshCampaignLogAsync, Func<Task> requestRefreshAsync)
public sealed class WorkspaceLiveStateController(
WorkspaceState state,
WorkspaceFeedbackService feedback,
Func<bool> isPlayRoute,
Func<bool> isAdminRoute,
Func<Guid, Task> startStateEventsAsync,
Func<Task> stopStateEventsCoreAsync,
Func<Task> refreshCampaignRosterAsync,
Func<Task> refreshSelectedCharacterSheetAsync,
Func<Guid?, Task> refreshCampaignLogAsync,
Func<Task> requestRefreshAsync)
{
public async Task OnStateEventReceivedAsync(CampaignStateSnapshot state1)
{
@@ -27,15 +37,17 @@ public sealed class WorkspaceLiveStateController(WorkspaceState state, Workspace
var previousSelectedCharacterId = state.SelectedCharacterId;
var previousSelectedCharacterVersion = GetCharacterVersion(previousState, previousSelectedCharacterId);
var rosterChanged = state1.RosterVersion != previousState.RosterVersion;
var logChanged = state.IsPlayScreen && state1.LogVersion != previousState.LogVersion;
var logChanged = isPlayRoute() && state1.LogVersion != previousState.LogVersion;
if (rosterChanged)
await refreshCampaignRosterAsync();
var selectedCharacterChanged = previousSelectedCharacterId != state.SelectedCharacterId;
var selectedCharacterVersionChanged = state.IsPlayScreen && !selectedCharacterChanged && GetCharacterVersion(state1, state.SelectedCharacterId) != previousSelectedCharacterVersion;
var selectedCharacterVersionChanged = isPlayRoute() && !selectedCharacterChanged &&
GetCharacterVersion(state1, state.SelectedCharacterId) !=
previousSelectedCharacterVersion;
if (state.IsPlayScreen && (selectedCharacterChanged || selectedCharacterVersionChanged))
if (isPlayRoute() && (selectedCharacterChanged || selectedCharacterVersionChanged))
await refreshSelectedCharacterSheetAsync();
if (logChanged)
@@ -70,7 +82,7 @@ public sealed class WorkspaceLiveStateController(WorkspaceState state, Workspace
public async Task SyncStateEventsAsync()
{
if (state.User is null || !state.SelectedCampaignId.HasValue || state.IsAdminScreen)
if (state.User is null || !state.SelectedCampaignId.HasValue || isAdminRoute() || !isPlayRoute())
{
await StopStateEventsAsync();
state.ConnectionState = "offline";
@@ -94,6 +106,7 @@ public sealed class WorkspaceLiveStateController(WorkspaceState state, Workspace
if (!characterId.HasValue)
return 0;
return snapshot.CharacterVersions.FirstOrDefault(version => version.CharacterId == characterId.Value)?.Version ?? 0;
return snapshot.CharacterVersions.FirstOrDefault(version => version.CharacterId == characterId.Value)
?.Version ?? 0;
}
}

View File

@@ -0,0 +1,35 @@
using RpgRoller.Components.Pages.HomeControls;
namespace RpgRoller.Components.Pages;
public sealed class WorkspacePageContext(
WorkspaceState state,
WorkspacePlayCoordinator play,
WorkspaceCampaignCoordinator campaigns,
WorkspaceAdminCoordinator admin,
WorkspaceCampaignScopeCoordinator scope,
WorkspaceSessionCoordinator session,
Func<Task> initializeRouteAsync,
bool hasSessionInitialized,
Func<Task> requestRefreshAsync,
string adminDatabaseDownloadUrl,
IReadOnlyList<AppHeaderMenuItem> headerMenuItems,
bool isPlayRoute,
bool isCampaignsRoute,
bool isAdminRoute)
{
public WorkspaceState State { get; } = state;
public WorkspacePlayCoordinator Play { get; } = play;
public WorkspaceCampaignCoordinator Campaigns { get; } = campaigns;
public WorkspaceAdminCoordinator Admin { get; } = admin;
public WorkspaceCampaignScopeCoordinator Scope { get; } = scope;
public WorkspaceSessionCoordinator Session { get; } = session;
public Func<Task> InitializeRouteAsync { get; } = initializeRouteAsync;
public bool HasSessionInitialized { get; } = hasSessionInitialized;
public Func<Task> RequestRefreshAsync { get; } = requestRefreshAsync;
public string AdminDatabaseDownloadUrl { get; } = adminDatabaseDownloadUrl;
public IReadOnlyList<AppHeaderMenuItem> HeaderMenuItems { get; } = headerMenuItems;
public bool IsPlayRoute { get; } = isPlayRoute;
public bool IsCampaignsRoute { get; } = isCampaignsRoute;
public bool IsAdminRoute { get; } = isAdminRoute;
}

View File

@@ -5,11 +5,18 @@ using RpgRoller.Contracts;
namespace RpgRoller.Components.Pages;
[ExcludeFromCodeCoverage]
public sealed class WorkspacePlayCoordinator(WorkspaceState state, WorkspaceFeedbackService feedback, RpgRollerApiClient apiClient, WorkspaceQueryService workspaceQuery, Func<CharacterSummary, bool> canEditCharacter, Func<Task> requestRefreshAsync)
public sealed class WorkspacePlayCoordinator(
WorkspaceState state,
WorkspaceFeedbackService feedback,
Func<bool> isPlayRoute,
RpgRollerApiClient apiClient,
WorkspaceQueryService workspaceQuery,
Func<CharacterSummary, bool> canEditCharacter,
Func<Task> requestRefreshAsync)
{
public async Task RefreshCampaignLogAsync(Guid? afterRollId = null)
{
if (!state.SelectedCampaignId.HasValue || !state.IsPlayScreen)
if (!state.SelectedCampaignId.HasValue || !isPlayRoute())
{
state.CampaignLog = [];
state.CampaignLogCursor = null;
@@ -18,7 +25,8 @@ public sealed class WorkspacePlayCoordinator(WorkspaceState state, WorkspaceFeed
}
var previousLogCount = state.CampaignLog.Count;
var page = await workspaceQuery.GetCampaignLogPageAsync(state.SelectedCampaignId.Value, afterRollId, CampaignLogWindowSize);
var page = await workspaceQuery.GetCampaignLogPageAsync(state.SelectedCampaignId.Value, afterRollId,
CampaignLogWindowSize);
Guid? newestRollId = null;
if (!afterRollId.HasValue || page.ResetRequired)
state.CampaignLog = page.Entries.ToList();
@@ -30,7 +38,8 @@ public sealed class WorkspacePlayCoordinator(WorkspaceState state, WorkspaceFeed
}
var shouldAutoExpandNewest = afterRollId.HasValue && page.Entries.Length > 0;
if (!shouldAutoExpandNewest && !afterRollId.HasValue && state.CurrentCampaignState is not null && previousLogCount == 0 && page.Entries.Length > 0)
if (!shouldAutoExpandNewest && !afterRollId.HasValue && state.CurrentCampaignState is not null &&
previousLogCount == 0 && page.Entries.Length > 0)
shouldAutoExpandNewest = true;
if (shouldAutoExpandNewest)
@@ -58,7 +67,7 @@ public sealed class WorkspacePlayCoordinator(WorkspaceState state, WorkspaceFeed
public async Task RefreshSelectedCharacterSheetAsync()
{
if (!state.SelectedCharacterId.HasValue || state.SelectedCampaign is null || !state.IsPlayScreen)
if (!state.SelectedCharacterId.HasValue || state.SelectedCampaign is null || !isPlayRoute())
{
state.SelectedCharacterSkills = [];
state.SelectedCharacterSkillGroups = [];
@@ -66,8 +75,10 @@ public sealed class WorkspacePlayCoordinator(WorkspaceState state, WorkspaceFeed
}
var sheet = await workspaceQuery.GetCharacterSheetAsync(state.SelectedCharacterId.Value);
state.SelectedCharacterSkillGroups = sheet.SkillGroups.OrderBy(group => group.Name, StringComparer.OrdinalIgnoreCase).ToList();
state.SelectedCharacterSkills = sheet.Skills.OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase).ToList();
state.SelectedCharacterSkillGroups =
sheet.SkillGroups.OrderBy(group => group.Name, StringComparer.OrdinalIgnoreCase).ToList();
state.SelectedCharacterSkills =
sheet.Skills.OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase).ToList();
}
public Task EnsureSelectedCharacterActiveAsync()
@@ -152,7 +163,8 @@ public sealed class WorkspacePlayCoordinator(WorkspaceState state, WorkspaceFeed
return Task.CompletedTask;
}
if (string.Equals(state.SelectedCampaign.RulesetId, RulesetFormHelpers.RulesetIds.Rolemaster, StringComparison.OrdinalIgnoreCase))
if (string.Equals(state.SelectedCampaign.RulesetId, RulesetFormHelpers.RulesetIds.Rolemaster,
StringComparison.OrdinalIgnoreCase))
{
OpenRolemasterSkillRollModal(skill);
return Task.CompletedTask;
@@ -177,7 +189,8 @@ public sealed class WorkspacePlayCoordinator(WorkspaceState state, WorkspaceFeed
state.IsSubmittingRolemasterSkillRoll = true;
try
{
await ExecuteSkillRollAsync(state.PendingRolemasterSkillRoll.Id, situationalModifier, keepModalOpenOnError: true);
await ExecuteSkillRollAsync(state.PendingRolemasterSkillRoll.Id, situationalModifier,
keepModalOpenOnError: true);
}
finally
{
@@ -199,7 +212,8 @@ public sealed class WorkspacePlayCoordinator(WorkspaceState state, WorkspaceFeed
state.IsMutating = true;
try
{
var roll = await apiClient.RequestAsync<RollResult>("POST", $"/api/skills/{skillId}/roll", new RollSkillRequest(state.RollVisibility, situationalModifier));
var roll = await apiClient.RequestAsync<RollResult>("POST", $"/api/skills/{skillId}/roll",
new RollSkillRequest(state.RollVisibility, situationalModifier));
CloseRolemasterSkillRollModal();
await HandleRecordedRollAsync(roll);
}
@@ -314,7 +328,8 @@ public sealed class WorkspacePlayCoordinator(WorkspaceState state, WorkspaceFeed
return;
var character = state.SelectedCampaign.Characters.FirstOrDefault(c => c.Id == state.SelectedCharacterId.Value);
if (character is null || !CanActivateCharacter(character, state.User) || state.ActiveCharacterId == character.Id)
if (character is null || !CanActivateCharacter(character, state.User) ||
state.ActiveCharacterId == character.Id)
return;
try
@@ -345,13 +360,16 @@ public sealed class WorkspacePlayCoordinator(WorkspaceState state, WorkspaceFeed
{
var visibleRollIds = state.CampaignLog.Select(entry => entry.RollId).ToHashSet();
foreach (var rollId in state.CampaignLogDetails.Keys.Where(rollId => !visibleRollIds.Contains(rollId)).ToArray())
foreach (var rollId in state.CampaignLogDetails.Keys.Where(rollId => !visibleRollIds.Contains(rollId))
.ToArray())
state.CampaignLogDetails.Remove(rollId);
foreach (var rollId in state.CampaignLogDetailsLoading.Where(rollId => !visibleRollIds.Contains(rollId)).ToArray())
foreach (var rollId in state.CampaignLogDetailsLoading.Where(rollId => !visibleRollIds.Contains(rollId))
.ToArray())
state.CampaignLogDetailsLoading.Remove(rollId);
foreach (var rollId in state.CampaignLogDetailErrors.Keys.Where(rollId => !visibleRollIds.Contains(rollId)).ToArray())
foreach (var rollId in state.CampaignLogDetailErrors.Keys.Where(rollId => !visibleRollIds.Contains(rollId))
.ToArray())
state.CampaignLogDetailErrors.Remove(rollId);
if (state.ExpandedCampaignLogRollId.HasValue && !visibleRollIds.Contains(state.ExpandedCampaignLogRollId.Value))

View File

@@ -0,0 +1,8 @@
namespace RpgRoller.Components.Pages;
public enum WorkspaceRoute
{
Play,
Campaigns,
Admin
}

View File

@@ -0,0 +1,14 @@
@ChildContent(Workspace)
@code {
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender)
return;
await Workspace.InitializeRouteAsync();
}
[Parameter, EditorRequired] public WorkspacePageContext Workspace { get; set; } = null!;
[Parameter, EditorRequired] public RenderFragment<WorkspacePageContext> ChildContent { get; set; } = null!;
}

View File

@@ -3,27 +3,43 @@ using RpgRoller.Contracts;
namespace RpgRoller.Components.Pages;
public sealed class WorkspaceSessionCoordinator(WorkspaceState state, WorkspaceFeedbackService feedback, IJSRuntime js, RpgRollerApiClient apiClient, WorkspaceQueryService workspaceQuery, Func<Guid?, Task> reloadCampaignsAsync, Func<Task> reloadCharacterCampaignOptionsAsync, Func<Task> refreshCampaignScopeAsync, Func<Task> syncStateEventsAsync, Func<Task> stopStateEventsAsync, Func<Task> ensureAdminUsersLoadedAsync, Action resetCampaignLogDetailState, Func<Task> requestRefreshAsync, Func<string?, Task> onLoggedOutAsync)
public sealed class WorkspaceSessionCoordinator(
WorkspaceState state,
WorkspaceFeedbackService feedback,
IJSRuntime js,
RpgRollerApiClient apiClient,
WorkspaceQueryService workspaceQuery,
Func<bool> isAdminRoute,
Func<Task> redirectToPlayAsync,
Func<Guid?, Task> reloadCampaignsAsync,
Func<Task> reloadCharacterCampaignOptionsAsync,
Func<Task> refreshCampaignScopeAsync,
Func<Task> requestRefreshAsync,
Func<Task> syncStateEventsAsync,
Func<Task> stopStateEventsAsync,
Func<Task> ensureAdminUsersLoadedAsync,
Action resetCampaignLogDetailState,
Func<string?, Task> onLoggedOutAsync)
{
public async Task InitializeAsync()
{
var storedScreen = await js.InvokeAsync<string?>("rpgRollerApi.getSessionValue", ScreenSessionKey);
state.CurrentScreen = NormalizeRequestedScreen(storedScreen) ?? ScreenPlay;
var storedPanel = await js.InvokeAsync<string?>("rpgRollerApi.getSessionValue", MobilePanelSessionKey);
if (string.Equals(storedPanel, "log", StringComparison.OrdinalIgnoreCase))
state.MobilePanel = "log";
var storedRollVisibility = await js.InvokeAsync<string?>("rpgRollerApi.getSessionValue", RollVisibilitySessionKey);
var storedRollVisibility =
await js.InvokeAsync<string?>("rpgRollerApi.getSessionValue", RollVisibilitySessionKey);
state.RollVisibility = NormalizeRollVisibility(storedRollVisibility);
Guid? preferredCampaignId = null;
if (!isAdminRoute())
{
var storedCampaignId = await js.InvokeAsync<string?>("rpgRollerApi.getSessionValue", CampaignSessionKey);
if (Guid.TryParse(storedCampaignId, out var parsedCampaignId))
preferredCampaignId = parsedCampaignId;
}
await CheckHealthAsync();
await LoadRulesetsAsync();
var reloaded = await ReloadAuthenticatedSessionAsync(preferredCampaignId);
if (!reloaded)
@@ -78,34 +94,11 @@ public sealed class WorkspaceSessionCoordinator(WorkspaceState state, WorkspaceF
await onLoggedOutAsync("Logged out.");
}
public async Task SwitchScreenAsync(string screen)
{
var targetScreen = NormalizeRequestedScreen(screen) ?? ScreenPlay;
if (string.Equals(targetScreen, ScreenAdmin, StringComparison.OrdinalIgnoreCase) && !state.IsCurrentUserAdmin)
targetScreen = ScreenPlay;
state.CurrentScreen = targetScreen;
state.IsScreenMenuOpen = false;
await PersistScreenPreferenceAsync(state.CurrentScreen);
await requestRefreshAsync();
if (state.User is not null)
{
await refreshCampaignScopeAsync();
await syncStateEventsAsync();
}
if (state.IsAdminScreen)
{
await ensureAdminUsersLoadedAsync();
await requestRefreshAsync();
}
}
public async Task OnRollVisibilityChangedAsync(string visibility)
{
state.RollVisibility = NormalizeRollVisibility(visibility);
await js.InvokeVoidAsync("rpgRollerApi.setSessionValue", RollVisibilitySessionKey, state.RollVisibility);
await requestRefreshAsync();
}
public void ClearAuthenticatedState()
@@ -168,16 +161,23 @@ public sealed class WorkspaceSessionCoordinator(WorkspaceState state, WorkspaceF
state.User = me.User;
state.ActiveCharacterId = me.ActiveCharacterId;
await EnsureScreenAccessAsync();
if (!await EnsureRouteAccessAsync())
return true;
if (isAdminRoute())
{
await stopStateEventsAsync();
state.ConnectionState = "offline";
await ensureAdminUsersLoadedAsync();
return true;
}
await LoadRulesetsAsync();
await reloadCampaignsAsync(preferredCampaignId ?? me.CurrentCampaignId);
await reloadCharacterCampaignOptionsAsync();
await refreshCampaignScopeAsync();
await syncStateEventsAsync();
if (state.IsAdminScreen)
await ensureAdminUsersLoadedAsync();
return true;
}
@@ -193,33 +193,17 @@ public sealed class WorkspaceSessionCoordinator(WorkspaceState state, WorkspaceF
}
}
private async Task EnsureScreenAccessAsync()
private async Task<bool> EnsureRouteAccessAsync()
{
if (state.IsCurrentUserAdmin)
return;
if (state.IsCurrentUserAdmin || !isAdminRoute())
{
return true;
}
state.AdminUsers = [];
state.HasLoadedAdminUsers = false;
if (!state.IsAdminScreen)
return;
state.CurrentScreen = ScreenPlay;
await PersistScreenPreferenceAsync(state.CurrentScreen);
}
private async Task PersistScreenPreferenceAsync(string screen)
{
try
{
await js.InvokeVoidAsync("rpgRollerApi.setSessionValue", ScreenSessionKey, screen);
}
catch (JSDisconnectedException)
{
}
catch (InvalidOperationException ex) when (IsStaticRenderInteropException(ex))
{
}
await redirectToPlayAsync();
return false;
}
private static string NormalizeRollVisibility(string? visibility)
@@ -227,29 +211,6 @@ public sealed class WorkspaceSessionCoordinator(WorkspaceState state, WorkspaceF
return string.Equals(visibility, "private", StringComparison.OrdinalIgnoreCase) ? "private" : "public";
}
private static string? NormalizeRequestedScreen(string? screen)
{
if (string.Equals(screen, ScreenAdmin, StringComparison.OrdinalIgnoreCase))
return ScreenAdmin;
if (string.Equals(screen, ScreenManagement, StringComparison.OrdinalIgnoreCase))
return ScreenManagement;
if (string.Equals(screen, ScreenPlay, StringComparison.OrdinalIgnoreCase))
return ScreenPlay;
return null;
}
private static bool IsStaticRenderInteropException(InvalidOperationException exception)
{
return exception.Message.Contains("JavaScript interop calls cannot be issued", StringComparison.OrdinalIgnoreCase);
}
private const string ScreenPlay = "play";
private const string ScreenManagement = "management";
private const string ScreenAdmin = "admin";
private const string ScreenSessionKey = "screen";
private const string CampaignSessionKey = "campaign";
private const string MobilePanelSessionKey = "play-panel";
private const string RollVisibilitySessionKey = "roll-visibility";

View File

@@ -17,7 +17,9 @@ public sealed class WorkspaceState
if (ownerUserId == SelectedCampaign.Gm.Id)
return $"{SelectedCampaign.Gm.DisplayName} (GM)";
var ownerDisplayName = SelectedCampaign.Characters.Where(character => character.OwnerUserId == ownerUserId).Select(character => character.OwnerDisplayName).FirstOrDefault(displayName => !string.IsNullOrWhiteSpace(displayName));
var ownerDisplayName = SelectedCampaign.Characters.Where(character => character.OwnerUserId == ownerUserId)
.Select(character => character.OwnerDisplayName)
.FirstOrDefault(displayName => !string.IsNullOrWhiteSpace(displayName));
return string.IsNullOrWhiteSpace(ownerDisplayName) ? "Unknown owner" : ownerDisplayName;
}
@@ -26,8 +28,10 @@ public sealed class WorkspaceState
{
if (!string.Equals(SelectedCampaign?.RulesetId, "d6", StringComparison.OrdinalIgnoreCase))
{
if (string.Equals(SelectedCampaign?.RulesetId, RulesetFormHelpers.RulesetIds.Rolemaster, StringComparison.OrdinalIgnoreCase))
return RulesetFormHelpers.DescribeRolemasterExpression(skill.DiceRollDefinition, skill.FumbleRange, skill.RolemasterAutoRetry);
if (string.Equals(SelectedCampaign?.RulesetId, RulesetFormHelpers.RulesetIds.Rolemaster,
StringComparison.OrdinalIgnoreCase))
return RulesetFormHelpers.DescribeRolemasterExpression(skill.DiceRollDefinition, skill.FumbleRange,
skill.RolemasterAutoRetry);
return skill.DiceRollDefinition;
}
@@ -59,7 +63,6 @@ public sealed class WorkspaceState
public bool HasHealthIssue { get; set; }
public string HealthIssueMessage { get; set; } = "Retry to restore the API connection.";
public List<WorkspaceToast> Toasts { get; } = [];
public string CurrentScreen { get; set; } = "play";
public string MobilePanel { get; set; } = "character";
public string ConnectionState { get; set; } = "offline";
public string LiveAnnouncement { get; set; } = string.Empty;
@@ -88,7 +91,9 @@ public sealed class WorkspaceState
public HashSet<Guid> CampaignLogDetailsLoading { get; } = [];
public Dictionary<Guid, string> CampaignLogDetailErrors { get; } = [];
public string? SelectedCampaignName => SelectedCampaign?.Name ?? Campaigns.FirstOrDefault(campaign => campaign.Id == SelectedCampaignId)?.Name;
public string? SelectedCampaignName => SelectedCampaign?.Name ??
Campaigns.FirstOrDefault(campaign => campaign.Id == SelectedCampaignId)
?.Name;
public CharacterSummary? SelectedCharacter =>
SelectedCampaign?.Characters.FirstOrDefault(character => character.Id == SelectedCharacterId);
@@ -101,11 +106,14 @@ public sealed class WorkspaceState
return null;
if (User is null)
return new(SelectedCampaign.Id, SelectedCampaign.Name, SelectedCampaign.RulesetId, SelectedCampaign.Gm, []);
return new(SelectedCampaign.Id, SelectedCampaign.Name, SelectedCampaign.RulesetId, SelectedCampaign.Gm,
[]);
var ownedCharacters = SelectedCampaign.Characters.Where(character => character.OwnerUserId == User.Id).ToArray();
var ownedCharacters = SelectedCampaign.Characters.Where(character => character.OwnerUserId == User.Id)
.ToArray();
return new(SelectedCampaign.Id, SelectedCampaign.Name, SelectedCampaign.RulesetId, SelectedCampaign.Gm, ownedCharacters);
return new(SelectedCampaign.Id, SelectedCampaign.Name, SelectedCampaign.RulesetId, SelectedCampaign.Gm,
ownedCharacters);
}
}
@@ -119,14 +127,18 @@ public sealed class WorkspaceState
if (SelectedCharacterId.HasValue)
{
var selectedCharacter = playSelectedCampaign.Characters.FirstOrDefault(character => character.Id == SelectedCharacterId.Value);
var selectedCharacter =
playSelectedCampaign.Characters.FirstOrDefault(character =>
character.Id == SelectedCharacterId.Value);
if (selectedCharacter is not null)
return selectedCharacter;
}
if (ActiveCharacterId.HasValue)
{
var activeCharacter = playSelectedCampaign.Characters.FirstOrDefault(character => character.Id == ActiveCharacterId.Value);
var activeCharacter =
playSelectedCampaign.Characters.FirstOrDefault(character =>
character.Id == ActiveCharacterId.Value);
if (activeCharacter is not null)
return activeCharacter;
}
@@ -157,10 +169,6 @@ public sealed class WorkspaceState
public bool IsSelectedCampaignD6 =>
string.Equals(SelectedCampaign?.RulesetId, "d6", StringComparison.OrdinalIgnoreCase);
public bool IsPlayScreen => string.Equals(CurrentScreen, "play", StringComparison.OrdinalIgnoreCase);
public bool IsManagementScreen => string.Equals(CurrentScreen, "management", StringComparison.OrdinalIgnoreCase);
public bool IsAdminScreen => string.Equals(CurrentScreen, "admin", StringComparison.OrdinalIgnoreCase);
public string ConnectionStateLabel => ConnectionState switch
{
"connected" => "Connected",
@@ -174,6 +182,4 @@ public sealed class WorkspaceState
"reconnecting" => "warn",
_ => "offline"
};
public string AppCssClass => IsPlayScreen ? "rr-app app-play" : "rr-app";
}

View File

@@ -1,81 +1,70 @@
using Microsoft.AspNetCore.WebUtilities;
using RpgRoller.Contracts;
using RpgRoller.Services;
namespace RpgRoller.Components;
public sealed class WorkspaceQueryService(IGameService gameService, WorkspaceSessionTokenAccessor sessionTokenAccessor)
public sealed class WorkspaceQueryService(RpgRollerApiClient apiClient)
{
public Task<MeResponse> GetMeAsync()
{
return Task.FromResult(GetValue(gameService.GetMe(GetRequiredSessionToken())));
return apiClient.RequestAsync<MeResponse>("GET", "/api/me");
}
public Task<IReadOnlyList<RulesetDefinition>> GetRulesetsAsync()
public async Task<IReadOnlyList<RulesetDefinition>> GetRulesetsAsync()
{
return Task.FromResult(gameService.GetRulesets());
return await apiClient.RequestAsync<RulesetDefinition[]>("GET", "/api/rulesets");
}
public Task<IReadOnlyList<CampaignSummary>> GetCampaignsAsync()
public async Task<IReadOnlyList<CampaignSummary>> GetCampaignsAsync()
{
return Task.FromResult(GetValue(gameService.GetCampaigns(GetRequiredSessionToken())));
return await apiClient.RequestAsync<CampaignSummary[]>("GET", "/api/campaigns");
}
public Task<IReadOnlyList<CampaignOption>> GetCharacterCampaignOptionsAsync()
public async Task<IReadOnlyList<CampaignOption>> GetCharacterCampaignOptionsAsync()
{
return Task.FromResult(GetValue(gameService.GetCharacterCampaignOptions(GetRequiredSessionToken())));
return await apiClient.RequestAsync<CampaignOption[]>("GET", "/api/campaigns/options");
}
public Task<CampaignRoster> GetCampaignAsync(Guid campaignId)
{
return Task.FromResult(GetValue(gameService.GetCampaign(GetRequiredSessionToken(), campaignId)));
return apiClient.RequestAsync<CampaignRoster>("GET", $"/api/campaigns/{campaignId:D}");
}
public Task<IReadOnlyList<string>> GetUsernamesAsync()
public async Task<IReadOnlyList<string>> GetUsernamesAsync()
{
return Task.FromResult(GetValue(gameService.GetUsernames(GetRequiredSessionToken())));
return await apiClient.RequestAsync<string[]>("GET", "/api/users/usernames");
}
public Task<CharacterSheet> GetCharacterSheetAsync(Guid characterId)
{
return Task.FromResult(GetValue(gameService.GetCharacterSheet(GetRequiredSessionToken(), characterId)));
return apiClient.RequestAsync<CharacterSheet>("GET", $"/api/characters/{characterId:D}/sheet");
}
public Task<IReadOnlyList<CampaignLogEntry>> GetCampaignLogAsync(Guid campaignId)
public async Task<IReadOnlyList<CampaignLogEntry>> GetCampaignLogAsync(Guid campaignId)
{
return Task.FromResult(GetValue(gameService.GetCampaignLog(GetRequiredSessionToken(), campaignId)));
return await apiClient.RequestAsync<CampaignLogEntry[]>("GET", $"/api/campaigns/{campaignId:D}/log");
}
public Task<CampaignLogPage> GetCampaignLogPageAsync(Guid campaignId, Guid? afterRollId = null, int? limit = null)
{
return Task.FromResult(GetValue(gameService.GetCampaignLogPage(GetRequiredSessionToken(), campaignId, afterRollId, limit)));
var query = new Dictionary<string, string?>();
if (afterRollId.HasValue)
query["afterRollId"] = afterRollId.Value.ToString("D");
if (limit.HasValue)
query["limit"] = limit.Value.ToString();
var path = QueryHelpers.AddQueryString($"/api/campaigns/{campaignId:D}/log/page", query);
return apiClient.RequestAsync<CampaignLogPage>("GET", path);
}
public Task<CampaignRollDetail> GetRollDetailAsync(Guid rollId)
{
return Task.FromResult(GetValue(gameService.GetRollDetail(GetRequiredSessionToken(), rollId)));
return apiClient.RequestAsync<CampaignRollDetail>("GET", $"/api/rolls/{rollId:D}");
}
public Task<IReadOnlyList<AdminUserSummary>> GetAdminUsersAsync()
public async Task<IReadOnlyList<AdminUserSummary>> GetAdminUsersAsync()
{
return Task.FromResult(GetValue(gameService.GetUsers(GetRequiredSessionToken())));
}
private string GetRequiredSessionToken()
{
return sessionTokenAccessor.GetRequiredSessionToken();
}
private static T GetValue<T>(ServiceResult<T> result)
{
if (result.Succeeded)
return result.Value!;
throw ToApiRequestException(result.Error!);
}
private static ApiRequestException ToApiRequestException(ServiceError error)
{
var statusCode = error.Code == "unauthorized" ? 401 : 400;
return new(statusCode, error.Message, error.Code);
return await apiClient.RequestAsync<AdminUserSummary[]>("GET", "/api/admin/users");
}
}

View File

@@ -1,33 +0,0 @@
using RpgRoller.Api;
namespace RpgRoller.Components;
public sealed class WorkspaceSessionTokenAccessor
{
public WorkspaceSessionTokenAccessor(IHttpContextAccessor httpContextAccessor)
{
var httpContext = httpContextAccessor.HttpContext;
if (httpContext is null)
return;
if (httpContext.Items.TryGetValue(SessionTokenItemKey, out var storedToken) && storedToken is string sessionToken && !string.IsNullOrWhiteSpace(sessionToken))
{
m_SessionToken = sessionToken;
return;
}
if (httpContext.TryReadSessionTokenFromCookie(out sessionToken))
m_SessionToken = sessionToken;
}
public string GetRequiredSessionToken()
{
if (!string.IsNullOrWhiteSpace(m_SessionToken))
return m_SessionToken;
throw new ApiRequestException(401, "You must be logged in.");
}
private const string SessionTokenItemKey = "__rpgroller.session-token";
private readonly string? m_SessionToken;
}

View File

@@ -13,9 +13,7 @@ builder.Services.AddResponseCompression(options =>
options.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(["application/json"]);
});
builder.Services.ConfigureHttpJsonOptions(options => RpgRollerJson.Configure(options.SerializerOptions));
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<RpgRollerApiClient>();
builder.Services.AddScoped<WorkspaceSessionTokenAccessor>();
builder.Services.AddScoped<WorkspaceQueryService>();
var app = builder.Build();
@@ -37,6 +35,7 @@ app.UseResponseCompression();
app.UseAntiforgery();
app.MapRpgRollerApi();
app.MapFrontendEntryEndpoints();
app.MapStaticAssets();
app.MapRazorComponents<App>().AddInteractiveServerRenderMode();
app.Run();

View File

@@ -141,7 +141,7 @@ window.rpgRollerApi = (() => {
let response;
try {
response = await fetch(toAppUrl(url), options);
} catch (error) {
} catch {
return {
ok: false,
status: 0,
@@ -214,6 +214,175 @@ window.rpgRollerApi = (() => {
element.value = "";
}
function initializeAuthPage() {
const root = document.querySelector("[data-auth-page]");
if (!root) {
return;
}
const statusElement = root.querySelector("[data-auth-status]");
const forms = root.querySelectorAll("[data-auth-form]");
forms.forEach((form) => {
form.addEventListener("submit", async (event) => {
event.preventDefault();
await submitAuthForm(root, form, statusElement);
});
});
}
function clearAuthErrors(root) {
const statusElement = root.querySelector("[data-auth-status]");
if (statusElement) {
statusElement.hidden = true;
statusElement.textContent = "";
statusElement.classList.remove("error", "success");
}
root.querySelectorAll("[data-form-error], [data-field-error]").forEach((element) => {
element.hidden = true;
element.textContent = "";
});
}
function setAuthStatus(statusElement, message, isError) {
if (!statusElement) {
return;
}
statusElement.hidden = !message;
statusElement.textContent = message || "";
statusElement.classList.toggle("error", !!message && isError);
statusElement.classList.toggle("success", !!message && !isError);
}
function setFormError(form, message) {
const errorElement = form.querySelector("[data-form-error]");
if (!errorElement) {
return;
}
errorElement.hidden = !message;
errorElement.textContent = message || "";
}
function setFieldError(form, fieldName, message) {
const errorElement = form.querySelector(`[data-field-error="${fieldName}"]`);
if (!errorElement) {
return;
}
errorElement.hidden = !message;
errorElement.textContent = message || "";
}
function setInvalidCredentialsErrors(form) {
setFieldError(form, "username", "Invalid username or password.");
setFieldError(form, "password", "Invalid username or password.");
}
function readFormData(form) {
return Object.fromEntries(new FormData(form).entries());
}
function validateAuthForm(formType, payload) {
const errors = {};
if (!payload.username || !payload.username.trim()) {
errors.username = "Username is required.";
}
if (formType === "register") {
if (!payload.displayName || !payload.displayName.trim()) {
errors.displayName = "Display name is required.";
}
if (!payload.password || payload.password.length < 8) {
errors.password = "Password must be at least 8 characters.";
}
} else if (!payload.password) {
errors.password = "Password is required.";
}
return errors;
}
function setSubmitting(form, isSubmitting) {
const submitButton = form.querySelector("button[type=\"submit\"]");
if (!submitButton) {
return;
}
submitButton.disabled = isSubmitting;
submitButton.textContent = isSubmitting
? submitButton.dataset.submittingLabel || submitButton.textContent
: submitButton.dataset.submitLabel || submitButton.textContent;
}
async function submitAuthForm(root, form, statusElement) {
clearAuthErrors(root);
const formType = form.dataset.authForm;
const payload = readFormData(form);
const errors = validateAuthForm(formType, payload);
Object.entries(errors).forEach(([fieldName, message]) => {
setFieldError(form, fieldName, message);
});
if (Object.keys(errors).length > 0) {
setAuthStatus(statusElement, "Resolve validation issues before submitting.", true);
setFormError(form, "Resolve validation issues before submitting.");
return;
}
const endpoint = formType === "register" ? "/api/auth/register" : "/api/auth/login";
const requestBody = formType === "register"
? {
username: payload.username.trim(),
displayName: payload.displayName.trim(),
password: payload.password
}
: {
username: payload.username.trim(),
password: payload.password
};
setSubmitting(form, true);
try {
const response = await request("POST", endpoint, requestBody);
if (!response.ok) {
setAuthStatus(statusElement, response.error || "Request failed.", true);
if (formType === "register" && response.code === "duplicate_username") {
setFieldError(form, "username", "Username is already taken. Choose another one.");
} else if (formType === "login" && response.code === "invalid_credentials") {
setInvalidCredentialsErrors(form);
} else {
setFormError(form, response.error || "Request failed.");
}
setFormError(form, response.error || "Request failed.");
return;
}
if (formType === "login") {
window.location.assign(toAppUrl("/play"));
return;
}
form.reset();
setAuthStatus(statusElement, "Registration successful. You can log in now.", false);
} finally {
setSubmitting(form, false);
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initializeAuthPage, { once: true });
} else {
initializeAuthPage();
}
return {
request,
getSessionValue,

350
TASKS.md
View File

@@ -1,4 +1,4 @@
# Rolemaster skill roll situational modifier modal
# Rewrite The Web App Into A Route-First Authenticated Shell
This ExecPlan is a living document. The sections `Progress`, `Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds.
@@ -6,193 +6,327 @@ This ExecPlan is a living document. The sections `Progress`, `Surprises & Discov
## Purpose / Big Picture
After this change, clicking the dice button for any Rolemaster skill on the play screen will no longer roll immediately. Instead, a modal dialog will open first and ask for a one-time situational modifier for that upcoming roll. The player can leave it blank for zero, enter a positive number such as `20` for a bonus, or enter a negative number such as `-15` for a penalty. Pressing Enter will confirm the roll, pressing Escape will cancel it, and clicking outside the modal will also cancel it.
After this change, the browser URL will match the authenticated screen the user is actually using. Anonymous users who open `/` will be redirected to `/login`. Authenticated users who open `/` will be redirected to `/play`. The hamburger menu will navigate to real routes such as `/play`, `/campaigns`, and `/admin` instead of toggling large conditional branches inside one component at `/`.
The important user-visible rule is that this temporary modifier must be applied everywhere the skill roll logic already uses the skills built-in modifier. For a skill stored as `d100!+50`, entering `20` means the first Rolemaster attempt is evaluated as `roll + 50 + 20`, not as a post-processing adjustment. That means an initial result of `8` becomes `8+50+20=78`, which falls into the existing automatic retry band and therefore triggers the retry flow. The retry attempt must also include the same `+20` situational modifier.
This matters because the current authenticated workspace is still one large, structurally dynamic Blazor Server surface. `POSTMORTEM.md` shows that this architecture is fragile when browser extensions mutate form-related DOM during startup. The route-first rewrite reduces the amount of UI that wakes up at once, removes the dual-purpose `/` shell, and makes the authenticated shell easier to reason about, test, and evolve.
The change is complete when a human can run the app, open `/`, observe the correct redirect based on auth state, log in at `/login`, land on `/play`, navigate to `/campaigns` and `/admin` with real URLs, refresh any of those routes without being thrown back to `/`, and run the automated host and Selenium tests that prove the new behavior.
## Progress
- [x] (2026-04-14 21:27:50Z) Created the initial ExecPlan in `TASKS.md`, grounded in the current workspace play flow, API contract, and Rolemaster retry implementation.
- [x] (2026-04-14 21:39:42Z) Added transient `SituationalModifier` support to the skill-roll request, API endpoint, service facade, and roll pipeline without adding persistence or schema changes.
- [x] (2026-04-14 21:51:33Z) Added a Rolemaster-only pre-roll modal on the play screen with autofocus, Escape dismissal, Enter submit, outside-click dismissal, and inline validation for signed integer input.
- [x] (2026-04-14 21:39:42Z) Updated Rolemaster roll execution and breakdown formatting so temporary modifiers are shown explicitly and feed retry-band evaluation plus retry attempts.
- [x] (2026-04-14 21:51:33Z) Added service, API, and Playwright coverage for the new behavior, updated `README.md`, and prepared the touched files for cleanup, full CI, and commit.
- [x] (2026-05-04 17:52Z) Reviewed `POSTMORTEM.md`, the current app shell, workspace routing behavior, and the existing host and frontend smoke tests to define the rewrite around real routes instead of `sessionStorage` screen switching.
- [x] (2026-05-04 17:52Z) Updated `README.md` so it accurately describes the current architecture and the approved rewrite direction.
- [x] (2026-05-04 18:29Z) Implemented a host-level `/` redirect to `/login` or `/play`, moved the static auth document to `/login`, switched login/logout targets to `/play` and `/login`, and updated the root-path host and smoke coverage to the new contract.
- [x] (2026-05-04 19:26Z) Replaced the checked-in Playwright smoke coverage with a geckodriver+Selenium smoke runner, including a Firefox DOM-wrap addon for extension-like startup mutations, and updated repo scripts/docs to the new browser verification path.
- [x] (2026-05-04) Introduced real authenticated routes for `/play`, `/campaigns`, and `/admin` while preserving the shared `Workspace` behavior behind those routes.
- [x] (2026-05-04) Removed `screen` as a `sessionStorage` routing mechanism and replaced menu actions with URL navigation.
- [x] (2026-05-04 21:42Z) Split the large `Workspace` render tree into a shared shell plus route-owned play, campaign-management, and admin content components, and kept the Selenium route and DOM-wrap coverage green after the split.
- [x] (2026-05-04 21:58Z) Removed shell-level `OnAfterRenderAsync` bootstrapping, moved the JS-dependent authenticated startup into a route-owned `WorkspaceRouteView`, removed shell-owned staged control renders, restored the missing development database fixture, and updated README to describe the completed route-first architecture.
- [x] (2026-05-04) Updated host tests, Selenium smoke tests, and docs so the real-route model is the documented and verified Milestone 2 behavior.
- [x] (2026-05-04) Added expanded workspace startup diagnostics across Blazor lifecycle logging, route-content render logging, and browser-side DOM mutation snapshots to narrow the remaining Firefox batch-2 crash.
- [x] (2026-05-04) Extended the diagnostics to page-load time by wrapping the interactive host in a stable container and logging pre-Blazor body and host mutations before the first interactive batch applies.
- [x] (2026-05-04) Reworked authenticated route startup into phased interactive batches so the first render mounts only a tiny shell, the second render mounts a simple header placeholder, the third render mounts route skeletons, and real control-heavy content appears only after route initialization completes.
- [x] (2026-05-04 22:17Z) Removed global authenticated `Routes` interactivity, moved `InteractiveServerRenderMode(prerender: false)` onto the real authenticated pages, and switched to manual `Blazor.start({ ssr: { disableDomPreservation: true } })` startup based on the upstream Firefox guidance in `dotnet/aspnetcore#58824`.
- [x] (2026-05-05) Confirmed the real fix in Firefox plus RoboForm, documented it in `README.md`, and removed the failed phased-render and diagnostics-only mitigation layers from the codebase.
## Surprises & Discoveries
- Observation: `TASKS.md` was empty before this plan was written, so this ExecPlan now defines the full intended work from scratch.
Evidence: `Get-Item D:\Code\RpgRoller\TASKS.md | Format-List Length` reported `Length : 0`.
- Observation: the current browser API client is still implemented through JavaScript interop, so the authenticated UI cannot simply move all startup work into `OnInitializedAsync`.
Evidence: `RpgRoller/Components/RpgRollerApiClient.cs` calls `js.InvokeAsync("rpgRollerApi.request", ...)`, which means authenticated data fetches currently depend on an interactive render before they can run.
- Observation: the situational modifier fit cleanly as transient request data. No `Skill`, `RollLogEntry`, migration, or EF model change was needed for the first implementation slice.
Evidence: the change only touched `RollSkillRequest`, the roll endpoint/service path, and Rolemaster roll formatting/execution files.
- Observation: the current smoke suite encodes the old dual-purpose `/` behavior and will fail as soon as `/` becomes a redirect entry point.
Evidence: the checked-in smoke coverage originally expected anonymous `GET /` to render static auth markup and authenticated `GET /` to render the Blazor workspace shell, so it had to be rewritten when `/` became a redirect entry point.
- Observation: the current Rolemaster retry rule is already based on the fully computed first attempt total, not just the raw die result, which matches the new requirement once the temporary modifier is included in that total.
Evidence: `RpgRoller/Services/RolemasterRollEngine.cs` resolves retry bands from `firstAttempt.Total`.
- Observation: the current host test also encodes an outdated assumption about `/`.
Evidence: `RpgRoller.Tests/Api/FrontendHostTests.cs` currently asserts that `GET /` returns HTTP 200 and a Blazor shell containing `_framework/blazor.web.js`.
- Observation: the repository already uses Blazor modal patterns with overlays and `ElementReference.FocusAsync()` for autofocus, so the new modal can follow an existing local pattern instead of inventing a second approach.
Evidence: `RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor(.cs)` renders a modal and focuses the name input in `OnAfterRenderAsync`.
- Observation: `MapRazorComponents<App>()` does not serve the static `/login` document unless a matching component route exists, even though `App.razor` itself renders the static auth markup outside the interactive router.
Evidence: the first Milestone 1 host test run returned HTTP 404 for `GET /login` until a minimal `RpgRoller/Components/Pages/LoginPage.razor` with `@page "/login"` was added.
- Observation: compact Rolemaster retry summaries still preview the trigger die, not the fully modified first-attempt arithmetic. The authoritative arithmetic belongs in the breakdown string.
Evidence: with a situational modifier, the new service test now expects `8 | open-ended | retry +5` in the compact summary while the detailed breakdown is `8+50+20=78; retry(+5): 42+50+20=112; final=117`.
- Observation: the repository-wide backend suite currently contains a missing-fixture failure unrelated to the route-first rewrite.
Evidence: `dotnet test RpgRoller.Tests/RpgRoller.Tests.csproj --collect:"XPlat Code Coverage" --settings RpgRoller.Tests/coverlet.runsettings` failed in `HostingCoverageTests.InitializeRpgRollerState_MigratesCopiedDevelopmentDatabaseAndPreservesD6Rolling` because `RpgRoller/App_Data/rpgroller.development.db` is not present in the worktree.
- Observation: Razor string parameters in the new modal call site need explicit `@` binding or the UI renders the property name as literal text.
Evidence: the first Playwright failure snapshot showed the dialog rendering `State.PendingRolemasterSkillRollError` instead of the actual inline validation message until the binding was corrected in `Workspace.razor`.
- Observation: once the route-owned components controlled their own modal and page subtree rendering, the extra shell-owned play-control staging was no longer necessary for the DOM-wrap smoke coverage.
Evidence: after moving authenticated startup into a route-owned wrapper and rendering play controls directly, `node ./scripts/run-selenium.js` still passed the extension-like DOM-wrap coverage against `/play`.
- Observation: the first Milestone 4 attempt was still incomplete because authenticated startup remained route-agnostic behind `Session.InitializeAsync()`.
Evidence: `/admin` and `/play` could still hit the Firefox `insertBefore` circuit crash until admin and campaign-management routes stopped preloading play-only campaign scope, selected sheets, logs, and SSE startup during their first interactive batch.
- Observation: the remaining Firefox failure still happens during Blazor batch application, so server-side coordinator logs alone are not enough to localize it.
Evidence: after route-scoping startup, Firefox still reported `There was an error applying batch 2` with `TypeError: can't access property "insertBefore", n.parentNode is null`, which motivated adding route render lifecycle logs plus browser-side workspace mutation snapshots.
- Observation: the RoboForm-triggered crash happens before any component `OnAfterRenderAsync` callback in the authenticated route tree.
Evidence: in the failing `/play` and `/admin` reproductions, the last server-side logs were only `OnInitialized` and `OnParametersSet` entries for `Workspace` and its immediate child components; there were no `WorkspaceRouteView.OnAfterRenderAsync` or `Workspace.InitializeRouteCoreAsync` entries before the circuit terminated.
- Observation: the current app still matched the upstream "global interactivity" failure shape even after the route-first rewrite, because `App.razor` continued to apply `@rendermode` to the root `Routes` component.
Evidence: `RpgRoller/Components/App.razor` still rendered `<Routes @rendermode="@(new InteractiveServerRenderMode(prerender: false))" />` until the final follow-up pass, while `dotnet/aspnetcore#58824` explicitly reports Firefox crashes for Global mode and says PerPage mode does not reproduce.
- Observation: once the authenticated pages moved to per-page interactivity, header route navigation needed full document reloads instead of in-circuit `NavigationManager.NavigateTo` transitions.
Evidence: the first Selenium run after the per-page render-mode change reached `/play` in the URL but never mounted `#skill-filter-input` after `/campaigns -> /play` until `Workspace.NavigateToRouteAsync` switched to `forceLoad: true`.
- Observation: the phased first-render shells and browser/server diagnostics were not part of the final fix.
Evidence: after the app switched to per-page interactive render modes plus manual `Blazor.start({ ssr: { disableDomPreservation: true } })`, the Firefox plus RoboForm repro stopped even after those extra mitigations were removed.
- Observation: the locally installed Snap Firefox build on this machine is viable for Selenium through `geckodriver`, but not for Playwright protocol control.
Evidence: Playwright stalled during the `-juggler-pipe` handshake, while a `geckodriver` plus Selenium session against `/snap/firefox/current/usr/lib/firefox/firefox` completed the same Milestone 1 verification successfully.
## Decision Log
- Decision: the situational modifier will be transient request data only and will not be stored on `Skill`, `RollLogEntry`, or in a migration.
Rationale: the feature is explicitly “once for the upcoming roll.” Persisting it would create stale state, require schema work, and misrepresent the feature.
Date/Author: 2026-04-14 / Codex
- Decision: implement the approved route-first approach rather than continuing to add localized mitigations inside the current `/` workspace shell.
Rationale: the user approved this direction after reviewing three refactor options, and `POSTMORTEM.md` concludes that the problem is architectural rather than a single bug.
Date/Author: 2026-05-04 / Codex and user
- Decision: `RollSkillRequest` will gain an integer `SituationalModifier` field with a default of `0`, and server-side skill-roll methods will accept the same value.
Rationale: zero is the normal case, avoids null semantics through the stack, and keeps the request payload simple for both tests and UI code.
Date/Author: 2026-04-14 / Codex
- Decision: keep the anonymous auth page as plain HTML and JavaScript, but move it to `/login` instead of restoring it as an interactive Blazor form.
Rationale: the anonymous path was intentionally isolated from Blazor in commit `2d2ed56`, and the postmortem treats that isolation as a valid mitigation for the login surface. The rewrite should not reintroduce a form-heavy Blazor login page unless there is a compelling reason later.
Date/Author: 2026-05-04 / Codex
- Decision: non-zero situational modifiers will be accepted only for Rolemaster skill rolls. Non-Rolemaster skill rolls will continue to execute immediately without showing the modal, and the server will reject any accidental non-zero modifier sent for another ruleset.
Rationale: the user asked for this behavior only for the Rolemaster system. The server-side guard prevents future UI regressions from silently broadening the feature.
Date/Author: 2026-04-14 / Codex
- Decision: make `/` a server-side redirect entry point instead of continuing to let `App.razor` choose between auth and workspace content based on request-time auth state.
Rationale: `App.razor` is currently a hidden architecture boundary. Moving auth-based entry selection to an HTTP redirect makes the boundary explicit, testable, and smaller.
Date/Author: 2026-05-04 / Codex
- Decision: the modal input will be stored as raw text in UI state and parsed on confirm rather than bound directly to an `int`.
Rationale: blank must mean zero, signed values must be easy to type, and raw text avoids awkward intermediate states such as a lone `-` while the user is editing.
Date/Author: 2026-04-14 / Codex
- Decision: use the URL path as the source of truth for the current authenticated screen.
Rationale: the current `screen` preference in `sessionStorage` causes the app state and the browser URL to disagree. Real routes remove that mismatch and make refresh, deep-linking, and testing simpler.
Date/Author: 2026-05-04 / Codex
- Decision: Rolemaster breakdown strings will show the base skill modifier and the one-shot situational modifier as separate visible terms instead of folding them into one combined number.
Rationale: the user needs to audit why a retry happened. `8+50+20=78` is clearer than `8+70=78`, especially when comparing the stored skill expression with the one-time adjustment.
Date/Author: 2026-04-14 / Codex
- Decision: stop using global authenticated interactivity and move the authenticated pages to per-page `InteractiveServerRenderMode(prerender: false)` with manual startup that disables SSR DOM preservation.
Rationale: upstream issue `dotnet/aspnetcore#58824` identifies Firefox failures tied to Global interactivity and explicitly notes that PerPage mode does not share the problem. The Blazor startup guidance also documents manual `Blazor.start` configuration for SSR options such as `disableDomPreservation`.
Date/Author: 2026-05-04 / Codex
- Decision: compact log badges do not need a new “situational modifier” badge.
Rationale: the result number already changes, the detailed breakdown will show the exact temporary modifier, and adding a new badge for a one-shot value would create clutter with little value.
Date/Author: 2026-04-14 / Codex
- Decision: remove the phased render system and the crash-diagnostics scaffolding after the real fix was confirmed.
Rationale: those changes were useful for isolating the failure, but they increased code complexity without contributing to the final Firefox plus RoboForm solution.
Date/Author: 2026-05-05 / Codex
- Decision: stage the rewrite in two layers: first introduce real routes while preserving existing feature behavior, then split the large workspace tree into route-owned subtrees.
Rationale: the current workspace is dense and risk-prone. A staged rewrite keeps the app working while the route model changes, and it gives the test suite meaningful checkpoints.
Date/Author: 2026-05-04 / Codex
- Decision: standardize frontend smoke verification on geckodriver plus Selenium instead of Playwright in this repository.
Rationale: the user updated the repo instructions to make Selenium the required browser automation path, and the locally installed Firefox stack works reliably through geckodriver while Playwright cannot control the Snap Firefox build on this machine.
Date/Author: 2026-05-04 / Codex and user
## Outcomes & Retrospective
The feature is now complete end to end. Rolemaster skill rolls no longer execute immediately from the play screen; they first open a modal that accepts an optional one-shot situational modifier, focuses the input automatically, closes on Escape or backdrop click, and validates whole-number input inline. Confirmed rolls send the temporary modifier through the existing skill-roll API, Rolemaster breakdowns show the base and situational modifiers as separate terms, and automatic retry math reuses the same situational modifier on both attempts. Service, API, and Playwright coverage now prove the backend math, the Rolemaster-only guard, and the browser interaction flow.
At plan creation time, the repository has an updated README and a concrete implementation plan, but no code for the route-first rewrite has been started yet. The immediate risk is not uncertainty about direction; it is carrying old assumptions about `/`, `Home.razor`, and `sessionStorage`-based screen switching into the first code changes. The milestones below are written to make those assumptions explicit and retire them in an observable order.
After Milestone 1, the dual-purpose `/` entry point is gone. Anonymous requests to `/` are now redirected before Blazor renders, the static auth document lives at `/login`, and successful login lands on `/play`. The main residual risk is that the authenticated shell is still monolithic behind the new `/play` route, so later milestones still need to replace in-memory screen switching with real route ownership.
After the Selenium migration iteration, the repositorys browser smoke coverage once again matches the documented verification path. The smoke suite now runs against Firefox through geckodriver, and the DOM-wrap regression coverage remains intact through a temporary test addon. The next risk is purely architectural again: the authenticated shell still uses in-memory screen switching, so Milestone 2 remains the next code change on the critical path.
After Milestone 2, the authenticated shell now has first-class `/play`, `/campaigns`, and `/admin` routes, and the menu navigates with URLs instead of `sessionStorage` screen names. The remaining risk is now narrower and more structural: `Workspace.razor` still owns mutually exclusive authenticated branches, and the root `OnAfterRenderAsync` path still stages page-specific startup work that should move into route-owned components in Milestones 3 and 4.
After Milestone 3, `Workspace.razor` is now a shell that owns shared chrome, health state, and toast feedback, while the play, campaign-management, and admin DOM each live in route-owned components supplied by `/play`, `/campaigns`, and `/admin`. The route split preserved the host tests and full Selenium smoke coverage, including the DOM-wrap regression case, but the final startup path is still staged through `Workspace.razor.cs` and remains the next target for Milestone 4.
After Milestone 4, authenticated startup is now triggered by a route-owned wrapper instead of `Workspace.razor.cs`, the shared shell no longer uses `OnAfterRenderAsync`, and the play route renders its controls directly without shell-driven follow-up batches. The route-first rewrite is now functionally complete: host tests pass, the Selenium smoke suite passes, and the restored development-database fixture lets the backend coverage suite validate the full repo behavior again.
Follow-up: the first pass at Milestone 4 removed shell-level `OnAfterRenderAsync`, but did not yet split `Session.InitializeAsync()` by route. The final follow-up fix made startup genuinely route-scoped by keeping `/admin` off play-only campaign scope and SSE startup, gating the full shell behind authenticated initialization, and adding direct `/admin` smoke coverage so this regression path stays visible.
Follow-up 2: gating the entire authenticated shell behind `HasSessionInitialized` produced another large first-batch subtree swap and broke the `/campaigns` to `/play` refresh path. The final stabilization renders a consistent route skeleton from batch 1, derives loading UI from `HasSessionInitialized` instead of mutating shared loading state in `WorkspaceRouteView`, and refreshes route-specific scope explicitly when the same `Workspace` instance changes from one authenticated route to another.
This section must be updated after each major milestone. When the implementation is complete, summarize which parts of the old workspace architecture were fully removed, which compatibility constraints remain, and whether the final startup path still depends on any multi-batch structural rendering.
## Context and Orientation
This repository is an ASP.NET Core and Blazor application rooted at `D:\Code\RpgRoller`. The user-facing play screen is assembled in `RpgRoller/Components/Pages/Workspace.razor`. That page wires `CharacterPanel` to the coordinator class `RpgRoller/Components/Pages/WorkspacePlayCoordinator.cs`, which currently handles skill rolls by calling `Play.RollSkillAsync`.
The current app serves both anonymous and authenticated experiences from the same HTML shell. In `RpgRoller/Components/App.razor`, the shell checks the current request path through `HttpContext`. If the request is for `/login`, it renders `RpgRoller/Components/Pages/HomeControls/StaticAuthPage.razor` as plain HTML and loads `RpgRoller/wwwroot/js/rpgroller-api.js`. That JavaScript file binds the login and registration forms and sends `fetch` requests to `/api/auth/register` and `/api/auth/login`.
The skill list and its dice buttons live in `RpgRoller/Components/Pages/HomeControls/SkillGroupBlock.razor`. Each button currently emits only the `Guid` skill identifier. `RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor` and `CharacterPanel.razor.cs` forward that identifier upward through the `RollRequested` callback without opening any pre-roll UI.
If a valid session cookie exists, `App.razor` instead renders the interactive Blazor router. The authenticated shell is now entered through `RpgRoller/Components/Pages/PlayPage.razor`, `CampaignsPage.razor`, and `AdminPage.razor`, which map `/play`, `/campaigns`, and `/admin` and forward into the shared `Workspace` component.
The request contract for a skill roll lives in `RpgRoller/Contracts/ApiContracts.cs` as `RollSkillRequest`. The HTTP endpoint is `POST /api/skills/{skillId}/roll` in `RpgRoller/Api/SkillEndpoints.cs`. The service contract is `IGameService.RollSkill(...)` in `RpgRoller/Services/IGameService.cs`, implemented by `RpgRoller/Services/GameService.cs`, and executed by `RpgRoller/Services/GameRollService.cs`.
The authenticated workspace lives in `RpgRoller/Components/Pages/Workspace.razor` and `Workspace.razor.cs`. The Razor file still contains the header, play screen, campaign management screen, admin screen, toasts, and modals. The code-behind wires several coordinator classes, and `OnAfterRenderAsync` still drives session initialization and staged control enablement. The current route now comes from the page component parameter rather than `WorkspaceState.CurrentScreen`, and `WorkspaceSessionCoordinator.cs` no longer persists a screen name in browser `sessionStorage`.
Rolemaster rolling behavior is implemented in `RpgRoller/Services/RollEngine.cs`, `RolemasterRollEngine.cs`, `RolemasterRetryPolicy.cs`, and `RollBreakdownFormatter.cs`. A “situational modifier” in this plan means a temporary integer that is added to or subtracted from the stored skill expression for one roll only. The stored skill expression is still the canonical thing saved on the skill, such as `d100!+50`. The situational modifier exists only in the request that triggers one roll and in the recorded breakdown text that explains that roll afterward.
In plain language, a “route-first authenticated shell” means that the browser path decides which authenticated page is being rendered. `/play` means the play page. `/campaigns` means the campaign management page. `/admin` means the admin page. The URL is not a decorative detail; it is the primary way the app chooses the screen. Menu clicks change the URL. Reloading the page preserves the same screen because the URL already says what the screen is.
UI and regression coverage already exist in the repository areas that matter here. `RpgRoller.Tests/Services/ServiceRolemasterRollTests.cs` covers Rolemaster engine behavior. `RpgRoller.Tests/Api/RolemasterApiTests.cs` covers Rolemaster HTTP behavior. `tests/e2e/smoke.spec.js` covers the browser play flow and already contains Rolemaster smoke tests, including the automatic retry badge path. These are the places to extend rather than creating disconnected new test files.
In this repository, “server-side redirect” means an HTTP redirect response such as `302 Found` returned before any Blazor UI is rendered. For example, `GET /` should answer with a redirect to `/login` or `/play` based on whether the session cookie maps to a real user through `IGameService.GetUserBySession`.
The API surface is already session-cookie-based. `RpgRoller/Api/AuthEndpoints.cs` sets the session cookie on login, `RpgRoller/Api/MeEndpoints.cs` returns the authenticated user model, and the rest of the authenticated `/api` routes are behind `RequireSessionTokenFilter`. This means the routing rewrite does not need a new auth system. It needs a clearer frontend entry structure and smaller authenticated page ownership boundaries.
One constraint must be kept in mind from the start: `RpgRoller/Components/RpgRollerApiClient.cs` performs requests through JavaScript interop. That means the authenticated UI still needs an interactive render before it can make its first data request. The rewrite must therefore reduce the amount of structure that changes after interactivity begins, not pretend that interactivity can be avoided entirely with the current client stack.
## Plan of Work
Start by widening the skill-roll request path, but keep the feature transient. In `RpgRoller/Contracts/ApiContracts.cs`, change `RollSkillRequest` so it carries both `Visibility` and `SituationalModifier`, defaulting the latter to `0`. Update `RpgRoller/Contracts/RpgRollerJsonSerializerContext.cs` only if the source generator needs to reflect the changed shape. Then thread the new integer through `RpgRoller/Api/SkillEndpoints.cs`, `RpgRoller/Services/IGameService.cs`, `RpgRoller/Services/GameService.cs`, and `RpgRoller/Services/GameRollService.cs`. In `GameRollService.RollSkill`, resolve the campaign and parsed expression exactly as today, then reject a non-zero situational modifier unless the campaign ruleset is Rolemaster. Reuse the existing authorization and visibility checks unchanged. No database or domain model changes are needed for this part.
Begin by separating the entry route from the anonymous auth page. Add a small host-level endpoint module, for example `RpgRoller/Api/FrontendEntryEndpoints.cs`, or an equivalent hosting extension, and map `GET /` before the Razor component host is mapped in `RpgRoller/Program.cs`. This endpoint must read the session cookie using the same cookie name defined in `RpgRoller/Api/SessionCookie.cs`, ask `IGameService` whether the cookie belongs to a real user, and return a redirect to `/play` for authenticated users or `/login` for anonymous users. This removes the dual-purpose `/` behavior.
After the request pipeline can accept the modifier, update the Rolemaster execution path so the temporary value participates in the actual roll math rather than being bolted on afterward. The cleanest repository-local shape is to extend `RollEngine.Roll(...)` and `RolemasterRollEngine.Roll(...)` with an optional `situationalModifier = 0` argument. Only the Rolemaster branch should consume it. In `RolemasterRollEngine`, add the situational modifier to the expression modifier for both standard Rolemaster rolls and open-ended percentile attempts. The first attempt total that feeds `RolemasterRetryPolicy.ResolveAutoRetryBonus(...)` must already include both the stored skill modifier and the situational modifier. The retry attempt must use the same situational modifier again. Update `RollBreakdownFormatter` so Rolemaster text remains explicit, for example `08+50+20=78` for a normal positive path and `(05) -97 +50 +20 = -22` for a low-end path. The retry breakdown must also preserve this explicit style, for example `08+50+20=78; retry(+5): 42+50+20=112; final=117`.
Next, simplify `RpgRoller/Components/App.razor` so it no longer chooses between anonymous and authenticated content based on auth state. It may still choose between a static `/login` document and the interactive authenticated router based on the request path, because the anonymous page is intentionally plain HTML. The important change is that auth-state branching moves out of the component tree. `App.razor` should become a stable host for either the static login document at `/login` or the interactive authenticated route set everywhere else.
Then add the pre-roll modal to the workspace play flow. Keep the state and orchestration in the existing play coordinator rather than letting `CharacterPanel` call the API itself. Change the roll callback path from `Guid` to `CharacterSheetSkill` so the coordinator has the skill name and expression immediately. Update `RpgRoller/Components/Pages/HomeControls/SkillGroupBlock.razor(.cs)` and `CharacterPanel.razor(.cs)` to pass the full skill object upward. In `RpgRoller/Components/Pages/WorkspaceState.cs`, add modal state for whether the prompt is open, which skill is pending, the pending raw modifier text, whether the modal is submitting, and the current validation message. In `RpgRoller/Components/Pages/WorkspacePlayCoordinator.cs`, replace the direct-roll entry point with a Rolemaster-aware method that either opens the modal or immediately rolls with modifier `0` for other rulesets. Add companion methods to confirm and cancel the pending Rolemaster roll.
After that, introduce real component routes for the authenticated pages. Create `RpgRoller/Components/Pages/PlayPage.razor`, `CampaignsPage.razor`, and `AdminPage.razor`, each with an explicit `@page` directive. In the first implementation pass, it is acceptable to keep much of the existing state and coordinator logic by adapting `Workspace` so each route uses only the subtree it needs. The key result of this milestone is that the URL changes from `/play` to `/campaigns` to `/admin` and each route refreshes correctly.
Render the modal near the bottom of `RpgRoller/Components/Pages/Workspace.razor`, alongside the existing character modals, so it shares the same page-level ownership as other overlays. Create a dedicated component pair at `RpgRoller/Components/Pages/HomeControls/RolemasterSkillRollModal.razor` and `RolemasterSkillRollModal.razor.cs` instead of expanding `CharacterPanel` further. The modal should show the skill name, the stored expression, a short help line explaining that blank means zero and negative numbers are allowed, and a single signed-number input. The input should autofocus via `ElementReference.FocusAsync()`. Escape should cancel. Enter should submit through the form. Clicking the overlay outside the dialog card should cancel, while clicking inside the card must not bubble out. Use the existing `.modal-overlay` and `.modal-card` styling patterns first; only add CSS in `RpgRoller/wwwroot/styles.css` if the modal needs a small amount of spacing or width tuning.
Once the routes exist, remove `screen` as a persistence and navigation concept. Delete the `CurrentScreen` routing responsibility from `WorkspaceState.cs` and remove the `screen` `sessionStorage` reads and writes from `WorkspaceSessionCoordinator.cs`. Replace menu items in `Workspace.razor.cs` and `AppHeader.razor` wiring so they navigate through `NavigationManager.NavigateTo(...)` or plain links to `/play`, `/campaigns`, and `/admin`. Keep `sessionStorage` only for true view preferences such as mobile panel state, selected campaign when appropriate, and roll visibility if those still earn their complexity.
Validation belongs in both UI and server code. The modal should trim whitespace and treat an empty field as `0`. If parsing fails, keep the modal open and show an inline error such as “Enter a whole number like 20, -15, or leave blank for 0.” On the server, reject values outside the same Rolemaster modifier limits already enforced by `DiceRules`, namely `-1000` through `1000`. Reuse the existing API error path so invalid requests still surface as user-facing errors without breaking the page.
The next pass is the structural split. Extract the common authenticated chrome into a dedicated component such as `RpgRoller/Components/Pages/AuthenticatedShell.razor`. This shared shell should own the header, logout action, health banner, and toast stack. Then move the play-only DOM, campaign management DOM, and admin DOM out of the monolithic conditional branches in `Workspace.razor` and into page-specific route components. `PlayPage` should own SSE startup and the play-specific panels. `CampaignsPage` should own character create and edit workflows. `AdminPage` should own admin-only data loading and buttons. The goal is that each route owns a smaller and more stable subtree, rather than all authenticated screens living under one branching root.
Finally, update documentation and tests. `README.md` must describe the new Rolemaster roll flow in current-state language, not as a changelog note. Extend service tests to prove that a situational modifier changes Rolemaster totals, triggers retry evaluation from the combined total, and applies again on the retry attempt. Extend API tests to prove the new request payload, server-side Rolemaster-only validation, and breakdown text. Extend Playwright smoke coverage so the browser proves the modal opens only on Rolemaster skill rolls, autofocus works, Enter submits, Escape and outside click dismiss, invalid text stays inline, and a positive situational bonus can be used to cause a retry-enabled result.
Finally, revisit startup sequencing. Because API reads still depend on JS interop, some post-render initialization may remain necessary, but it should be limited to the page that actually needs it. Remove the pattern where the authenticated shell root performs several structural follow-up renders merely to decide which screen to show. If staged initialization remains on `/play`, it should be contained to the play page and should reveal a stable page-local loading shell rather than reshaping the entire authenticated app. Record the exact remaining `OnAfterRenderAsync` responsibilities in the code and in `README.md`.
Throughout the rewrite, keep the documentation and tests aligned. `README.md` must stop describing the rewrite as planned once the code lands, and the host and smoke tests must verify the new route-first behavior rather than preserve the old root-path assumptions.
## Milestones
### Milestone 1: Make `/` An Explicit Entry Redirect
At the end of this milestone, a browser request to `/` no longer renders either the auth page or the workspace directly. Instead, the server returns a redirect to `/login` or `/play` based on the session cookie. The anonymous auth page is reachable at `/login`, and logging in transitions the user to `/play`.
Implement this by adding the new entry endpoint mapping, updating `App.razor` to host `/login` without auth-state branching, and changing `rpgroller-api.js` so successful login goes to `/play` rather than `/`. Also update `Home.razor.cs` or its replacement logout helper so logout navigates to `/login` with the existing status message query behavior.
Proof for this milestone is simple and observable. Anonymous `GET /` returns a redirect to `/login`. Authenticated `GET /` returns a redirect to `/play`. Opening `/login` renders the current static auth markup without loading `_framework/blazor.web.js`. Logging in from `/login` lands on the play workspace.
### Milestone 2: Add Real Authenticated Routes Without Breaking Features
At the end of this milestone, `/play`, `/campaigns`, and `/admin` all exist as first-class routes, and the hamburger menu moves between them using URLs rather than `sessionStorage`. Feature behavior may still be backed by some shared workspace code, but the route model is now real.
Implement this by creating the new page components and adapting the current workspace logic so the correct route renders the correct content. During this milestone it is acceptable to keep a shared backing component or service if that reduces churn, but the URL must be the authoritative screen selection mechanism. Direct navigation to `/campaigns` should show campaign management, not the play screen followed by an in-memory switch. Direct navigation to `/admin` should either show the admin page for admins or redirect non-admin users to `/play`.
Acceptance for this milestone is that refreshing `/campaigns` leaves the user on `/campaigns`, refreshing `/play` leaves the user on `/play`, and opening `/admin` as a non-admin does not expose admin controls.
### Milestone 3: Split The Monolithic Workspace Tree
At the end of this milestone, there is no longer a single authenticated component that conditionally renders all major screens under one branch-heavy root. Shared authenticated chrome is extracted, and each route owns its own main content subtree.
Implement this by introducing a shared authenticated shell component and moving the play, campaign management, and admin markup and page-specific coordination into route-owned components. Keep shared models and helper methods where they still make sense, but stop letting the root workspace decide which major screen exists in the DOM. If common state still exists, narrow it to user identity, selected campaign context, and shared feedback only.
Acceptance for this milestone is partly structural and partly behavioral. Structurally, `Workspace.razor` should no longer contain mutually exclusive branches for play, management, and admin screens. Behaviorally, the DOM-wrap smoke test or its replacement should still pass while each route loads only the controls it needs.
### Milestone 4: Reduce Startup Churn And Finalize Docs
At the end of this milestone, the authenticated shell no longer uses `OnAfterRenderAsync` as the orchestration point for screen selection and broad structural staging. Any remaining post-render work is page-local, justified, and documented.
Implement this by moving any remaining screen-routing or shell-bootstrap logic out of `Workspace.razor.cs`, narrowing `OnAfterRenderAsync` responsibilities, and updating `README.md` to describe the completed route-first architecture rather than a planned rewrite. Also update `POSTMORTEM.md` only if a concise follow-up note is warranted; do not rewrite its historical analysis.
Acceptance for this milestone is a passing automated suite plus a manual browser run where `/`, `/login`, `/play`, `/campaigns`, and `/admin` all behave consistently with the final route model.
## Concrete Steps
All commands below run from `D:\Code\RpgRoller`.
Run all commands from the repository root, which is `/home/frank/Code/RpgRoller`.
First, implement the request and engine changes, then run targeted tests while the work is still small.
Start by inspecting the current route and auth files before editing:
dotnet test RpgRoller.Tests/RpgRoller.Tests.csproj --filter "FullyQualifiedName~ServiceRolemasterRollTests|FullyQualifiedName~RolemasterApiTests"
sed -n '1,220p' RpgRoller/Components/App.razor
sed -n '1,220p' RpgRoller/Components/Pages/Home.razor
sed -n '1,260p' RpgRoller/Components/Pages/Workspace.razor.cs
sed -n '1,260p' RpgRoller/Components/Pages/WorkspaceSessionCoordinator.cs
sed -n '1,260p' RpgRoller/wwwroot/js/rpgroller-api.js
sed -n '1,220p' RpgRoller.Tests/Api/FrontendHostTests.cs
sed -n '1,260p' tests/e2e/smoke.js
After the UI modal is in place, run the browser smoke suite directly.
When implementing Milestone 1, update the host test first so the intended redirect behavior is explicit:
pwsh ./scripts/run-playwright.ps1
dotnet test RpgRoller.Tests/RpgRoller.Tests.csproj --filter FrontendHostTests
Before closing the iteration, format every touched file using the repository rule. Replace the placeholder list with the exact touched file paths separated by semicolons.
Expected direction after the edits:
jb cleanupcode --build=False RpgRoller/Contracts/ApiContracts.cs;RpgRoller/Api/SkillEndpoints.cs;RpgRoller/Services/IGameService.cs;RpgRoller/Services/GameService.cs;RpgRoller/Services/GameRollService.cs;RpgRoller/Services/RollEngine.cs;RpgRoller/Services/RolemasterRollEngine.cs;RpgRoller/Services/RollBreakdownFormatter.cs;RpgRoller/Components/Pages/Workspace.razor;RpgRoller/Components/Pages/WorkspaceState.cs;RpgRoller/Components/Pages/WorkspacePlayCoordinator.cs;RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor;RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor.cs;RpgRoller/Components/Pages/HomeControls/SkillGroupBlock.razor;RpgRoller/Components/Pages/HomeControls/SkillGroupBlock.razor.cs;RpgRoller/Components/Pages/HomeControls/RolemasterSkillRollModal.razor;RpgRoller/Components/Pages/HomeControls/RolemasterSkillRollModal.razor.cs;RpgRoller/wwwroot/styles.css;RpgRoller.Tests/Services/ServiceRolemasterRollTests.cs;RpgRoller.Tests/Api/RolemasterApiTests.cs;tests/e2e/smoke.spec.js;README.md
RootPath_RedirectsToLogin_WhenAnonymous
RootPath_RedirectsToPlay_WhenAuthenticated
LoginPath_ServesStaticAuthMarkup
Run the repository-wide local CI script as the final proof.
After wiring `/login` and the root redirect, run the app locally:
pwsh ./scripts/ci-local.ps1
dotnet run --project RpgRoller/RpgRoller.csproj
Then create one brief commit for the iteration.
Then verify in a browser:
git add TASKS.md README.md RpgRoller/Contracts/ApiContracts.cs RpgRoller/Api/SkillEndpoints.cs RpgRoller/Services/IGameService.cs RpgRoller/Services/GameService.cs RpgRoller/Services/GameRollService.cs RpgRoller/Services/RollEngine.cs RpgRoller/Services/RolemasterRollEngine.cs RpgRoller/Services/RollBreakdownFormatter.cs RpgRoller/Components/Pages/Workspace.razor RpgRoller/Components/Pages/WorkspaceState.cs RpgRoller/Components/Pages/WorkspacePlayCoordinator.cs RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor.cs RpgRoller/Components/Pages/HomeControls/SkillGroupBlock.razor RpgRoller/Components/Pages/HomeControls/SkillGroupBlock.razor.cs RpgRoller/Components/Pages/HomeControls/RolemasterSkillRollModal.razor RpgRoller/Components/Pages/HomeControls/RolemasterSkillRollModal.razor.cs RpgRoller/wwwroot/styles.css RpgRoller.Tests/Services/ServiceRolemasterRollTests.cs RpgRoller.Tests/Api/RolemasterApiTests.cs tests/e2e/smoke.spec.js
git commit -m "Add Rolemaster situational roll modifier prompt"
open http://localhost:5000/
observe: anonymous request lands on /login
submit valid credentials
observe: browser lands on /play
Expected proof points during implementation are:
When implementing route pages and navigation, prefer running the focused smoke suite against a temporary database:
A Rolemaster skill such as d100!+50 opens the modal instead of rolling immediately.
Leaving the field blank and pressing Enter records a normal roll.
Typing 20 and pressing Enter records a breakdown that visibly contains +20.
If the first attempt becomes 76 through 110 after adding the situational modifier, the existing retry flow still fires.
A D6 or D&D 5e skill still rolls immediately with no popup.
node ./scripts/run-selenium.js
If the app is already running and a faster inner loop is needed, run the checked-in smoke file directly:
npm run e2e:smoke
After each milestone that touches C# files, run the relevant test suite and then the full backend suite before moving on:
dotnet test RpgRoller.Tests/RpgRoller.Tests.csproj --collect:"XPlat Code Coverage" --settings RpgRoller.Tests/coverlet.runsettings
After major frontend milestones, repeat browser verification in Firefox. If a Firefox profile with RoboForm is available, include that manual check and record the result in `Surprises & Discoveries` or `Outcomes & Retrospective`.
## Validation and Acceptance
Acceptance is behavioral, not just “the code compiles.”
The final implementation is acceptable only if all of the following behaviors are true and visible.
Start the browser smoke environment with `pwsh ./scripts/run-playwright.ps1` or run the application locally and navigate to the play screen. Create or seed a Rolemaster campaign, a character, and an open-ended skill such as `Observation` with `d100!+50`, `fumbleRange: 5`, and `rolemasterAutoRetry: true`.
Anonymous navigation:
On the play screen, clicking `Roll Observation` must open a modal dialog. The modifier input must receive focus immediately. Pressing Escape must close the dialog with no roll recorded. Clicking the backdrop outside the dialog must also close it with no roll recorded. Reopening the dialog and pressing Enter with the field blank must submit a normal zero-modifier roll.
`GET /` returns an HTTP redirect to `/login`. Opening `/login` shows the static auth document with the current register and login forms. The `/login` document must not load `_framework/blazor.web.js`, and it must still include the existing auth page hooks used by `rpgroller-api.js`.
Reopen the dialog and enter `20`. After confirming, the recorded roll detail must show the temporary modifier as a separate term in the breakdown. If the first computed attempt lands in the retry window, the breakdown must show the same `+20` in both attempts and the final total must reflect the retry bonus on top of the retry attempt. The compact log entry should still show the existing retry badge behavior, and expanding the detail should show the existing attempt markers on the dice chips.
Authenticated navigation:
Run `dotnet test RpgRoller.Tests/RpgRoller.Tests.csproj` and expect the suite to pass. The new or updated tests should fail before the feature is implemented and pass after it is complete. Run `pwsh ./scripts/ci-local.ps1` and expect the build, tests, coverage gate, and Playwright smoke test to pass end-to-end.
After a successful login, the browser lands on `/play`. Opening `/` with an already valid session cookie redirects to `/play`. Refreshing `/play`, `/campaigns`, or `/admin` preserves the same route instead of rebuilding everything behind `/`.
Menu behavior:
The header menu items navigate to real routes. The active state matches the current route. Non-admin users cannot remain on `/admin`; they are redirected to `/play` or shown a deliberate authorization result defined by the implementation, but not an exposed admin UI.
Workspace stability:
The authenticated play route continues to support the existing play workflow, including campaign log rendering, character controls, and custom roll actions. The DOM-wrap smoke coverage for extension-like mutations must still pass, either through the existing test or an updated equivalent that targets `/play`.
Automated coverage:
`dotnet test RpgRoller.Tests/RpgRoller.Tests.csproj --collect:"XPlat Code Coverage" --settings RpgRoller.Tests/coverlet.runsettings` passes.
`node ./scripts/run-selenium.js` passes against a temporary SQLite database.
If any previous tests are deleted or renamed because they encoded the old `/` behavior, replace them with tests that prove the new route model instead of simply removing coverage.
## Idempotence and Recovery
This feature should be implemented additively and is safe to retry. Re-running the same code-edit steps should only replace the intended current-state logic. No migration is expected for this feature because the modifier is transient. If a draft implementation accidentally introduces persistence for the modifier, remove that persistence before considering the work complete.
This rewrite should be implemented as a sequence of additive, testable steps. Each milestone must leave the app runnable and the tests meaningful. Avoid a large flag day where `/` is changed, the route pages half-exist, and the smoke suite is left broken for an extended period.
If the UI modal gets into a bad state during development, the safe recovery path is to clear only the workspace prompt state in `WorkspaceState` and retry the interaction. If Playwright fails because the temporary application process is still running, stop the lingering `dotnet` process once, rerun the script, and confirm the health endpoint responds before rechecking the smoke suite.
The safest recovery strategy is to keep the current workspace internals temporarily while introducing the new route model. That means it is acceptable to reuse `Workspace` behind the new page routes during Milestone 2, as long as the route behavior is correct and clearly transitional. After that, extract route-specific subtrees in Milestone 3.
When changing redirects or login targets, update the host and Selenium assertions in the same commit as the code change so the repository never has code and tests describing different route contracts.
Use a temporary SQLite database for Selenium verification, as required by the repo instructions, so browser tests do not mutate the canonical development database.
## Artifacts and Notes
The most important visible transcript to preserve during implementation is the breakdown text for a retry-causing situational bonus. A representative successful result should look like this shape, with different random numbers allowed:
Current evidence that must be retired by this rewrite:
08+50+20=78; retry(+5): 42+50+20=112; final=117
RpgRoller.Tests/Api/FrontendHostTests.cs
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Contains("_framework/blazor.web.js", html);
The corresponding browser-level proof should include a log row that shows the final result, retains the existing retry badge, and expands into detail whose dice titles still include attempt markers such as:
tests/e2e/smoke.js
browser checks for anonymous `/`, static `/login`, authenticated `/`, and the authenticated workspace flows
Roll 8, step 1, attempt 1, Rolemaster open-ended initial
Roll 42, step 1, retry attempt 2, Rolemaster open-ended initial
Current evidence that explains the bootstrap constraint:
RpgRoller/Components/RpgRollerApiClient.cs
var response = await js.InvokeAsync<JsApiResponse>("rpgRollerApi.request", method, path, payload);
Current evidence that Milestone 2 is intentionally transitional rather than final:
RpgRoller/Components/Pages/Workspace.razor
@if (IsPlayRoute) { ... }
else if (IsCampaignsRoute) { ... }
else if (IsAdminRoute) { ... }
## Interfaces and Dependencies
At the end of the implementation, these repository interfaces should exist in the following shapes.
The implementation must continue to use the existing ASP.NET Core hosting model in `RpgRoller/Program.cs`, the minimal API auth surface in `RpgRoller/Api`, and the existing `IGameService` session lookup methods. Do not introduce a second auth mechanism.
In `RpgRoller/Contracts/ApiContracts.cs`, define:
At the end of Milestone 1, the codebase must contain a host-level entry point with behavior equivalent to:
public sealed record RollSkillRequest(string Visibility, int SituationalModifier = 0);
GET /
if session cookie maps to a valid user: redirect to /play
otherwise: redirect to /login
In `RpgRoller/Services/IGameService.cs`, define:
At the end of Milestone 2, the codebase must contain route components equivalent to:
ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility, int situationalModifier = 0);
/play
/campaigns
/admin
In `RpgRoller/Services/GameRollService.cs`, define a matching method:
Each of those routes must be directly navigable and refreshable.
public ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility, int situationalModifier)
At the end of Milestone 3, the codebase must contain a shared authenticated shell component or layout that owns common header and feedback concerns, while route pages own their feature-specific DOM. Stable names are recommended:
In `RpgRoller/Services/RollEngine.cs`, extend the Rolemaster path with:
RpgRoller/Components/Pages/AuthenticatedShell.razor
RpgRoller/Components/Pages/PlayPage.razor
RpgRoller/Components/Pages/CampaignsPage.razor
RpgRoller/Components/Pages/AdminPage.razor
public (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) Roll(
RulesetKind ruleset,
DiceExpression expression,
int wildDice,
bool allowFumble,
int? fumbleRange,
bool rolemasterAutoRetry = false,
int situationalModifier = 0)
These exact filenames are recommended because they make the route split obvious to a new contributor, but equivalent names are acceptable if the same ownership boundaries are preserved and the README is updated accordingly.
In `RpgRoller/Components/Pages/HomeControls/SkillGroupBlock.razor.cs` and `CharacterPanel.razor.cs`, change the roll callback shape to carry the full skill:
## Revision Note
[Parameter]
public EventCallback<CharacterSheetSkill> RollSkillRequested { get; set; }
[Parameter]
public EventCallback<CharacterSheetSkill> RollRequested { get; set; }
Create a new modal component at `RpgRoller/Components/Pages/HomeControls/RolemasterSkillRollModal.razor` and `.razor.cs` that accepts at least the visibility flag, skill label, expression label, raw modifier text, submit state, and confirm/cancel callbacks. The component should use only existing Blazor and repository dependencies; no third-party modal library is required or desired.
Plan revision note (2026-04-14 / Codex): created the initial ExecPlan for the new Rolemaster one-shot situational modifier modal because `TASKS.md` was empty and the feature needs a self-contained implementation guide before coding begins.
Plan revision note (2026-04-14 / Codex): updated the living plan after the first backend slice landed so progress, discoveries, and retrospective match the new transient request path, explicit breakdown formatting, and added service/API coverage.
Plan revision note (2026-04-14 / Codex): updated the living plan again after the UI slice completed so the document now reflects the shipped modal behavior, the Playwright coverage, and the final end-to-end outcome.
2026-05-04 17:52Z: Initial ExecPlan created after the route-first rewrite direction was approved and the README was overhauled. The main reason for the plan is to replace the dual-purpose `/` shell with explicit routes while keeping the repository testable at every step.

193
package-lock.json generated
View File

@@ -6,70 +6,167 @@
"": {
"name": "rpgroller",
"devDependencies": {
"@playwright/test": "^1.59.1"
"selenium-webdriver": "^4.43.0"
}
},
"node_modules/@playwright/test": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
"integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
"node_modules/@bazel/runfiles": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/@bazel/runfiles/-/runfiles-6.5.0.tgz",
"integrity": "sha512-RzahvqTkfpY2jsDxo8YItPX+/iZ6hbiikw1YhE0bA9EKBR5Og8Pa6FHn9PO9M0zaXRVsr0GFQLKbB/0rzy9SzA==",
"dev": true
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"dev": true
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"dev": true
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"dev": true
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"dev": true
},
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.59.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"dev": true
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"dev": true
},
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"dev": true,
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"dev": true
},
"node_modules/selenium-webdriver": {
"version": "4.43.0",
"resolved": "https://registry.npmjs.org/selenium-webdriver/-/selenium-webdriver-4.43.0.tgz",
"integrity": "sha512-dV4zBTT37or3Z3/8uD6rS8zvd4ZxPuG4EJVlqYIbZCGZCYttZm7xb9rlFLSk4rrsQHAeDYvudl7cquo0vWpHjg==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/SeleniumHQ"
},
{
"type": "opencollective",
"url": "https://opencollective.com/selenium"
}
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.59.1"
},
"bin": {
"playwright": "cli.js"
"@bazel/runfiles": "^6.5.0",
"jszip": "^3.10.1",
"tmp": "^0.2.5",
"ws": "^8.20.0"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
"node": ">= 20.0.0"
}
},
"node_modules/playwright-core": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"dev": true
},
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/tmp": {
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
"integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==",
"dev": true,
"engines": {
"node": ">=18"
"node": ">=14.14"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"dev": true
},
"node_modules/ws": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
"dev": true,
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}

View File

@@ -2,11 +2,10 @@
"name": "rpgroller",
"private": true,
"scripts": {
"e2e": "playwright test",
"e2e:smoke": "playwright test tests/e2e/smoke.spec.js --reporter=line",
"e2e:install": "playwright install chromium"
"e2e": "node scripts/run-selenium.js",
"e2e:smoke": "node tests/e2e/smoke.js"
},
"devDependencies": {
"@playwright/test": "^1.59.1"
"selenium-webdriver": "^4.43.0"
}
}

View File

@@ -1,13 +0,0 @@
const { defineConfig } = require("@playwright/test");
module.exports = defineConfig({
testDir: "./tests/e2e",
timeout: 30_000,
fullyParallel: false,
reporter: "line",
use: {
baseURL: process.env.PLAYWRIGHT_BASE_URL || "http://127.0.0.1:5000",
headless: true,
trace: "retain-on-failure"
}
});

View File

@@ -1,6 +1,7 @@
param(
[switch]$SkipDotnetRestore,
[switch]$SkipBuild,
[switch]$SkipBrowserSmoke,
[switch]$SkipPlaywright
)
@@ -66,10 +67,6 @@ try {
npm ci
}
Invoke-Step -Name "Ensure Playwright browser" -Action {
npm exec playwright install chromium
}
Invoke-Step -Name "Run tests" -Action {
if ($SkipBuild) {
dotnet test RpgRoller.Tests/RpgRoller.Tests.csproj --verbosity minimal --collect:"XPlat Code Coverage" --settings RpgRoller.Tests/coverlet.runsettings --results-directory $testResultsRoot
@@ -83,9 +80,9 @@ try {
pwsh ./scripts/check-coverage.ps1 -MinLineRate 0.90 -MinBranchRate 0.70 -ResultsRoot $testResultsRoot
}
if (-not $SkipPlaywright) {
Invoke-Step -Name "Run Playwright smoke test" -Action {
pwsh ./scripts/run-playwright.ps1
if (-not ($SkipBrowserSmoke -or $SkipPlaywright)) {
Invoke-Step -Name "Run Selenium smoke test" -Action {
node ./scripts/run-selenium.js
}
}

View File

@@ -1,63 +0,0 @@
param(
[string]$BaseUrl = "http://127.0.0.1:5095",
[string]$Spec = "tests/e2e/smoke.spec.js"
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$repoRoot = Split-Path -Parent $scriptDir
$appUrl = [Uri]$BaseUrl
$healthUrl = "$BaseUrl/api/health"
$tempDbPath = Join-Path $env:TEMP ("rpgroller-playwright-{0}.db" -f [Guid]::NewGuid().ToString("N"))
$process = $null
Push-Location $repoRoot
try {
$env:ConnectionStrings__RpgRoller = "Data Source=$tempDbPath"
$env:PLAYWRIGHT_BASE_URL = $BaseUrl
$process = Start-Process dotnet -ArgumentList @(
"run",
"--project",
"RpgRoller/RpgRoller.csproj",
"--verbosity"
"minimal"
"--urls",
$BaseUrl
) -WorkingDirectory $repoRoot -PassThru -NoNewWindow
$response = $null
for ($i = 0; $i -lt 60; $i++) {
try {
$response = Invoke-WebRequest -Uri $healthUrl -UseBasicParsing -TimeoutSec 2
if ($response.StatusCode -eq 200) {
break
}
}
catch {
Start-Sleep -Milliseconds 500
}
Start-Sleep -Milliseconds 500
}
if (-not $response -or $response.StatusCode -ne 200) {
throw "Application failed to start on $BaseUrl."
}
npm exec playwright test $Spec -- --reporter=line
if ($LASTEXITCODE -ne 0) {
throw "Playwright exited with code $LASTEXITCODE."
}
}
finally {
if ($process -and -not $process.HasExited) {
Stop-Process -Id $process.Id -Force
}
Remove-Item Env:\ConnectionStrings__RpgRoller -ErrorAction SilentlyContinue
Remove-Item Env:\PLAYWRIGHT_BASE_URL -ErrorAction SilentlyContinue
Pop-Location
}

90
scripts/run-selenium.js Normal file
View File

@@ -0,0 +1,90 @@
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { spawn } = require("node:child_process");
const repoRoot = path.resolve(__dirname, "..");
const baseUrl = process.env.SELENIUM_BASE_URL || "http://127.0.0.1:5095";
const healthUrl = new URL("/api/health", baseUrl).toString();
const smokeScript = process.argv[2] || "tests/e2e/smoke.js";
const tempDbPath = path.join(os.tmpdir(), `rpgroller-selenium-${Date.now()}-${Math.random().toString(16).slice(2)}.db`);
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function waitForHealthCheck() {
for (let attempt = 0; attempt < 60; attempt += 1) {
try {
const response = await fetch(healthUrl);
if (response.ok) {
return;
}
} catch {
}
await delay(500);
}
throw new Error(`Application failed to start on ${baseUrl}.`);
}
function spawnProcess(command, args, options) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, options);
child.once("error", reject);
resolve(child);
});
}
async function run() {
const app = await spawnProcess(
"dotnet",
["run", "--project", "RpgRoller/RpgRoller.csproj", "--verbosity", "minimal", "--urls", baseUrl],
{
cwd: repoRoot,
stdio: "inherit",
env: {
...process.env,
ConnectionStrings__RpgRoller: `Data Source=${tempDbPath}`
}
}
);
try {
await waitForHealthCheck();
const smoke = await spawnProcess("node", [smokeScript], {
cwd: repoRoot,
stdio: "inherit",
env: {
...process.env,
SELENIUM_BASE_URL: baseUrl
}
});
const exitCode = await new Promise((resolve, reject) => {
smoke.once("error", reject);
smoke.once("exit", resolve);
});
if (exitCode !== 0) {
throw new Error(`Selenium smoke exited with code ${exitCode}.`);
}
} finally {
app.kill("SIGTERM");
await delay(500);
if (!app.killed) {
app.kill("SIGKILL");
}
if (fs.existsSync(tempDbPath)) {
fs.rmSync(tempDbPath, { force: true });
}
}
}
run().catch((error) => {
console.error(error.stack || error);
process.exitCode = 1;
});

View File

@@ -0,0 +1,91 @@
const assert = require("node:assert/strict");
const {
absoluteUrl,
clickByTitle,
clickText,
fillInput,
getValue,
registerAndLoginApi,
runSmokeTests,
seedAuthenticatedBrowser,
uniqueName,
waitFor,
withDriver,
waitForSelector,
waitForText,
waitForUrl
} = require("./lib/selenium-smoke");
const tests = [
{
name: "campaign management rerenders immediately after campaign and character mutations",
run: async () => withDriver({}, async (driver) => {
const username = uniqueName("campaign-refresh");
const { sessionCookie } = await registerAndLoginApi(username, "Campaign Refresh");
const campaignName = uniqueName("campaign");
const characterName = uniqueName("character");
const updatedCharacterName = uniqueName("character-updated");
await seedAuthenticatedBrowser(driver, sessionCookie);
await driver.get(absoluteUrl("/campaigns"));
await waitForUrl(driver, "/campaigns");
await waitForText(driver, "Character Management");
await clickText(driver, "button", "Add campaign", { contains: true });
await waitForSelector(driver, "#campaign-name");
await fillInput(driver, "#campaign-name", campaignName);
await fillInput(driver, "#campaign-ruleset", "d6");
await clickText(driver, "button", "Create Campaign");
await waitFor(
driver,
() => driver.executeScript(
(name) => (document.querySelector("#campaign-select")?.textContent || "").includes(name),
campaignName
),
`Expected campaign ${campaignName} to appear in the campaign selector.`
);
const selectedCampaignId = await getValue(driver, "#campaign-select");
assert.ok(selectedCampaignId, "Expected a selected campaign after campaign creation.");
await clickText(driver, "button", "Add character", { contains: true });
await waitForSelector(driver, "#character-create-name");
await fillInput(driver, "#character-create-name", characterName);
await clickText(driver, "button", "Create Character");
await waitFor(
driver,
() => driver.executeScript(
(name) => [...document.querySelectorAll(".management-list strong")].some((element) => element.textContent.includes(name)),
characterName
),
`Expected character ${characterName} to appear in the campaign roster.`
);
await clickByTitle(driver, "Edit character");
await waitForSelector(driver, "#character-edit-name");
await fillInput(driver, "#character-edit-name", updatedCharacterName);
await clickText(driver, "button", "Save Character");
await waitFor(
driver,
() => driver.executeScript(
(nextName, previousName) => {
const names = [...document.querySelectorAll(".management-list strong")]
.map((element) => element.textContent || "");
return names.some((name) => name.includes(nextName)) && names.every((name) => !name.includes(previousName));
},
updatedCharacterName,
characterName
),
`Expected updated character name ${updatedCharacterName} to appear immediately in the campaign roster.`
);
})
}
];
runSmokeTests(tests).catch((error) => {
console.error(error.stack || error);
process.exitCode = 1;
});

View File

@@ -0,0 +1,77 @@
(function injectDomWrapScript() {
const script = document.createElement("script");
script.textContent = `(() => {
const wrappedMarker = "rrWrappedByTest";
const errorPatterns = /error applying batch|unhandled exception on the current circuit/i;
const errors = [];
window.__rrDomWrapTestErrors = errors;
const originalConsoleError = console.error.bind(console);
console.error = (...args) => {
const text = args.map((arg) => String(arg)).join(" ");
if (errorPatterns.test(text)) {
errors.push(text);
}
originalConsoleError(...args);
};
window.addEventListener("error", (event) => {
const text = [event.message, event.filename, event.lineno, event.colno].filter(Boolean).join(" ");
if (errorPatterns.test(text)) {
errors.push(text);
}
});
window.addEventListener("unhandledrejection", (event) => {
const reason = event.reason ? String(event.reason) : "";
if (errorPatterns.test(reason)) {
errors.push(reason);
}
});
function wrapControl(element) {
if (!(element instanceof HTMLElement) || !element.isConnected || element.dataset[wrappedMarker] === "1") {
return;
}
const parent = element.parentNode;
if (!parent) {
return;
}
const wrapper = document.createElement("span");
wrapper.dataset[wrappedMarker] = "1";
element.dataset[wrappedMarker] = "1";
parent.insertBefore(wrapper, element);
wrapper.appendChild(element);
}
function queueWrap(node) {
if (!(node instanceof Element)) {
return;
}
if (node.matches("input, select")) {
queueMicrotask(() => wrapControl(node));
}
node.querySelectorAll("input, select").forEach((element) => {
queueMicrotask(() => wrapControl(element));
});
}
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach(queueWrap);
});
});
observer.observe(document.documentElement, { childList: true, subtree: true });
document.querySelectorAll("input, select").forEach((element) => queueWrap(element));
})();`;
(document.documentElement || document).appendChild(script);
script.remove();
})();

View File

@@ -0,0 +1,17 @@
{
"manifest_version": 2,
"name": "RpgRoller DOM Wrap Smoke",
"version": "1.0",
"description": "Wraps input controls at document start to mimic extension behavior during smoke tests.",
"content_scripts": [
{
"matches": [
"<all_urls>"
],
"js": [
"content.js"
],
"run_at": "document_start"
}
]
}

View File

@@ -0,0 +1,364 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const { Builder, By, Key, until } = require("selenium-webdriver");
const firefox = require("selenium-webdriver/firefox");
const baseUrl = process.env.SELENIUM_BASE_URL || "http://127.0.0.1:5000";
const defaultPassword = "Password123";
let uniqueSuffix = 0;
function absoluteUrl(relativePath) {
return new URL(relativePath, baseUrl).toString();
}
function normalizeText(text) {
return String(text || "").replace(/\s+/g, " ").trim();
}
function uniqueName(prefix) {
uniqueSuffix += 1;
return `${prefix}-${Date.now()}-${uniqueSuffix}`;
}
function formatCookie(sessionCookie) {
return `${sessionCookie.name}=${sessionCookie.value}`;
}
function parseSessionCookie(setCookieHeader) {
assert.ok(setCookieHeader, "Missing Set-Cookie header for session login.");
const match = setCookieHeader.match(/(?:^|,\s*)rpgroller_session=([^;]+)/);
assert.ok(match, `Could not find rpgroller_session in Set-Cookie header: ${setCookieHeader}`);
return { name: "rpgroller_session", value: match[1] };
}
async function request(relativePath, options = {}) {
const headers = new Headers(options.headers || {});
if (options.cookie) {
headers.set("cookie", typeof options.cookie === "string" ? options.cookie : formatCookie(options.cookie));
}
let body;
if (options.json !== undefined) {
headers.set("content-type", "application/json");
headers.set("accept", "application/json");
body = JSON.stringify(options.json);
}
return fetch(absoluteUrl(relativePath), {
method: options.method || "GET",
headers,
body,
redirect: options.redirect || "follow"
});
}
async function postJson(relativePath, payload, options = {}) {
const response = await request(relativePath, {
method: "POST",
json: payload,
cookie: options.cookie,
redirect: options.redirect
});
assert.equal(response.status, 200, `POST ${relativePath} failed with ${response.status}.`);
return response.json();
}
async function deleteJson(relativePath, options = {}) {
const response = await request(relativePath, {
method: "DELETE",
cookie: options.cookie
});
assert.equal(response.status, 200, `DELETE ${relativePath} failed with ${response.status}.`);
return response.json();
}
async function registerUser(username, displayName, password = defaultPassword) {
return postJson("/api/auth/register", { username, password, displayName });
}
async function loginUser(username, password = defaultPassword) {
const response = await request("/api/auth/login", {
method: "POST",
json: { username, password },
redirect: "manual"
});
assert.equal(response.status, 200, `Login for ${username} failed with ${response.status}.`);
const sessionCookie = parseSessionCookie(response.headers.get("set-cookie"));
const user = await response.json();
return { sessionCookie, user };
}
async function registerAndLoginApi(username, displayName, password = defaultPassword) {
await registerUser(username, displayName, password);
return loginUser(username, password);
}
function resolveFirefoxBinary() {
const candidates = [
process.env.FIREFOX_BINARY,
"/snap/firefox/current/usr/lib/firefox/firefox",
"/usr/bin/firefox"
];
return candidates.find((candidate) => candidate && fs.existsSync(candidate)) || null;
}
async function createDriver(options = {}) {
const firefoxOptions = new firefox.Options().addArguments("-headless");
const binary = resolveFirefoxBinary();
if (binary) {
firefoxOptions.setBinary(binary);
}
const driver = await new Builder()
.forBrowser("firefox")
.setFirefoxOptions(firefoxOptions)
.build();
await driver.manage().setTimeouts({
implicit: 0,
pageLoad: 30000,
script: 30000
});
if (options.addonPath) {
await driver.installAddon(path.resolve(options.addonPath), true);
}
return driver;
}
async function withDriver(options, callback) {
const driver = await createDriver(options);
try {
return await callback(driver);
} finally {
await driver.quit();
}
}
async function seedAuthenticatedBrowser(driver, sessionCookie) {
await driver.get(absoluteUrl("/login"));
await driver.manage().addCookie({
name: sessionCookie.name,
value: sessionCookie.value,
path: "/"
});
}
async function waitFor(driver, predicate, message, timeout = 15000) {
await driver.wait(async () => Boolean(await predicate()), timeout, message);
}
async function waitForUrl(driver, relativePath, timeout = 15000) {
const expectedUrl = absoluteUrl(relativePath);
await driver.wait(until.urlIs(expectedUrl), timeout, `Expected URL ${expectedUrl}.`);
}
async function waitForSelector(driver, selector, timeout = 15000) {
const element = await driver.wait(until.elementLocated(By.css(selector)), timeout, `Expected selector ${selector}.`);
await driver.wait(until.elementIsVisible(element), timeout, `Expected visible selector ${selector}.`);
return element;
}
async function waitForText(driver, text, timeout = 15000) {
await waitFor(
driver,
() => driver.executeScript((expected) => document.body.innerText.includes(expected), text),
`Expected page text "${text}".`,
timeout
);
}
async function waitForAbsent(driver, selector, timeout = 15000) {
await waitFor(
driver,
() => driver.executeScript((css) => !document.querySelector(css), selector),
`Expected selector ${selector} to be absent.`,
timeout
);
}
async function selectorCount(driver, selector) {
return driver.executeScript((css) => document.querySelectorAll(css).length, selector);
}
async function hasSelector(driver, selector) {
return driver.executeScript((css) => Boolean(document.querySelector(css)), selector);
}
async function elementText(driver, selector) {
const text = await driver.executeScript((css) => document.querySelector(css)?.textContent || "", selector);
return normalizeText(text);
}
async function allTexts(driver, selector) {
const texts = await driver.executeScript(
(css) => [...document.querySelectorAll(css)].map((element) => element.textContent || ""),
selector
);
return texts.map(normalizeText);
}
async function getValue(driver, selector) {
return driver.executeScript((css) => document.querySelector(css)?.value ?? null, selector);
}
async function getClassName(driver, selector) {
return driver.executeScript((css) => document.querySelector(css)?.className ?? "", selector);
}
async function getAttribute(driver, selector, attributeName) {
return driver.executeScript(
(css, attribute) => document.querySelector(css)?.getAttribute(attribute) ?? null,
selector,
attributeName
);
}
async function isChecked(driver, selector) {
return driver.executeScript((css) => Boolean(document.querySelector(css)?.checked), selector);
}
async function clickSelector(driver, selector) {
const element = await waitForSelector(driver, selector);
await element.click();
}
async function clickText(driver, selector, text, options = {}) {
const matched = await driver.executeScript(
(css, expectedText, contains, last) => {
const candidates = [...document.querySelectorAll(css)];
const normalized = expectedText.replace(/\s+/g, " ").trim();
const match = candidates.filter((candidate) => {
const candidateText = (candidate.textContent || "").replace(/\s+/g, " ").trim();
return contains ? candidateText.includes(normalized) : candidateText === normalized;
});
const element = last ? match.at(-1) : match[0];
if (!element) {
return false;
}
element.click();
return true;
},
selector,
text,
Boolean(options.contains),
Boolean(options.last)
);
assert.ok(matched, `Could not find ${selector} with text "${text}".`);
}
async function fillInput(driver, selector, value) {
const updated = await driver.executeScript(
(css, nextValue) => {
const input = document.querySelector(css);
if (!input) {
return false;
}
input.focus();
input.value = nextValue;
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new Event("change", { bubbles: true }));
return true;
},
selector,
value
);
assert.ok(updated, `Could not find input ${selector}.`);
}
async function clickLabel(driver, labelText) {
const clicked = await driver.executeScript((text) => {
const label = [...document.querySelectorAll("label")].find(
(element) => (element.textContent || "").replace(/\s+/g, " ").trim() === text
);
if (!label) {
return false;
}
const targetId = label.getAttribute("for");
const target = targetId ? document.getElementById(targetId) : label.querySelector("input,select,textarea");
if (!target) {
return false;
}
target.click();
target.dispatchEvent(new Event("change", { bubbles: true }));
return true;
}, labelText);
assert.ok(clicked, `Could not find label "${labelText}".`);
}
async function clickByTitle(driver, title) {
const clicked = await driver.executeScript((expectedTitle) => {
const button = [...document.querySelectorAll("[title]")].find((element) => element.getAttribute("title") === expectedTitle);
if (!button) {
return false;
}
button.click();
return true;
}, title);
assert.ok(clicked, `Could not find element with title "${title}".`);
}
async function getDomWrapErrors(driver) {
return driver.executeScript(() => window.__rrDomWrapTestErrors || []);
}
async function runSmokeTests(tests) {
for (let index = 0; index < tests.length; index += 1) {
const testCase = tests[index];
console.log(`[${index + 1}/${tests.length}] ${testCase.name}`);
await testCase.run();
console.log(`PASS ${testCase.name}`);
}
}
module.exports = {
Key,
absoluteUrl,
allTexts,
baseUrl,
clickByTitle,
clickLabel,
clickSelector,
clickText,
defaultPassword,
deleteJson,
elementText,
fillInput,
formatCookie,
getAttribute,
getClassName,
getDomWrapErrors,
getValue,
hasSelector,
isChecked,
postJson,
registerAndLoginApi,
request,
runSmokeTests,
seedAuthenticatedBrowser,
selectorCount,
uniqueName,
waitFor,
waitForAbsent,
waitForSelector,
waitForText,
waitForUrl,
withDriver
};

671
tests/e2e/smoke.js Normal file
View File

@@ -0,0 +1,671 @@
const assert = require("node:assert/strict");
const path = require("node:path");
const {
Key,
absoluteUrl,
allTexts,
clickByTitle,
clickLabel,
clickSelector,
clickText,
elementText,
fillInput,
getAttribute,
getClassName,
getDomWrapErrors,
getValue,
hasSelector,
isChecked,
postJson,
registerAndLoginApi,
request,
runSmokeTests,
seedAuthenticatedBrowser,
selectorCount,
uniqueName,
waitFor,
waitForAbsent,
waitForSelector,
waitForText,
waitForUrl,
withDriver
} = require("./lib/selenium-smoke");
const domWrapAddonPath = path.join(__dirname, "dom-wrap-addon");
let bootstrapAdminSession = null;
async function ensureAdminSession() {
if (bootstrapAdminSession) {
return bootstrapAdminSession;
}
const username = uniqueName("bootstrap-admin");
bootstrapAdminSession = await registerAndLoginApi(username, "Bootstrap Admin");
return bootstrapAdminSession;
}
async function openAuthenticatedPlay(driver, sessionCookie) {
await seedAuthenticatedBrowser(driver, sessionCookie);
await driver.get(absoluteUrl("/play"));
await waitForText(driver, "Campaign Log");
}
const tests = [
{
name: "home page loads auth entry points",
run: async () => withDriver({}, async (driver) => {
await driver.get(absoluteUrl("/"));
await waitForUrl(driver, "/login");
await waitForText(driver, "RpgRoller");
assert.deepEqual(await allTexts(driver, "h2"), ["Register", "Login"]);
assert.equal(await hasSelector(driver, "#register-username"), true);
assert.equal(await hasSelector(driver, "#login-password"), true);
})
},
{
name: "root document redirects anonymous users to login",
run: async () => {
const response = await request("/", { redirect: "manual" });
assert.equal(response.status, 302);
assert.equal(response.headers.get("location"), "/login");
}
},
{
name: "login document renders static auth markup without bootstrapping blazor",
run: async () => {
const response = await request("/login");
assert.equal(response.status, 200);
const html = await response.text();
assert.ok(!html.includes("Connecting..."));
assert.ok(html.includes("Register or log in to join a campaign session."));
assert.ok(!html.includes("_framework/blazor.web.js"));
assert.ok(!html.includes("<!--Blazor:"));
assert.ok(html.includes("data-auth-page"));
}
},
{
name: "authenticated root document redirects to play",
run: async () => {
const { sessionCookie } = await ensureAdminSession();
const response = await request("/", {
cookie: sessionCookie,
redirect: "manual"
});
assert.equal(response.status, 302);
assert.equal(response.headers.get("location"), "/play");
}
},
{
name: "authenticated route navigation and refresh use real URLs",
run: async () => withDriver({}, async (driver) => {
const username = uniqueName("routes");
const { sessionCookie } = await registerAndLoginApi(username, "Route Navigation");
const campaign = await postJson("/api/campaigns", {
name: "Route Navigation",
rulesetId: "d6"
}, { cookie: sessionCookie });
await postJson("/api/characters", {
name: "Navigator",
campaignId: campaign.id
}, { cookie: sessionCookie });
await seedAuthenticatedBrowser(driver, sessionCookie);
await driver.get(absoluteUrl("/campaigns"));
await waitForUrl(driver, "/campaigns");
await waitForSelector(driver, "#campaign-select");
assert.equal(await hasSelector(driver, "#skill-filter-input"), false);
await driver.navigate().refresh();
await waitForUrl(driver, "/campaigns");
await waitForSelector(driver, "#campaign-select");
await clickSelector(driver, ".menu-toggle");
await clickText(driver, ".menu-item", "Play");
await waitForUrl(driver, "/play");
await waitForSelector(driver, "#skill-filter-input");
await driver.navigate().refresh();
await waitForUrl(driver, "/play");
await waitForSelector(driver, "#skill-filter-input");
await clickSelector(driver, ".menu-toggle");
await clickText(driver, ".menu-item", "Campaign Management");
await waitForUrl(driver, "/campaigns");
await waitForSelector(driver, "#campaign-select");
})
},
{
name: "non-admin users are redirected away from admin route",
run: async () => withDriver({}, async (driver) => {
const username = uniqueName("admin-redirect");
const { sessionCookie } = await registerAndLoginApi(username, "Admin Redirect");
await seedAuthenticatedBrowser(driver, sessionCookie);
await driver.get(absoluteUrl("/admin"));
await waitForUrl(driver, "/play");
await waitForText(driver, "Campaign Log");
assert.equal(await hasSelector(driver, ".management-list"), false);
})
},
{
name: "admin route mounts directly without play UI",
run: async () => withDriver({}, async (driver) => {
const { sessionCookie } = await ensureAdminSession();
await seedAuthenticatedBrowser(driver, sessionCookie);
await driver.get(absoluteUrl("/admin"));
await waitForUrl(driver, "/admin");
await waitForText(driver, "User Management");
assert.equal(await hasSelector(driver, ".management-list"), true);
assert.equal(await hasSelector(driver, "#skill-filter-input"), false);
assert.equal(await hasSelector(driver, ".log-panel"), false);
})
},
{
name: "successful login transitions to play workspace",
run: async () => withDriver({}, async (driver) => {
const username = uniqueName("login");
await postJson("/api/auth/register", {
username,
password: "Password123",
displayName: "Login Flow"
});
await driver.get(absoluteUrl("/login"));
await fillInput(driver, "#login-username", username);
await fillInput(driver, "#login-password", "Password123");
await clickText(driver, "button", "Login");
await waitForUrl(driver, "/play");
await waitForText(driver, "Campaign Log");
assert.equal(await selectorCount(driver, "#login-username"), 0);
})
},
{
name: "workspace stays usable when input controls are DOM-wrapped during mount",
run: async () => withDriver({ addonPath: domWrapAddonPath }, async (driver) => {
const username = uniqueName("wrapped");
const { sessionCookie } = await registerAndLoginApi(username, "Wrapped Inputs");
const campaign = await postJson("/api/campaigns", {
name: "Wrapped Inputs",
rulesetId: "d6"
}, { cookie: sessionCookie });
const character = await postJson("/api/characters", {
name: "Wrapper Hero",
campaignId: campaign.id
}, { cookie: sessionCookie });
await postJson(`/api/characters/${character.id}/skills`, {
name: "Stealth",
diceRollDefinition: "2D+1",
wildDice: 1,
allowFumble: true
}, { cookie: sessionCookie });
await openAuthenticatedPlay(driver, sessionCookie);
await waitForSelector(driver, "#skill-filter-input");
await waitForSelector(driver, "#custom-roll-expression");
await waitFor(
driver,
() => driver.executeScript(() => [...document.querySelectorAll("button")].some((button) => button.textContent.includes("Roll Stealth"))),
"Expected Roll Stealth button."
);
assert.deepEqual(await getDomWrapErrors(driver), []);
})
},
{
name: "Rolemaster open-ended roll detail renders specialized dice chips",
run: async () => withDriver({}, async (driver) => {
const username = uniqueName("rm");
const { sessionCookie } = await registerAndLoginApi(username, "Rolemaster Smoke");
const campaign = await postJson("/api/campaigns", {
name: "Rolemaster Smoke",
rulesetId: "rolemaster"
}, { cookie: sessionCookie });
const character = await postJson("/api/characters", {
name: "Open Ender",
campaignId: campaign.id
}, { cookie: sessionCookie });
const skill = await postJson(`/api/characters/${character.id}/skills`, {
name: "Open Sight",
diceRollDefinition: "d100!+85",
wildDice: 0,
allowFumble: false,
fumbleRange: 95
}, { cookie: sessionCookie });
let qualifyingRoll = null;
for (let attempt = 0; attempt < 12; attempt += 1) {
const roll = await postJson(`/api/skills/${skill.id}/roll`, { visibility: "public" }, { cookie: sessionCookie });
if (roll.dice.some((die) => die.kind === "rolemaster-open-ended-high" || die.kind === "rolemaster-open-ended-low-subtract")) {
qualifyingRoll = roll;
break;
}
}
assert.notEqual(qualifyingRoll, null, "Expected an open-ended Rolemaster roll within 12 attempts.");
await openAuthenticatedPlay(driver, sessionCookie);
await waitForSelector(driver, ".log-panel .log-entry");
await clickSelector(driver, ".log-panel .log-entry-toggle");
await waitForSelector(driver, ".die-chip.rolemaster-open-ended-high, .die-chip.rolemaster-open-ended-low-subtract");
assert.equal(await hasSelector(driver, ".log-detail .roll-dice-strip"), true);
})
},
{
name: "Rolemaster automatic retry badge shows before detail expands",
run: async () => withDriver({}, async (driver) => {
const username = uniqueName("rm-retry");
const { sessionCookie } = await registerAndLoginApi(username, "Rolemaster Retry Smoke");
const campaign = await postJson("/api/campaigns", {
name: "Rolemaster Retry Smoke",
rulesetId: "rolemaster"
}, { cookie: sessionCookie });
const character = await postJson("/api/characters", {
name: "Retry Hero",
campaignId: campaign.id
}, { cookie: sessionCookie });
const skill = await postJson(`/api/characters/${character.id}/skills`, {
name: "Retry Sight",
diceRollDefinition: "d100!+10",
wildDice: 0,
allowFumble: false,
fumbleRange: 5,
rolemasterAutoRetry: true
}, { cookie: sessionCookie });
let retriedRoll = null;
for (let attempt = 0; attempt < 10; attempt += 1) {
const roll = await postJson(`/api/skills/${skill.id}/roll`, { visibility: "public" }, { cookie: sessionCookie });
if (roll.breakdown.includes("retry(+")) {
retriedRoll = roll;
break;
}
}
assert.notEqual(retriedRoll, null, "Expected a retry-enabled Rolemaster roll within 10 attempts.");
await openAuthenticatedPlay(driver, sessionCookie);
await waitFor(
driver,
() => driver.executeScript(() => [...document.querySelectorAll(".log-panel .log-entry")].some((entry) => entry.textContent.includes("retry +"))),
"Expected retry roll entry."
);
const collapsedState = await driver.executeScript(() => {
const entries = [...document.querySelectorAll(".log-panel .log-entry")].filter((entry) => {
const text = entry.textContent || "";
return text.includes("Retry Sight") && text.includes("retry +");
});
const retryEntry = entries.at(-1);
if (!retryEntry) {
return null;
}
return {
badgeTexts: [...retryEntry.querySelectorAll(".log-event-badge")].map((element) => element.textContent || ""),
summaryText: retryEntry.querySelector(".log-summary-text")?.textContent || "",
detailCount: retryEntry.querySelectorAll(".log-detail").length
};
});
assert.ok(collapsedState);
assert.ok(collapsedState.badgeTexts.some((badgeText) => /Retry \+(5|10)/.test(badgeText)));
assert.match(collapsedState.summaryText, /retry \+(5|10)/i);
assert.equal(collapsedState.detailCount, 0);
await clickText(driver, ".log-panel .log-entry-toggle", "Details", { contains: true, last: true }).catch(async () => {
const toggled = await driver.executeScript(() => {
const entries = [...document.querySelectorAll(".log-panel .log-entry")].filter((entry) => {
const text = entry.textContent || "";
return text.includes("Retry Sight") && text.includes("retry +");
});
const retryEntry = entries.at(-1);
const toggle = retryEntry?.querySelector(".log-entry-toggle");
if (!toggle) {
return false;
}
toggle.click();
return true;
});
assert.ok(toggled, "Could not expand retry entry.");
});
await waitFor(
driver,
() => driver.executeScript(() => {
const entries = [...document.querySelectorAll(".log-panel .log-entry")].filter((entry) => {
const text = entry.textContent || "";
return text.includes("Retry Sight") && text.includes("retry +");
});
return (entries.at(-1)?.querySelectorAll(".log-detail .die-chip").length || 0) === 2;
}),
"Expected two retry detail dice chips."
);
const detailState = await driver.executeScript(() => {
const entries = [...document.querySelectorAll(".log-panel .log-entry")].filter((entry) => {
const text = entry.textContent || "";
return text.includes("Retry Sight") && text.includes("retry +");
});
const retryEntry = entries.at(-1);
const chips = [...(retryEntry?.querySelectorAll(".log-detail .die-chip") || [])];
return chips.map((chip) => chip.getAttribute("title") || "");
});
assert.equal(detailState.length, 2);
assert.match(detailState[0], /attempt 1/i);
assert.match(detailState[1], /retry attempt 2/i);
})
},
{
name: "Rolemaster skill roll modal autofocuses, validates, and closes on escape or backdrop",
run: async () => withDriver({}, async (driver) => {
const username = uniqueName("rm-modal");
const { sessionCookie } = await registerAndLoginApi(username, "Rolemaster Modal Smoke");
const campaign = await postJson("/api/campaigns", {
name: "Rolemaster Modal Smoke",
rulesetId: "rolemaster"
}, { cookie: sessionCookie });
const character = await postJson("/api/characters", {
name: "Observer",
campaignId: campaign.id
}, { cookie: sessionCookie });
await postJson(`/api/characters/${character.id}/skills`, {
name: "Observation",
diceRollDefinition: "d100!+50",
wildDice: 0,
allowFumble: false,
fumbleRange: 5,
rolemasterAutoRetry: true
}, { cookie: sessionCookie });
await openAuthenticatedPlay(driver, sessionCookie);
await waitFor(
driver,
() => driver.executeScript(() => [...document.querySelectorAll("button")].some((button) => button.textContent.includes("Roll Observation"))),
"Expected Roll Observation button."
);
await clickText(driver, "button", "Roll Observation", { contains: true });
await waitForSelector(driver, ".rolemaster-roll-modal");
await waitFor(
driver,
() => driver.executeScript(() => document.activeElement?.id === "rolemaster-situational-modifier"),
"Expected modifier input to be focused."
);
await (await waitForSelector(driver, "#rolemaster-situational-modifier")).sendKeys(Key.ESCAPE);
await waitForAbsent(driver, ".rolemaster-roll-modal");
await clickText(driver, "button", "Roll Observation", { contains: true });
await waitForSelector(driver, ".rolemaster-roll-modal");
await driver.executeScript(() => {
document.querySelector(".modal-overlay")?.click();
});
await waitForAbsent(driver, ".rolemaster-roll-modal");
await clickText(driver, "button", "Roll Observation", { contains: true });
await waitForSelector(driver, ".rolemaster-roll-modal");
await fillInput(driver, "#rolemaster-situational-modifier", "1001");
await clickText(driver, ".rolemaster-roll-modal button", "Roll");
await waitForText(driver, "Enter a whole number between -1000 and 1000.");
assert.equal(await hasSelector(driver, ".rolemaster-roll-modal"), true);
await fillInput(driver, "#rolemaster-situational-modifier", "");
await (await waitForSelector(driver, "#rolemaster-situational-modifier")).sendKeys(Key.ENTER);
await waitForAbsent(driver, ".rolemaster-roll-modal");
await waitFor(
driver,
() => driver.executeScript(() => document.querySelector(".log-panel .log-entry.expanded")?.textContent.includes("Observation") || false),
"Expected expanded Observation log entry."
);
})
},
{
name: "newly rolled log entry auto-expands",
run: async () => withDriver({}, async (driver) => {
const username = uniqueName("d6-log");
const { sessionCookie } = await registerAndLoginApi(username, "D6 Auto Expand");
const campaign = await postJson("/api/campaigns", {
name: "D6 Auto Expand",
rulesetId: "d6"
}, { cookie: sessionCookie });
const character = await postJson("/api/characters", {
name: "Auto Hero",
campaignId: campaign.id
}, { cookie: sessionCookie });
await postJson(`/api/characters/${character.id}/skills`, {
name: "Stealth",
diceRollDefinition: "2D+1",
wildDice: 1,
allowFumble: true
}, { cookie: sessionCookie });
await openAuthenticatedPlay(driver, sessionCookie);
await waitFor(
driver,
() => driver.executeScript(() => [...document.querySelectorAll("button")].some((button) => button.textContent.includes("Roll Stealth"))),
"Expected Roll Stealth button."
);
await clickText(driver, "button", "Roll Stealth", { contains: true });
await waitForSelector(driver, ".log-panel .log-entry.expanded");
assert.equal(await hasSelector(driver, ".log-panel .log-entry.expanded .roll-dice-strip"), true);
})
},
{
name: "custom roll composer keeps parse errors inline and records successful rolls",
run: async () => withDriver({}, async (driver) => {
const username = uniqueName("custom-roll");
const { sessionCookie } = await registerAndLoginApi(username, "Custom Roller");
const campaign = await postJson("/api/campaigns", {
name: "Custom Roll Campaign",
rulesetId: "dnd5e"
}, { cookie: sessionCookie });
await postJson("/api/characters", {
name: "Improviser",
campaignId: campaign.id
}, { cookie: sessionCookie });
await openAuthenticatedPlay(driver, sessionCookie);
await waitFor(
driver,
() => driver.executeScript(() => {
const input = document.querySelector("#custom-roll-expression");
const button = document.querySelector(".custom-roll-composer button");
return Boolean(input && button && !input.disabled && !button.disabled);
}),
"Expected custom roll composer to be interactive."
);
assert.match(await elementText(driver, ".custom-roll-composer-head .muted"), /uses public visibility/i);
await fillInput(driver, "#roll-visibility", "private");
await waitFor(
driver,
() => elementText(driver, ".custom-roll-composer-head .muted").then((text) => /uses private visibility/i.test(text)),
"Expected custom roll status text to reflect private visibility."
);
await fillInput(driver, "#custom-roll-expression", "bad");
await clickText(driver, ".custom-roll-composer button", "Roll");
await waitFor(
driver,
() => driver.executeScript(() => {
const input = document.querySelector("#custom-roll-expression");
return Boolean(input && /error/.test(input.className));
}),
"Expected custom roll input to show an inline validation error."
);
assert.match(await getClassName(driver, "#custom-roll-expression"), /error/);
assert.match(await getAttribute(driver, "#custom-roll-expression", "title"), /Expected dnd5e format like 2d12\+2\./);
assert.equal(await selectorCount(driver, ".toast.error"), 0);
await fillInput(driver, "#custom-roll-expression", "1d20+5");
await clickText(driver, ".custom-roll-composer button", "Roll");
await waitFor(
driver,
() => driver.executeScript(() => {
const className = document.querySelector("#custom-roll-expression")?.className || "";
const firstLogEntry = document.querySelector(".log-panel .log-entry");
return !/error/.test(className) &&
Boolean(firstLogEntry?.textContent.includes("Custom roll")) &&
Boolean(firstLogEntry?.textContent.includes("Private"));
}),
"Expected successful custom roll entry."
);
})
},
{
name: "Rolemaster UI exposes conditional create and edit fields",
run: async () => withDriver({}, async (driver) => {
const username = uniqueName("rm-ui");
const { sessionCookie } = await registerAndLoginApi(username, "Rolemaster UI");
const campaign = await postJson("/api/campaigns", {
name: "Rolemaster UI Campaign",
rulesetId: "rolemaster"
}, { cookie: sessionCookie });
const character = await postJson("/api/characters", {
name: "UI Character",
campaignId: campaign.id
}, { cookie: sessionCookie });
await postJson(`/api/characters/${character.id}/skill-groups`, {
name: "Awareness",
diceRollDefinition: "d100!+15",
wildDice: 0,
allowFumble: false,
fumbleRange: 5
}, { cookie: sessionCookie });
await postJson(`/api/characters/${character.id}/skills`, {
name: "Perception",
diceRollDefinition: "d100!+25",
wildDice: 0,
allowFumble: false,
fumbleRange: 5,
rolemasterAutoRetry: true
}, { cookie: sessionCookie });
await openAuthenticatedPlay(driver, sessionCookie);
await waitForSelector(driver, "#workspace-screen-menu-button");
await clickSelector(driver, "#workspace-screen-menu-button");
await clickText(driver, ".screen-menu .menu-item", "Campaign Management");
await waitFor(
driver,
() => driver.executeScript(() => [...document.querySelectorAll("button")].some((button) => button.textContent.includes("Add campaign"))),
"Expected Campaign Management controls."
);
await clickText(driver, "button", "Add campaign", { contains: true });
await waitForSelector(driver, "#campaign-ruleset");
assert.equal(await elementText(driver, "#campaign-ruleset option[value='rolemaster']"), "Rolemaster");
await clickText(driver, "button", "Cancel");
await clickSelector(driver, "#workspace-screen-menu-button");
await clickText(driver, ".screen-menu .menu-item", "Play");
await waitFor(
driver,
() => driver.executeScript(() => [...document.querySelectorAll("button")].some((button) => button.textContent.includes("Add group"))),
"Expected Play controls after returning from Campaign Management."
);
await clickText(driver, "button", "Add group", { contains: true });
await waitForSelector(driver, "#skill-group-expression");
assert.equal(await selectorCount(driver, "#skill-group-wild-dice"), 0);
assert.equal(await getValue(driver, "#skill-group-expression"), "d100");
await fillInput(driver, "#skill-group-expression", "d100!+15");
await waitForSelector(driver, "#skill-group-fumble-range");
await fillInput(driver, "#skill-group-fumble-range", "");
await clickText(driver, "button", "Create Group");
await waitForText(driver, "Open-ended Rolemaster groups require a fumble range.");
await clickText(driver, "button", "Cancel");
await clickText(driver, "button", "Add skill", { contains: true });
await waitForSelector(driver, "#skill-create-expression");
assert.equal(await getValue(driver, "#skill-create-expression"), "d100!+15");
await fillInput(driver, "#skill-create-expression", "15d10");
await waitFor(
driver,
() => selectorCount(driver, "#skill-create-fumble-range").then((count) => count === 0),
"Expected no create fumble range for non-open-ended expression."
);
await waitFor(
driver,
() => selectorCount(driver, "#skill-auto-retry").then((count) => count === 0),
"Expected no auto retry checkbox for non-open-ended expression."
);
await fillInput(driver, "#skill-create-expression", "d100!+25");
await waitForSelector(driver, "#skill-create-fumble-range");
await waitForSelector(driver, "#skill-auto-retry");
await clickLabel(driver, "Automatic retry");
await fillInput(driver, "#skill-create-expression", "d10");
await waitFor(
driver,
() => selectorCount(driver, "#skill-auto-retry").then((count) => count === 0),
"Expected create auto retry checkbox to disappear."
);
await fillInput(driver, "#skill-create-expression", "d100!+25");
await waitForSelector(driver, "#skill-auto-retry");
assert.equal(await isChecked(driver, "#skill-auto-retry"), false);
await clickText(driver, "button", "Cancel");
await clickByTitle(driver, "Edit skill");
await waitForSelector(driver, "#skill-edit-expression");
assert.equal(await getValue(driver, "#skill-edit-expression"), "d100!+25");
assert.equal(await getValue(driver, "#skill-edit-fumble-range"), "5");
assert.equal(await isChecked(driver, "#skill-auto-retry"), true);
await fillInput(driver, "#skill-edit-expression", "d10");
await waitFor(
driver,
() => selectorCount(driver, "#skill-edit-fumble-range").then((count) => count === 0),
"Expected edit fumble range to disappear."
);
await waitFor(
driver,
() => selectorCount(driver, "#skill-auto-retry").then((count) => count === 0),
"Expected edit auto retry checkbox to disappear."
);
await fillInput(driver, "#skill-edit-expression", "d100!+25");
await waitForSelector(driver, "#skill-auto-retry");
assert.equal(await isChecked(driver, "#skill-auto-retry"), false);
await clickText(driver, "button", "Cancel");
})
}
];
runSmokeTests(tests).catch((error) => {
console.error(error.stack || error);
process.exitCode = 1;
});

View File

@@ -1,331 +0,0 @@
const { test, expect } = require("@playwright/test");
async function postJson(request, url, data) {
const response = await request.post(url, { data });
expect(response.ok()).toBeTruthy();
return await response.json();
}
async function registerAndLogin(request, username, displayName) {
await postJson(request, "/api/auth/register", {
username,
password: "Password123",
displayName
});
const loginResponse = await request.post("/api/auth/login", {
data: {
username,
password: "Password123"
}
});
expect(loginResponse.ok()).toBeTruthy();
}
test("home page loads auth entry points", async ({ page }) => {
await page.goto("/");
await expect(page.locator("h1")).toContainText("RpgRoller");
await expect(page.getByRole("heading", { name: "Register" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Login" })).toBeVisible();
await expect(page.getByLabel("Username").first()).toBeVisible();
await expect(page.getByLabel("Password").nth(1)).toBeVisible();
});
test("successful login transitions to play workspace", async ({ page, context }) => {
const username = `login-${Date.now()}`;
const password = "Password123";
await postJson(context.request, "/api/auth/register", {
username,
password,
displayName: "Login Flow"
});
await page.goto("/");
await page.locator("#login-username").fill(username);
await page.locator("#login-password").fill(password);
await page.getByRole("button", { name: "Login" }).click();
await expect(page.getByText("Campaign Log")).toBeVisible();
await expect(page.locator("#login-username")).toHaveCount(0);
});
test("Rolemaster open-ended roll detail renders specialized dice chips", async ({ page, context }) => {
const username = `rm-${Date.now()}`;
const displayName = "Rolemaster Smoke";
await registerAndLogin(context.request, username, displayName);
const campaign = await postJson(context.request, "/api/campaigns", {
name: "Rolemaster Smoke",
rulesetId: "rolemaster"
});
const character = await postJson(context.request, "/api/characters", {
name: "Open Ender",
campaignId: campaign.id
});
const skill = await postJson(context.request, `/api/characters/${character.id}/skills`, {
name: "Open Sight",
diceRollDefinition: "d100!+85",
wildDice: 0,
allowFumble: false,
fumbleRange: 95
});
await postJson(context.request, `/api/skills/${skill.id}/roll`, { visibility: "public" });
await page.goto("/");
await expect(page.getByText("Campaign Log")).toBeVisible();
await expect(page.locator(".log-panel .log-entry").first()).toBeVisible();
await expect(page.locator(".log-panel .log-event-badge")).toContainText(["Fumble"]);
const logEntry = page.locator(".log-panel .log-entry-toggle").first();
await expect(logEntry).toBeVisible();
await logEntry.click();
const rolemasterFollowUpDice = page.locator(".die-chip.rolemaster-open-ended-high, .die-chip.rolemaster-open-ended-low-subtract");
await expect(rolemasterFollowUpDice.first()).toBeVisible();
await expect(page.locator(".log-detail .roll-dice-strip")).toBeVisible();
});
test("Rolemaster automatic retry badge shows before detail expands", async ({ page, context }) => {
const username = `rm-retry-${Date.now()}`;
await registerAndLogin(context.request, username, "Rolemaster Retry Smoke");
const campaign = await postJson(context.request, "/api/campaigns", {
name: "Rolemaster Retry Smoke",
rulesetId: "rolemaster"
});
const character = await postJson(context.request, "/api/characters", {
name: "Retry Hero",
campaignId: campaign.id
});
const skill = await postJson(context.request, `/api/characters/${character.id}/skills`, {
name: "Retry Sight",
diceRollDefinition: "d100!+10",
wildDice: 0,
allowFumble: false,
fumbleRange: 5,
rolemasterAutoRetry: true
});
let retriedRoll = null;
for (let attempt = 0; attempt < 10; attempt += 1) {
const roll = await postJson(context.request, `/api/skills/${skill.id}/roll`, { visibility: "public" });
if (roll.breakdown.includes("retry(+")) {
retriedRoll = roll;
break;
}
}
expect(retriedRoll, "expected a retry-enabled Rolemaster roll within 10 attempts").not.toBeNull();
await page.goto("/");
await expect(page.getByText("Campaign Log")).toBeVisible();
const retryEntry = page.locator(".log-panel .log-entry").filter({ hasText: "retry +" }).last();
await expect(retryEntry).toBeVisible();
await expect(retryEntry.locator(".log-event-badge")).toContainText([/Retry \+(5|10)/]);
await expect(retryEntry.locator(".log-summary-text")).toContainText(/retry \+(5|10)/);
await expect(retryEntry.locator(".log-detail")).toHaveCount(0);
await retryEntry.locator(".log-entry-toggle").click();
const detailDice = retryEntry.locator(".log-detail .die-chip");
await expect(detailDice).toHaveCount(2);
await expect(detailDice.nth(0)).toHaveAttribute("title", /attempt 1/i);
await expect(detailDice.nth(1)).toHaveAttribute("title", /retry attempt 2/i);
});
test("Rolemaster skill roll modal autofocuses, validates, and closes on escape or backdrop", async ({ page, context }) => {
const username = `rm-modal-${Date.now()}`;
await registerAndLogin(context.request, username, "Rolemaster Modal Smoke");
const campaign = await postJson(context.request, "/api/campaigns", {
name: "Rolemaster Modal Smoke",
rulesetId: "rolemaster"
});
const character = await postJson(context.request, "/api/characters", {
name: "Observer",
campaignId: campaign.id
});
await postJson(context.request, `/api/characters/${character.id}/skills`, {
name: "Observation",
diceRollDefinition: "d100!+50",
wildDice: 0,
allowFumble: false,
fumbleRange: 5,
rolemasterAutoRetry: true
});
await page.goto("/");
await expect(page.getByText("Campaign Log")).toBeVisible();
const rollButton = page.getByRole("button", { name: "Roll Observation" });
const modal = page.getByRole("dialog", { name: "Rolemaster situational modifier" });
const modifierInput = page.locator("#rolemaster-situational-modifier");
await rollButton.click();
await expect(modal).toBeVisible();
await expect(modifierInput).toBeFocused();
await page.keyboard.press("Escape");
await expect(modal).toHaveCount(0);
await rollButton.click();
await expect(modal).toBeVisible();
await page.locator(".modal-overlay").click({ position: { x: 8, y: 8 } });
await expect(modal).toHaveCount(0);
await rollButton.click();
await expect(modal).toBeVisible();
await modifierInput.fill("1001");
await modal.getByRole("button", { name: "Roll" }).click();
await expect(page.getByText("Enter a whole number between -1000 and 1000.")).toBeVisible();
await expect(modal).toBeVisible();
await modifierInput.fill("");
await page.keyboard.press("Enter");
await expect(modal).toHaveCount(0);
await expect(page.locator(".log-panel .log-entry.expanded").first()).toContainText("Observation");
});
test("newly rolled log entry auto-expands", async ({ page, context }) => {
const username = `d6-log-${Date.now()}`;
await registerAndLogin(context.request, username, "D6 Auto Expand");
const campaign = await postJson(context.request, "/api/campaigns", {
name: "D6 Auto Expand",
rulesetId: "d6"
});
const character = await postJson(context.request, "/api/characters", {
name: "Auto Hero",
campaignId: campaign.id
});
await postJson(context.request, `/api/characters/${character.id}/skills`, {
name: "Stealth",
diceRollDefinition: "2D+1",
wildDice: 1,
allowFumble: true
});
await page.goto("/");
await expect(page.getByText("Campaign Log")).toBeVisible();
await page.getByRole("button", { name: "Roll Stealth" }).click();
const expandedEntry = page.locator(".log-panel .log-entry.expanded").first();
await expect(expandedEntry).toBeVisible();
await expect(expandedEntry.locator(".log-detail .roll-dice-strip")).toBeVisible();
});
test("custom roll composer keeps parse errors inline and records successful rolls", async ({ page, context }) => {
const username = `custom-roll-${Date.now()}`;
await registerAndLogin(context.request, username, "Custom Roller");
const campaign = await postJson(context.request, "/api/campaigns", {
name: "Custom Roll Campaign",
rulesetId: "dnd5e"
});
await postJson(context.request, "/api/characters", {
name: "Improviser",
campaignId: campaign.id
});
await page.goto("/");
await expect(page.getByText("Campaign Log")).toBeVisible();
const composer = page.locator(".custom-roll-composer");
const input = page.locator("#custom-roll-expression");
await input.fill("bad");
await composer.getByRole("button", { name: "Roll" }).click();
await expect(input).toHaveClass(/error/);
await expect(input).toHaveAttribute("title", /Expected dnd5e format like 2d12\+2\./);
await expect(page.locator(".toast.error")).toHaveCount(0);
await input.fill("1d20+5");
await composer.getByRole("button", { name: "Roll" }).click();
await expect(input).not.toHaveClass(/error/);
await expect(page.locator(".log-panel .log-entry").first()).toContainText("Custom roll");
});
test("Rolemaster UI exposes conditional create and edit fields", async ({ page, context }) => {
const username = `rm-ui-${Date.now()}`;
await registerAndLogin(context.request, username, "Rolemaster UI");
const campaign = await postJson(context.request, "/api/campaigns", {
name: "Rolemaster UI Campaign",
rulesetId: "rolemaster"
});
const character = await postJson(context.request, "/api/characters", {
name: "UI Character",
campaignId: campaign.id
});
await postJson(context.request, `/api/characters/${character.id}/skill-groups`, {
name: "Awareness",
diceRollDefinition: "d100!+15",
wildDice: 0,
allowFumble: false,
fumbleRange: 5
});
await postJson(context.request, `/api/characters/${character.id}/skills`, {
name: "Perception",
diceRollDefinition: "d100!+25",
wildDice: 0,
allowFumble: false,
fumbleRange: 5,
rolemasterAutoRetry: true
});
await page.goto("/");
await expect(page.locator("#workspace-screen-menu-button")).toBeVisible();
await page.locator("#workspace-screen-menu-button").click();
await page.getByRole("menuitem", { name: "Campaign Management" }).click();
await page.getByRole("button", { name: "Add campaign" }).click();
await expect(page.locator("#campaign-ruleset option[value='rolemaster']")).toHaveText("Rolemaster");
await page.getByRole("button", { name: "Cancel" }).click();
await page.locator("#workspace-screen-menu-button").click();
await page.getByRole("menuitem", { name: "Play" }).click();
await page.getByRole("button", { name: "Add group" }).click();
await expect(page.locator("#skill-group-wild-dice")).toHaveCount(0);
await expect(page.locator("#skill-group-expression")).toHaveValue("d100");
await page.locator("#skill-group-expression").fill("d100!+15");
await expect(page.locator("#skill-group-fumble-range")).toBeVisible();
await page.locator("#skill-group-fumble-range").fill("");
await page.getByRole("button", { name: "Create Group" }).click();
await expect(page.getByText("Open-ended Rolemaster groups require a fumble range.")).toBeVisible();
await page.getByRole("button", { name: "Cancel" }).click();
await page.getByRole("button", { name: "Add skill" }).first().click();
await expect(page.locator("#skill-create-expression")).toHaveValue("d100!+15");
await page.locator("#skill-create-expression").fill("15d10");
await expect(page.locator("#skill-create-fumble-range")).toHaveCount(0);
await expect(page.getByLabel("Automatic retry")).toHaveCount(0);
await page.locator("#skill-create-expression").fill("d100!+25");
await expect(page.locator("#skill-create-fumble-range")).toBeVisible();
await expect(page.getByLabel("Automatic retry")).toBeVisible();
await page.getByLabel("Automatic retry").check();
await page.locator("#skill-create-expression").fill("d10");
await expect(page.getByLabel("Automatic retry")).toHaveCount(0);
await page.locator("#skill-create-expression").fill("d100!+25");
await expect(page.getByLabel("Automatic retry")).toBeVisible();
await expect(page.getByLabel("Automatic retry")).not.toBeChecked();
await page.getByRole("button", { name: "Cancel" }).click();
await page.locator("button[title='Edit skill']").first().click();
await expect(page.locator("#skill-edit-expression")).toHaveValue("d100!+25");
await expect(page.locator("#skill-edit-fumble-range")).toHaveValue("5");
await expect(page.getByLabel("Automatic retry")).toBeChecked();
await page.locator("#skill-edit-expression").fill("d10");
await expect(page.locator("#skill-edit-fumble-range")).toHaveCount(0);
await expect(page.getByLabel("Automatic retry")).toHaveCount(0);
await page.locator("#skill-edit-expression").fill("d100!+25");
await expect(page.getByLabel("Automatic retry")).toBeVisible();
await expect(page.getByLabel("Automatic retry")).not.toBeChecked();
await page.getByRole("button", { name: "Cancel" }).click();
});