14 Commits

Author SHA1 Message Date
2c99544baf Changed background images to webp 2026-05-28 19:32:42 +02:00
8e730af85d Fix Windows deploy remote script line endings 2026-05-18 21:13:50 +02:00
66607e51eb Light/Dark theming 2026-05-18 21:00:38 +02:00
ecc799ae7f Move campaign selector into header 2026-05-18 20:13:14 +02:00
ff28f70b51 Add Windows deploy script 2026-05-18 19:11:31 +02:00
20c8868744 remove stale script 2026-05-18 19:06:13 +02:00
b80e9f1aec updated docs 2026-05-18 19:05:39 +02:00
d74f8a65a9 removed stale scripts 2026-05-18 19:00:09 +02:00
c79bea86b6 removed stale doc 2026-05-18 18:59:23 +02:00
e7ae0e00c1 Background image 2026-05-05 02:35:05 +02:00
43bd68e707 Allow GM play roster access 2026-05-05 02:10:26 +02:00
e574b4a37b Cleared TASKS.md 2026-05-05 01:59:19 +02:00
b8bd92e3dc Fix proxied live updates 2026-05-05 01:55:59 +02:00
2be1fc599a Add Linux deploy script 2026-05-05 01:27:48 +02:00
53 changed files with 1658 additions and 1382 deletions

View File

@@ -8,25 +8,8 @@ These tools are installed and available: Python3, geckodriver, Selenium
## Rules ## Rules
- Prefer extracting code to a shared helper to be reused instead of duplicating code. Always keep high maintainability standards. - After every iteration, run `jb cleanupcode --build=False '$file1' '$file2' ...` for every C# file you touched.
- Always place each newly created class into its own file. The file name must match the class name.
- When asked to begin working on a task, create a detailed implementation plan first, present the plan to the user, and ask for approval before beginning with the actual implementation.
- Don't make assumptions in the plan. If necessary, ask all clarifying questions before presenting the final plan.
- When an task is finished, perform a code review to evaluate if the change is clean and maintainable with high software engineering standards. Iterate on the code and repeat the review process until satisfied.
- 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 a geckodriver+Selenium run. - 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.
- Keep changes small with minimal churn and commit often. If one iteration encompasses many smaller tasks with more than one commit, create a git branch and do the commits there. Let me review the branch before merging it back to master.
- When multiple commits are necessary, pause after every commit and ask the user to give a command to proceed.
- After every iteration, do a git commit with a brief summary of the changes as a commit message.
- If you find unexpected changes in the code (deletions, changes, diff results that were not communicated), never revert them and never restore the old state. Assume that those changes happened with intent.
- Never use `git restore`, `git checkout --`, reset commands, or equivalent rollback actions to discard local changes unless the user explicitly asks for that exact rollback.
### Dotnet CLI ### Dotnet CLI

View File

@@ -1 +1,26 @@
This is a linux environment, read `AGENTS.linux.md`. # Agent Guide
Detect which operating system you're currently running on.
If this is a linux environment, read `AGENTS.linux.md`.
If this is a windows environment, read `AGENTS.windows.md`.
## Rules
- Prefer extracting code to a shared helper to be reused instead of duplicating code. Always keep high maintainability standards.
- Keep changes as small as possible, design solutions that achieve the goals with minimal churn.
- Always place each newly created class into its own file. The file name must match the class name.
- When asked to begin wor~~~~king on a task, create a detailed implementation plan first, present the plan to the user, and ask for approval before beginning with the actual implementation.
- Don't make assumptions in the plan. If necessary, ask all clarifying questions before presenting the final plan.
- When an task is finished, perform a code review to evaluate if the change is clean and maintainable with high software engineering standards. Iterate on the code and repeat the review process until satisfied.
- 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.
- 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.
- Keep changes small and commit often. If one iteration encompasses many smaller tasks with more than one commit, create a git branch and do the commits there. Let me review the branch before merging it back to master.
- When multiple commits are necessary, pause after every commit and ask the user to give a command to proceed.
- After every iteration, do a git commit with a brief summary of the changes as a commit message.
- If you find unexpected changes in the code (deletions, changes, diff results that were not communicated), never revert them and never restore the old state. Assume that those changes happened with intent.
- Never use `git restore`, `git checkout --`, reset commands, or equivalent rollback actions to discard local changes unless the user explicitly asks for that exact rollback.

View File

@@ -12,30 +12,14 @@ These tool paths should be used instead of any entry in the PATH environment var
## Rules ## Rules
- Prefer extracting code to a shared helper to be reused instead of duplicating code. Always keep high maintainability standards. - After every iteration, run `dotnet jb cleanupcode --build=False '$file1' '$file2' ...` for every C# file you touched.
- Keep changes as small as possible, design solutions that achieve the goals with minimal churn.
- Always place each newly created class into its own file. The file name must match the class name.
- When asked to begin working on a task, create a detailed implementation plan first, present the plan to the user, and ask for approval before beginning with the actual implementation.
- Don't make assumptions in the plan. If necessary, ask all clarifying questions before presenting the final plan.
- When an task is finished, perform a code review to evaluate if the change is clean and maintainable with high software engineering standards. Iterate on the code and repeat the review process until satisfied.
- After the implementation is finished, verify all changed files, and run `python D:\Code\crlf.py $file1 $file2 ...` only for files you recognize, in order to normalize all line endings of all touched files to CRLF. - After the implementation is finished, verify all changed files, and run `python D:\Code\crlf.py $file1 $file2 ...` only for files you recognize, in order to normalize all line endings of all touched files to CRLF.
- 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 `jb cleanupcode --build=False '$file1' '$file2' ...` for every C# file you touched.
- After every frontend change, verify the results using an ephemeral Playwright run. - 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. - For ad hoc verification in this repo, do not default to `npx playwright test` with a temp spec outside the repo.
- Prefer a repo-local ephemeral Node script under `artifacts_verify/` that imports `playwright` with `require('playwright')` and drives the browser directly. - Prefer a repo-local ephemeral Node script under `artifacts_verify/` that imports `playwright` with `require('playwright')` and drives the browser directly.
- If using the Playwright test runner, use the repo-local CLI at `node_modules\.bin\playwright.cmd` and keep the spec inside the repo so local `node_modules` resolution works. - If using the Playwright test runner, use the repo-local CLI at `node_modules\.bin\playwright.cmd` and keep the spec inside the repo so local `node_modules` resolution works.
- Do not mix the global Playwright CLI with the repo-local `@playwright/test` package. - Do not mix the global Playwright CLI with the repo-local `@playwright/test` package.
- 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. - 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.
- Keep changes small and commit often. If one iteration encompasses many smaller tasks with more than one commit, create a git branch and do the commits there. Let me review the branch before merging it back to master.
- When multiple commits are necessary, pause after every commit and ask the user to give a command to proceed.
- After every iteration, do a git commit with a brief summary of the changes as a commit message.
- If you find unexpected changes in the code (deletions, changes, diff results that were not communicated), never revert them and never restore the old state. Assume that those changes happened with intent.
- Never use `git restore`, `git checkout --`, reset commands, or equivalent rollback actions to discard local changes unless the user explicitly asks for that exact rollback.
### PowerShell ### PowerShell

View File

@@ -1,419 +0,0 @@
# 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.

View File

@@ -1,4 +1,4 @@
# RpgRoller # RpgRoller
RpgRoller is an ASP.NET Core and 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.
@@ -54,7 +54,8 @@ Frontend:
- `RpgRoller/Components/RpgRollerApiClient.cs`: browser API client for write actions - `RpgRoller/Components/RpgRollerApiClient.cs`: browser API client for write actions
- `RpgRoller/Components/WorkspaceQueryService.cs`: browser-facing read client for workspace data - `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/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 - `RpgRoller/wwwroot/styles.css`: app styling, light and dark theme variables, and responsive layout
- `RpgRoller/wwwroot/images/light.webp` and `RpgRoller/wwwroot/images/dark.webp`: themed workspace background art
Current repo note: Current repo note:
@@ -75,10 +76,11 @@ Current repo note:
- Supported campaign rulesets: D6 System, D&D 5e, and Rolemaster - Supported campaign rulesets: D6 System, D&D 5e, and Rolemaster
- Account registration, login, session-based auth, and role-aware authorization - Account registration, login, session-based auth, and role-aware authorization
- Admin tools for user listing, role updates, account deletion, and direct SQLite database download - Admin tools for user listing, role updates, account deletion, and direct SQLite database download
- Per-user light and dark theme preference with OS-based initial selection
- Campaign creation, roster reads, participant-scoped visibility, and owner and 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 - 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 - Skill groups with reusable defaults plus skill and skill-group create, edit, reassign, and delete flows
- Owner-scoped play workspace that lists only the current user's characters while preserving GM and admin management capabilities - Play workspace that lists the current user's characters, or the full active campaign roster when the user is that campaign's GM
- Campaign log paging, lazy-loaded roll detail, compact summaries, and live state refresh through SSE - 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 - Custom roll submission from the play screen without creating a persisted skill
- Instant skill filtering in the character panel - Instant skill filtering in the character panel
@@ -190,6 +192,65 @@ VS Code launch profiles in `.vscode/launch.json`:
- `RpgRoller: Server + Edge (F5)` - `RpgRoller: Server + Edge (F5)`
- `RpgRoller: Server + Firefox (F5)` - `RpgRoller: Server + Firefox (F5)`
## Deployment
Deploy to the Linux server with:
```bash
bash ./scripts/deploy.sh
```
The script publishes the app locally, uploads a release to `/root/docker/rpgroller/releases/<UTC timestamp>`, updates `/root/docker/rpgroller/current`, rebuilds the `rpgroller` image, and recreates the `rpgroller` container. The SQLite database is preserved because the container keeps using the existing bind mount at `/root/docker/rpgroller/data`.
Reverse proxy requirements for production:
- Use `rpgroller.franktovar.de` as the only canonical host.
- Forward `X-Forwarded-For` and `X-Forwarded-Proto` so ASP.NET Core can mark the session cookie as secure behind TLS termination.
- Proxy `/_blazor` with WebSocket upgrade headers.
- Proxy `/api/events/state` as Server-Sent Events with buffering disabled, for example:
```nginx
server {
server_name rpgroller.franktovar.de;
location /_blazor {
proxy_pass http://127.0.0.1:8082;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 300;
}
location /api/events/state {
proxy_pass http://127.0.0.1:8082;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_cache off;
gzip off;
proxy_read_timeout 3600;
add_header X-Accel-Buffering no;
}
location / {
proxy_pass http://127.0.0.1:8082;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300;
}
}
```
Environment overrides: Environment overrides:
- Set `ConnectionStrings__RpgRoller` to point at a custom SQLite database. - Set `ConnectionStrings__RpgRoller` to point at a custom SQLite database.

View File

@@ -1,4 +1,4 @@
namespace RpgRoller.Tests; namespace RpgRoller.Tests;
public sealed class AuthApiTests(WebApplicationFactory<Program> factory) : ApiTestBase(factory) public sealed class AuthApiTests(WebApplicationFactory<Program> factory) : ApiTestBase(factory)
{ {
@@ -20,13 +20,39 @@ public sealed class AuthApiTests(WebApplicationFactory<Program> factory) : ApiTe
var me = await GetAsync<MeResponse>(client, "/api/me"); var me = await GetAsync<MeResponse>(client, "/api/me");
Assert.Equal(registerResult.Id, me.User.Id); Assert.Equal(registerResult.Id, me.User.Id);
Assert.Null(me.User.ThemePreference);
Assert.Null(me.ActiveCharacterId); Assert.Null(me.ActiveCharacterId);
Assert.Null(me.CurrentCampaignId); Assert.Null(me.CurrentCampaignId);
var themeUser = await PutAsync<UpdateThemePreferenceRequest, UserSummary>(client, "/api/me/theme", new("dark"));
Assert.Equal("dark", themeUser.ThemePreference);
var themedMe = await GetAsync<MeResponse>(client, "/api/me");
Assert.Equal("dark", themedMe.User.ThemePreference);
var invalidLogin = await client.PostAsJsonAsync("/api/auth/login", new LoginRequest("alice", "wrong-password")); var invalidLogin = await client.PostAsJsonAsync("/api/auth/login", new LoginRequest("alice", "wrong-password"));
Assert.Equal(HttpStatusCode.BadRequest, invalidLogin.StatusCode); Assert.Equal(HttpStatusCode.BadRequest, invalidLogin.StatusCode);
} }
[Fact]
public async Task ThemePreferenceEndpoint_RequiresAuthAndValidTheme()
{
using var factory = CreateFactory();
using var client = factory.CreateClient(new() { AllowAutoRedirect = false });
var unauthorized = await client.PutAsJsonAsync("/api/me/theme", new UpdateThemePreferenceRequest("dark"));
Assert.Equal(HttpStatusCode.Unauthorized, unauthorized.StatusCode);
var unauthorizedInvalid = await client.PutAsJsonAsync("/api/me/theme", new UpdateThemePreferenceRequest("sepia"));
Assert.Equal(HttpStatusCode.Unauthorized, unauthorizedInvalid.StatusCode);
await RegisterAsync(client, "theme-api", "Password123", "Theme Api");
await LoginAsync(client, "theme-api", "Password123");
var invalid = await client.PutAsJsonAsync("/api/me/theme", new UpdateThemePreferenceRequest("sepia"));
Assert.Equal(HttpStatusCode.BadRequest, invalid.StatusCode);
}
[Fact] [Fact]
public async Task UsernamesEndpoint_RequiresAuthAndReturnsAlphabeticalList() public async Task UsernamesEndpoint_RequiresAuthAndReturnsAlphabeticalList()
{ {
@@ -44,4 +70,24 @@ public sealed class AuthApiTests(WebApplicationFactory<Program> factory) : ApiTe
var usernames = await GetAsync<IReadOnlyList<string>>(client, "/api/users/usernames"); var usernames = await GetAsync<IReadOnlyList<string>>(client, "/api/users/usernames");
Assert.Equal(["amy", "bob", "zoe"], usernames); Assert.Equal(["amy", "bob", "zoe"], usernames);
} }
[Fact]
public async Task LoginCookie_IsMarkedSecure_WhenForwardedProtoIsHttps()
{
using var factory = CreateFactory();
using var client = factory.CreateClient(new() { AllowAutoRedirect = false });
await RegisterAsync(client, "proxy-user", "Password123", "Proxy User");
using var request = new HttpRequestMessage(HttpMethod.Post, "/api/auth/login") { Content = JsonContent.Create(new LoginRequest("proxy-user", "Password123")) };
request.Headers.TryAddWithoutValidation("X-Forwarded-Proto", "https");
using var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.NotNull(response.Headers.SingleOrDefault(header => header.Key == "Set-Cookie").Value);
var setCookie = Assert.Single(response.Headers.GetValues("Set-Cookie"));
Assert.Contains("rpgroller_session=", setCookie);
Assert.Contains("secure", setCookie, StringComparison.OrdinalIgnoreCase);
}
} }

View File

@@ -13,26 +13,33 @@ public sealed class CampaignApiTests(WebApplicationFactory<Program> factory) : A
await RegisterAsync(gmClient, "gm", "Password123", "Game Master"); await RegisterAsync(gmClient, "gm", "Password123", "Game Master");
await LoginAsync(gmClient, "gm", "Password123"); await LoginAsync(gmClient, "gm", "Password123");
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns", new("Alpha Campaign", "dnd5e")); var campaign =
await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns",
new("Alpha Campaign", "dnd5e"));
var gmCharacter = await PostAsync<CreateCharacterRequest, CharacterSummary>(gmClient, "/api/characters", new("Arin", campaign.Id)); var gmCharacter =
await PostAsync<CreateCharacterRequest, CharacterSummary>(gmClient, "/api/characters",
new("Arin", campaign.Id));
Assert.Equal("Game Master", gmCharacter.OwnerDisplayName); Assert.Equal("Game Master", gmCharacter.OwnerDisplayName);
var activateResponse = await gmClient.PostAsync($"/api/characters/{gmCharacter.Id}/activate", null); var activateResponse = await gmClient.PostAsync($"/api/characters/{gmCharacter.Id}/activate", null);
Assert.Equal(HttpStatusCode.OK, activateResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, activateResponse.StatusCode);
var createdSkill = await PostAsync<CreateSkillRequest, SkillSummary>(gmClient, $"/api/characters/{gmCharacter.Id}/skills", new("Arcana", "2d12+2", 0, false)); var createdSkill = await PostAsync<CreateSkillRequest, SkillSummary>(gmClient,
$"/api/characters/{gmCharacter.Id}/skills", new("Arcana", "2d12+2", 0, false));
Assert.Equal("2d12+2", createdSkill.DiceRollDefinition); Assert.Equal("2d12+2", createdSkill.DiceRollDefinition);
Assert.Equal(0, createdSkill.WildDice); Assert.Equal(0, createdSkill.WildDice);
Assert.False(createdSkill.AllowFumble); Assert.False(createdSkill.AllowFumble);
var updatedSkill = await PutAsync<UpdateSkillRequest, SkillSummary>(gmClient, $"/api/skills/{createdSkill.Id}", new("Arcana Mastery", "2d12+3", 0, false)); var updatedSkill = await PutAsync<UpdateSkillRequest, SkillSummary>(gmClient, $"/api/skills/{createdSkill.Id}",
new("Arcana Mastery", "2d12+3", 0, false));
Assert.Equal("Arcana Mastery", updatedSkill.Name); Assert.Equal("Arcana Mastery", updatedSkill.Name);
Assert.Equal("2d12+3", updatedSkill.DiceRollDefinition); Assert.Equal("2d12+3", updatedSkill.DiceRollDefinition);
Assert.Equal(0, updatedSkill.WildDice); Assert.Equal(0, updatedSkill.WildDice);
Assert.False(updatedSkill.AllowFumble); Assert.False(updatedSkill.AllowFumble);
var invalidSkill = await gmClient.PostAsJsonAsync($"/api/characters/{gmCharacter.Id}/skills", new CreateSkillRequest("Broken", "5D+4", 0, false)); var invalidSkill = await gmClient.PostAsJsonAsync($"/api/characters/{gmCharacter.Id}/skills",
new CreateSkillRequest("Broken", "5D+4", 0, false));
Assert.Equal(HttpStatusCode.BadRequest, invalidSkill.StatusCode); Assert.Equal(HttpStatusCode.BadRequest, invalidSkill.StatusCode);
var details = await GetAsync<CampaignRoster>(gmClient, $"/api/campaigns/{campaign.Id}"); var details = await GetAsync<CampaignRoster>(gmClient, $"/api/campaigns/{campaign.Id}");
@@ -53,14 +60,49 @@ public sealed class CampaignApiTests(WebApplicationFactory<Program> factory) : A
Assert.Equal(gmCharacter.Id, currentCampaignCharacters[0].Id); Assert.Equal(gmCharacter.Id, currentCampaignCharacters[0].Id);
Assert.Equal("Game Master", currentCampaignCharacters[0].OwnerDisplayName); Assert.Equal("Game Master", currentCampaignCharacters[0].OwnerDisplayName);
var otherCampaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns", new("Beta Campaign", "d6")); var otherCampaign =
await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns",
new("Beta Campaign", "d6"));
var updatedCharacter = await PutAsync<UpdateCharacterRequest, CharacterSummary>(gmClient, $"/api/characters/{gmCharacter.Id}", new("Arin Updated", otherCampaign.Id)); var updatedCharacter = await PutAsync<UpdateCharacterRequest, CharacterSummary>(gmClient,
$"/api/characters/{gmCharacter.Id}", new("Arin Updated", otherCampaign.Id));
Assert.Equal("Arin Updated", updatedCharacter.Name); Assert.Equal("Arin Updated", updatedCharacter.Name);
Assert.Equal(otherCampaign.Id, updatedCharacter.CampaignId); Assert.Equal(otherCampaign.Id, updatedCharacter.CampaignId);
} }
[Fact]
public async Task GmCanActivateAnotherPlayersCharacter_AndMeReflectsCampaignContext()
{
using var factory = CreateFactory(3, 3, 3);
using var gmClient = factory.CreateClient(new() { AllowAutoRedirect = false });
using var playerClient = factory.CreateClient(new() { AllowAutoRedirect = false });
using var outsiderClient = factory.CreateClient(new() { AllowAutoRedirect = false });
await RegisterAsync(gmClient, "gm-activate", "Password123", "GM");
await RegisterAsync(playerClient, "player-activate", "Password123", "Player");
await RegisterAsync(outsiderClient, "outsider-activate", "Password123", "Outsider");
await LoginAsync(gmClient, "gm-activate", "Password123");
await LoginAsync(playerClient, "player-activate", "Password123");
await LoginAsync(outsiderClient, "outsider-activate", "Password123");
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns",
new("Activation Campaign", "d6"));
var playerCharacter = await PostAsync<CreateCharacterRequest, CharacterSummary>(playerClient, "/api/characters",
new("Scout", campaign.Id));
var gmActivate = await gmClient.PostAsync($"/api/characters/{playerCharacter.Id}/activate", null);
Assert.Equal(HttpStatusCode.OK, gmActivate.StatusCode);
var gmMe = await GetAsync<MeResponse>(gmClient, "/api/me");
Assert.Equal(playerCharacter.Id, gmMe.ActiveCharacterId);
Assert.Equal(campaign.Id, gmMe.CurrentCampaignId);
var outsiderActivate = await outsiderClient.PostAsync($"/api/characters/{playerCharacter.Id}/activate", null);
Assert.Equal(HttpStatusCode.BadRequest, outsiderActivate.StatusCode);
}
[Fact] [Fact]
public async Task CampaignCreation_AcceptsRolemasterRuleset() public async Task CampaignCreation_AcceptsRolemasterRuleset()
{ {
@@ -70,7 +112,9 @@ public sealed class CampaignApiTests(WebApplicationFactory<Program> factory) : A
await RegisterAsync(gmClient, "gm-rm-api", "Password123", "Game Master"); await RegisterAsync(gmClient, "gm-rm-api", "Password123", "Game Master");
await LoginAsync(gmClient, "gm-rm-api", "Password123"); await LoginAsync(gmClient, "gm-rm-api", "Password123");
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns", new("Shadow World", "rolemaster")); var campaign =
await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns",
new("Shadow World", "rolemaster"));
Assert.Equal("rolemaster", campaign.RulesetId); Assert.Equal("rolemaster", campaign.RulesetId);
} }
@@ -84,23 +128,32 @@ public sealed class CampaignApiTests(WebApplicationFactory<Program> factory) : A
await RegisterAsync(gmClient, "gm-rm-skill", "Password123", "Game Master"); await RegisterAsync(gmClient, "gm-rm-skill", "Password123", "Game Master");
await LoginAsync(gmClient, "gm-rm-skill", "Password123"); await LoginAsync(gmClient, "gm-rm-skill", "Password123");
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns", new("Shadow World", "rolemaster")); var campaign =
var character = await PostAsync<CreateCharacterRequest, CharacterSummary>(gmClient, "/api/characters", new("Kalen", campaign.Id)); await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns",
new("Shadow World", "rolemaster"));
var character =
await PostAsync<CreateCharacterRequest, CharacterSummary>(gmClient, "/api/characters",
new("Kalen", campaign.Id));
var missingFumbleRange = await gmClient.PostAsJsonAsync($"/api/characters/{character.Id}/skills", new CreateSkillRequest("Bad Open Ended", "d100!+35", 0, false)); var missingFumbleRange = await gmClient.PostAsJsonAsync($"/api/characters/{character.Id}/skills",
new CreateSkillRequest("Bad Open Ended", "d100!+35", 0, false));
Assert.Equal(HttpStatusCode.BadRequest, missingFumbleRange.StatusCode); Assert.Equal(HttpStatusCode.BadRequest, missingFumbleRange.StatusCode);
var group = await PostAsync<CreateSkillGroupRequest, SkillGroupSummary>(gmClient, $"/api/characters/{character.Id}/skill-groups", new("Perception", "d100!+15", 0, false, 5)); var group = await PostAsync<CreateSkillGroupRequest, SkillGroupSummary>(gmClient,
$"/api/characters/{character.Id}/skill-groups", new("Perception", "d100!+15", 0, false, 5));
Assert.Equal(5, group.FumbleRange); Assert.Equal(5, group.FumbleRange);
var invalidRetry = await gmClient.PostAsJsonAsync($"/api/characters/{character.Id}/skills", new CreateSkillRequest("Bad Retry", "d100+35", 0, false, group.Id, null, true)); var invalidRetry = await gmClient.PostAsJsonAsync($"/api/characters/{character.Id}/skills",
new CreateSkillRequest("Bad Retry", "d100+35", 0, false, group.Id, null, true));
Assert.Equal(HttpStatusCode.BadRequest, invalidRetry.StatusCode); Assert.Equal(HttpStatusCode.BadRequest, invalidRetry.StatusCode);
var skill = await PostAsync<CreateSkillRequest, SkillSummary>(gmClient, $"/api/characters/{character.Id}/skills", new("Awareness", "d100!+35", 0, false, group.Id, 3, true)); var skill = await PostAsync<CreateSkillRequest, SkillSummary>(gmClient,
$"/api/characters/{character.Id}/skills", new("Awareness", "d100!+35", 0, false, group.Id, 3, true));
Assert.Equal(3, skill.FumbleRange); Assert.Equal(3, skill.FumbleRange);
Assert.True(skill.RolemasterAutoRetry); Assert.True(skill.RolemasterAutoRetry);
var updatedSkill = await PutAsync<UpdateSkillRequest, SkillSummary>(gmClient, $"/api/skills/{skill.Id}", new("Awareness", "d100!+45", 0, false, group.Id, 4, true)); var updatedSkill = await PutAsync<UpdateSkillRequest, SkillSummary>(gmClient, $"/api/skills/{skill.Id}",
new("Awareness", "d100!+45", 0, false, group.Id, 4, true));
Assert.Equal(4, updatedSkill.FumbleRange); Assert.Equal(4, updatedSkill.FumbleRange);
Assert.True(updatedSkill.RolemasterAutoRetry); Assert.True(updatedSkill.RolemasterAutoRetry);
@@ -128,23 +181,31 @@ public sealed class CampaignApiTests(WebApplicationFactory<Program> factory) : A
await RegisterAsync(receiverClient, "receiver2", "Password123", "Receiver"); await RegisterAsync(receiverClient, "receiver2", "Password123", "Receiver");
await LoginAsync(receiverClient, "receiver2", "Password123"); await LoginAsync(receiverClient, "receiver2", "Password123");
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns", new("Grouped Campaign", "d6")); var campaign =
var character = await PostAsync<CreateCharacterRequest, CharacterSummary>(ownerClient, "/api/characters", new("Grouped Hero", campaign.Id)); await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns",
new("Grouped Campaign", "d6"));
var character = await PostAsync<CreateCharacterRequest, CharacterSummary>(ownerClient, "/api/characters",
new("Grouped Hero", campaign.Id));
var createdGroup = await PostAsync<CreateSkillGroupRequest, SkillGroupSummary>(ownerClient, $"/api/characters/{character.Id}/skill-groups", new("Combat", "2D+1", 1, true)); var createdGroup = await PostAsync<CreateSkillGroupRequest, SkillGroupSummary>(ownerClient,
var renamedGroup = await PutAsync<UpdateSkillGroupRequest, SkillGroupSummary>(gmClient, $"/api/skill-groups/{createdGroup.Id}", new("Battle", "3D+2", 2, false)); $"/api/characters/{character.Id}/skill-groups", new("Combat", "2D+1", 1, true));
var renamedGroup = await PutAsync<UpdateSkillGroupRequest, SkillGroupSummary>(gmClient,
$"/api/skill-groups/{createdGroup.Id}", new("Battle", "3D+2", 2, false));
Assert.Equal("Battle", renamedGroup.Name); Assert.Equal("Battle", renamedGroup.Name);
Assert.Equal("3D+2", renamedGroup.DiceRollDefinition); Assert.Equal("3D+2", renamedGroup.DiceRollDefinition);
Assert.Equal(2, renamedGroup.WildDice); Assert.Equal(2, renamedGroup.WildDice);
Assert.False(renamedGroup.AllowFumble); Assert.False(renamedGroup.AllowFumble);
var groupedSkill = await PostAsync<CreateSkillRequest, SkillSummary>(ownerClient, $"/api/characters/{character.Id}/skills", new("Strike", "2D+1", 1, true, renamedGroup.Id)); var groupedSkill = await PostAsync<CreateSkillRequest, SkillSummary>(ownerClient,
$"/api/characters/{character.Id}/skills", new("Strike", "2D+1", 1, true, renamedGroup.Id));
Assert.Equal(renamedGroup.Id, groupedSkill.SkillGroupId); Assert.Equal(renamedGroup.Id, groupedSkill.SkillGroupId);
var ungroupedSkill = await PutAsync<UpdateSkillRequest, SkillSummary>(ownerClient, $"/api/skills/{groupedSkill.Id}", new("Strike", "2D+1", 1, true)); var ungroupedSkill = await PutAsync<UpdateSkillRequest, SkillSummary>(ownerClient,
$"/api/skills/{groupedSkill.Id}", new("Strike", "2D+1", 1, true));
Assert.Null(ungroupedSkill.SkillGroupId); Assert.Null(ungroupedSkill.SkillGroupId);
var groupedAgainSkill = await PutAsync<UpdateSkillRequest, SkillSummary>(ownerClient, $"/api/skills/{groupedSkill.Id}", new("Strike", "2D+1", 1, true, renamedGroup.Id)); var groupedAgainSkill = await PutAsync<UpdateSkillRequest, SkillSummary>(ownerClient,
$"/api/skills/{groupedSkill.Id}", new("Strike", "2D+1", 1, true, renamedGroup.Id));
Assert.Equal(renamedGroup.Id, groupedAgainSkill.SkillGroupId); Assert.Equal(renamedGroup.Id, groupedAgainSkill.SkillGroupId);
var deleteSkill = await ownerClient.DeleteAsync($"/api/skills/{groupedAgainSkill.Id}"); var deleteSkill = await ownerClient.DeleteAsync($"/api/skills/{groupedAgainSkill.Id}");
@@ -153,7 +214,8 @@ public sealed class CampaignApiTests(WebApplicationFactory<Program> factory) : A
var deleteGroup = await ownerClient.DeleteAsync($"/api/skill-groups/{renamedGroup.Id}"); var deleteGroup = await ownerClient.DeleteAsync($"/api/skill-groups/{renamedGroup.Id}");
Assert.Equal(HttpStatusCode.OK, deleteGroup.StatusCode); Assert.Equal(HttpStatusCode.OK, deleteGroup.StatusCode);
var transferResult = await PutAsync<UpdateCharacterRequest, CharacterSummary>(gmClient, $"/api/characters/{character.Id}", new("Grouped Hero", campaign.Id, "receiver2")); var transferResult = await PutAsync<UpdateCharacterRequest, CharacterSummary>(gmClient,
$"/api/characters/{character.Id}", new("Grouped Hero", campaign.Id, "receiver2"));
Assert.Equal("Grouped Hero", transferResult.Name); Assert.Equal("Grouped Hero", transferResult.Name);
Assert.Equal("Receiver", transferResult.OwnerDisplayName); Assert.Equal("Receiver", transferResult.OwnerDisplayName);
@@ -190,12 +252,17 @@ public sealed class CampaignApiTests(WebApplicationFactory<Program> factory) : A
Assert.Contains(adminEntry.Roles, role => string.Equals(role, "admin", StringComparison.OrdinalIgnoreCase)); Assert.Contains(adminEntry.Roles, role => string.Equals(role, "admin", StringComparison.OrdinalIgnoreCase));
Assert.Empty(playerEntry.Roles); Assert.Empty(playerEntry.Roles);
var promotedPlayer = await PutAsync<UpdateUserRolesRequest, AdminUserSummary>(adminClient, $"/api/admin/users/{player.Id}/roles", new(["admin"])); var promotedPlayer = await PutAsync<UpdateUserRolesRequest, AdminUserSummary>(adminClient,
$"/api/admin/users/{player.Id}/roles", new(["admin"]));
Assert.Contains(promotedPlayer.Roles, role => string.Equals(role, "admin", StringComparison.OrdinalIgnoreCase)); Assert.Contains(promotedPlayer.Roles, role => string.Equals(role, "admin", StringComparison.OrdinalIgnoreCase));
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns", new("Disposable Campaign", "d6")); var campaign =
var character = await PostAsync<CreateCharacterRequest, CharacterSummary>(playerClient, "/api/characters", new("Disposable Hero", campaign.Id)); await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns",
var skill = await PostAsync<CreateSkillRequest, SkillSummary>(playerClient, $"/api/characters/{character.Id}/skills", new("Stealth", "2D+1", 1, true)); new("Disposable Campaign", "d6"));
var character = await PostAsync<CreateCharacterRequest, CharacterSummary>(playerClient, "/api/characters",
new("Disposable Hero", campaign.Id));
var skill = await PostAsync<CreateSkillRequest, SkillSummary>(playerClient,
$"/api/characters/{character.Id}/skills", new("Stealth", "2D+1", 1, true));
_ = await PostAsync<RollSkillRequest, RollResult>(playerClient, $"/api/skills/{skill.Id}/roll", new("public")); _ = await PostAsync<RollSkillRequest, RollResult>(playerClient, $"/api/skills/{skill.Id}/roll", new("public"));
var deleteCampaign = await adminClient.DeleteAsync($"/api/campaigns/{campaign.Id}"); var deleteCampaign = await adminClient.DeleteAsync($"/api/campaigns/{campaign.Id}");
@@ -267,13 +334,18 @@ public sealed class CampaignApiTests(WebApplicationFactory<Program> factory) : A
await RegisterAsync(playerClient, "player-options", "Password123", "Player"); await RegisterAsync(playerClient, "player-options", "Password123", "Player");
await LoginAsync(playerClient, "player-options", "Password123"); await LoginAsync(playerClient, "player-options", "Password123");
var firstCampaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns", new("Alpha Visible", "d6")); var firstCampaign =
var secondCampaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(otherGmClient, "/api/campaigns", new("Beta Available", "d6")); await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns",
new("Alpha Visible", "d6"));
var secondCampaign =
await PostAsync<CreateCampaignRequest, CampaignSummary>(otherGmClient, "/api/campaigns",
new("Beta Available", "d6"));
var playerVisibleCampaigns = await GetAsync<IReadOnlyList<CampaignSummary>>(playerClient, "/api/campaigns"); var playerVisibleCampaigns = await GetAsync<IReadOnlyList<CampaignSummary>>(playerClient, "/api/campaigns");
Assert.Empty(playerVisibleCampaigns); Assert.Empty(playerVisibleCampaigns);
var playerCampaignOptions = await GetAsync<IReadOnlyList<CampaignOption>>(playerClient, "/api/campaigns/options"); var playerCampaignOptions =
await GetAsync<IReadOnlyList<CampaignOption>>(playerClient, "/api/campaigns/options");
Assert.Equal(2, playerCampaignOptions.Count); Assert.Equal(2, playerCampaignOptions.Count);
Assert.Contains(playerCampaignOptions, option => option.Id == firstCampaign.Id); Assert.Contains(playerCampaignOptions, option => option.Id == firstCampaign.Id);
Assert.Contains(playerCampaignOptions, option => option.Id == secondCampaign.Id); Assert.Contains(playerCampaignOptions, option => option.Id == secondCampaign.Id);
@@ -300,9 +372,13 @@ public sealed class CampaignApiTests(WebApplicationFactory<Program> factory) : A
await RegisterAsync(otherClient, "other-delete", "Password123", "Other"); await RegisterAsync(otherClient, "other-delete", "Password123", "Other");
await LoginAsync(otherClient, "other-delete", "Password123"); await LoginAsync(otherClient, "other-delete", "Password123");
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns", new("Deletion Campaign", "d6")); var campaign =
var ownerCharacter = await PostAsync<CreateCharacterRequest, CharacterSummary>(ownerClient, "/api/characters", new("Owner Character", campaign.Id)); await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns",
var otherCharacter = await PostAsync<CreateCharacterRequest, CharacterSummary>(otherClient, "/api/characters", new("Other Character", campaign.Id)); new("Deletion Campaign", "d6"));
var ownerCharacter = await PostAsync<CreateCharacterRequest, CharacterSummary>(ownerClient, "/api/characters",
new("Owner Character", campaign.Id));
var otherCharacter = await PostAsync<CreateCharacterRequest, CharacterSummary>(otherClient, "/api/characters",
new("Other Character", campaign.Id));
var gmDeleteAttempt = await gmClient.DeleteAsync($"/api/characters/{ownerCharacter.Id}"); var gmDeleteAttempt = await gmClient.DeleteAsync($"/api/characters/{ownerCharacter.Id}");
Assert.Equal(HttpStatusCode.BadRequest, gmDeleteAttempt.StatusCode); Assert.Equal(HttpStatusCode.BadRequest, gmDeleteAttempt.StatusCode);
@@ -333,14 +409,19 @@ public sealed class CampaignApiTests(WebApplicationFactory<Program> factory) : A
await RegisterAsync(playerClient, "player-log-cap", "Password123", "Player"); await RegisterAsync(playerClient, "player-log-cap", "Password123", "Player");
await LoginAsync(playerClient, "player-log-cap", "Password123"); await LoginAsync(playerClient, "player-log-cap", "Password123");
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns", new("Log Cap", "d6")); var campaign =
var character = await PostAsync<CreateCharacterRequest, CharacterSummary>(playerClient, "/api/characters", new("Roller", campaign.Id)); await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns", new("Log Cap", "d6"));
var skill = await PostAsync<CreateSkillRequest, SkillSummary>(playerClient, $"/api/characters/{character.Id}/skills", new("Stealth", "2D+1", 1, true)); var character =
await PostAsync<CreateCharacterRequest, CharacterSummary>(playerClient, "/api/characters",
new("Roller", campaign.Id));
var skill = await PostAsync<CreateSkillRequest, SkillSummary>(playerClient,
$"/api/characters/{character.Id}/skills", new("Stealth", "2D+1", 1, true));
var rollIds = new List<Guid>(); var rollIds = new List<Guid>();
for (var i = 0; i < 105; i++) for (var i = 0; i < 105; i++)
{ {
var roll = await PostAsync<RollSkillRequest, RollResult>(playerClient, $"/api/skills/{skill.Id}/roll", new("public")); var roll = await PostAsync<RollSkillRequest, RollResult>(playerClient, $"/api/skills/{skill.Id}/roll",
new("public"));
rollIds.Add(roll.RollId); rollIds.Add(roll.RollId);
} }
@@ -369,14 +450,19 @@ public sealed class CampaignApiTests(WebApplicationFactory<Program> factory) : A
await RegisterAsync(playerClient, "player-log-page", "Password123", "Player"); await RegisterAsync(playerClient, "player-log-page", "Password123", "Player");
await LoginAsync(playerClient, "player-log-page", "Password123"); await LoginAsync(playerClient, "player-log-page", "Password123");
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns", new("Log Page", "d6")); var campaign =
var character = await PostAsync<CreateCharacterRequest, CharacterSummary>(playerClient, "/api/characters", new("Roller", campaign.Id)); await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns", new("Log Page", "d6"));
var skill = await PostAsync<CreateSkillRequest, SkillSummary>(playerClient, $"/api/characters/{character.Id}/skills", new("Stealth", "2D+1", 1, true)); var character =
await PostAsync<CreateCharacterRequest, CharacterSummary>(playerClient, "/api/characters",
new("Roller", campaign.Id));
var skill = await PostAsync<CreateSkillRequest, SkillSummary>(playerClient,
$"/api/characters/{character.Id}/skills", new("Stealth", "2D+1", 1, true));
var rollIds = new List<Guid>(); var rollIds = new List<Guid>();
for (var i = 0; i < 5; i++) for (var i = 0; i < 5; i++)
{ {
var roll = await PostAsync<RollSkillRequest, RollResult>(playerClient, $"/api/skills/{skill.Id}/roll", new("public")); var roll = await PostAsync<RollSkillRequest, RollResult>(playerClient, $"/api/skills/{skill.Id}/roll",
new("public"));
rollIds.Add(roll.RollId); rollIds.Add(roll.RollId);
} }
@@ -393,8 +479,10 @@ public sealed class CampaignApiTests(WebApplicationFactory<Program> factory) : A
Assert.False(string.IsNullOrWhiteSpace(entry.VisibilityLabel)); Assert.False(string.IsNullOrWhiteSpace(entry.VisibilityLabel));
}); });
var latestRoll = await PostAsync<RollSkillRequest, RollResult>(playerClient, $"/api/skills/{skill.Id}/roll", new("public")); var latestRoll =
var incrementalPage = await GetAsync<CampaignLogPage>(gmClient, $"/api/campaigns/{campaign.Id}/log/page?afterRollId={initialPage.Cursor}&limit=3"); await PostAsync<RollSkillRequest, RollResult>(playerClient, $"/api/skills/{skill.Id}/roll", new("public"));
var incrementalPage = await GetAsync<CampaignLogPage>(gmClient,
$"/api/campaigns/{campaign.Id}/log/page?afterRollId={initialPage.Cursor}&limit=3");
Assert.Single(incrementalPage.Entries); Assert.Single(incrementalPage.Entries);
Assert.Equal(latestRoll.RollId, incrementalPage.Entries[0].RollId); Assert.Equal(latestRoll.RollId, incrementalPage.Entries[0].RollId);

View File

@@ -1,4 +1,4 @@
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.Data.Sqlite; using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -159,6 +159,7 @@ public sealed class HostingCoverageTests
usersColumns.Add(usersTableInfoReader.GetString(1)); usersColumns.Add(usersTableInfoReader.GetString(1));
Assert.Contains("Roles", usersColumns); Assert.Contains("Roles", usersColumns);
Assert.Contains("ThemePreference", usersColumns);
using var usersRoleCommand = verifyConnection.CreateCommand(); using var usersRoleCommand = verifyConnection.CreateCommand();
usersRoleCommand.CommandText = "SELECT Roles FROM Users WHERE UsernameNormalized = 'LEGACY-ADMIN';"; usersRoleCommand.CommandText = "SELECT Roles FROM Users WHERE UsernameNormalized = 'LEGACY-ADMIN';";
@@ -214,6 +215,11 @@ public sealed class HostingCoverageTests
retryHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260414204309_AddRolemasterAutoRetry';"; retryHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260414204309_AddRolemasterAutoRetry';";
var retryHistoryCount = Convert.ToInt32(retryHistoryCommand.ExecuteScalar()); var retryHistoryCount = Convert.ToInt32(retryHistoryCommand.ExecuteScalar());
Assert.Equal(1, retryHistoryCount); Assert.Equal(1, retryHistoryCount);
using var themeHistoryCommand = verifyConnection.CreateCommand();
themeHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260518183838_AddUserThemePreference';";
var themeHistoryCount = Convert.ToInt32(themeHistoryCommand.ExecuteScalar());
Assert.Equal(1, themeHistoryCount);
} }
[Fact] [Fact]
@@ -359,6 +365,11 @@ public sealed class HostingCoverageTests
retryHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260414204309_AddRolemasterAutoRetry';"; retryHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260414204309_AddRolemasterAutoRetry';";
var retryHistoryCount = Convert.ToInt32(retryHistoryCommand.ExecuteScalar()); var retryHistoryCount = Convert.ToInt32(retryHistoryCommand.ExecuteScalar());
Assert.Equal(1, retryHistoryCount); Assert.Equal(1, retryHistoryCount);
using var themeHistoryCommand = verifyConnection.CreateCommand();
themeHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260518183838_AddUserThemePreference';";
var themeHistoryCount = Convert.ToInt32(themeHistoryCommand.ExecuteScalar());
Assert.Equal(1, themeHistoryCount);
} }
[Fact] [Fact]
@@ -481,6 +492,15 @@ public sealed class HostingCoverageTests
Assert.Contains("FumbleRange", skillGroupColumns); Assert.Contains("FumbleRange", skillGroupColumns);
using var usersTableInfoCommand = verifyConnection.CreateCommand();
usersTableInfoCommand.CommandText = "PRAGMA table_info('Users');";
using var usersTableInfoReader = usersTableInfoCommand.ExecuteReader();
var usersColumns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
while (usersTableInfoReader.Read())
usersColumns.Add(usersTableInfoReader.GetString(1));
Assert.Contains("ThemePreference", usersColumns);
using var authorizationRolesHistoryCommand = verifyConnection.CreateCommand(); using var authorizationRolesHistoryCommand = verifyConnection.CreateCommand();
authorizationRolesHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260226170000_AddAuthorizationRoles';"; authorizationRolesHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260226170000_AddAuthorizationRoles';";
var authorizationRolesHistoryCount = Convert.ToInt32(authorizationRolesHistoryCommand.ExecuteScalar()); var authorizationRolesHistoryCount = Convert.ToInt32(authorizationRolesHistoryCommand.ExecuteScalar());
@@ -490,5 +510,10 @@ public sealed class HostingCoverageTests
retryHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260414204309_AddRolemasterAutoRetry';"; retryHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260414204309_AddRolemasterAutoRetry';";
var retryHistoryCount = Convert.ToInt32(retryHistoryCommand.ExecuteScalar()); var retryHistoryCount = Convert.ToInt32(retryHistoryCommand.ExecuteScalar());
Assert.Equal(1, retryHistoryCount); Assert.Equal(1, retryHistoryCount);
using var themeHistoryCommand = verifyConnection.CreateCommand();
themeHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260518183838_AddUserThemePreference';";
var themeHistoryCount = Convert.ToInt32(themeHistoryCommand.ExecuteScalar());
Assert.Equal(1, themeHistoryCount);
} }
} }

View File

@@ -1,4 +1,4 @@
namespace RpgRoller.Tests; namespace RpgRoller.Tests;
public sealed class ServiceAuthTests public sealed class ServiceAuthTests
{ {
@@ -74,4 +74,26 @@ public sealed class ServiceAuthTests
var usernames = ServiceTestSupport.GetValue(service.GetUsernames(session)); var usernames = ServiceTestSupport.GetValue(service.GetUsernames(session));
Assert.Equal(["amy", "bob", "zoe"], usernames); Assert.Equal(["amy", "bob", "zoe"], usernames);
} }
[Fact]
public void UpdateThemePreference_RequiresAuthAndPersistsSupportedTheme()
{
using var harness = ServiceTestSupport.CreateHarness();
var service = harness.Service;
service.Register("theme-user", "Password123", "Theme User");
var session = ServiceTestSupport.GetValue(service.Login("theme-user", "Password123")).SessionToken;
var unauthorized = service.UpdateThemePreference(string.Empty, "dark");
var invalid = service.UpdateThemePreference(session, "sepia");
var updated = service.UpdateThemePreference(session, "DARK");
Assert.False(unauthorized.Succeeded);
Assert.False(invalid.Succeeded);
Assert.True(updated.Succeeded);
Assert.Equal("dark", ServiceTestSupport.GetValue(updated).ThemePreference);
var me = ServiceTestSupport.GetValue(service.GetMe(session));
Assert.Equal("dark", me.User.ThemePreference);
}
} }

View File

@@ -1,4 +1,4 @@
namespace RpgRoller.Tests; namespace RpgRoller.Tests;
public sealed class ServicePersistenceTests public sealed class ServicePersistenceTests
{ {
@@ -32,12 +32,16 @@ public sealed class ServicePersistenceTests
Assert.False(service.UpdateCharacter(string.Empty, ownerCharacter.Id, "Renamed", campaign.Id).Succeeded); Assert.False(service.UpdateCharacter(string.Empty, ownerCharacter.Id, "Renamed", campaign.Id).Succeeded);
Assert.False(service.UpdateCharacter(ownerSession, Guid.NewGuid(), "Renamed", campaign.Id).Succeeded); Assert.False(service.UpdateCharacter(ownerSession, Guid.NewGuid(), "Renamed", campaign.Id).Succeeded);
Assert.False(service.ActivateCharacter(string.Empty, ownerCharacter.Id).Succeeded); Assert.False(service.ActivateCharacter(string.Empty, ownerCharacter.Id).Succeeded);
Assert.False(service.ActivateCharacter(gmSession, ownerCharacter.Id).Succeeded); Assert.True(service.ActivateCharacter(gmSession, ownerCharacter.Id).Succeeded);
Assert.False(service.GetOwnCharacters(string.Empty).Succeeded); Assert.False(service.GetOwnCharacters(string.Empty).Succeeded);
Assert.False(service.CreateSkill(string.Empty, ownerCharacter.Id, "Stealth", "2D+1", 1, true).Succeeded); Assert.False(service.CreateSkill(string.Empty, ownerCharacter.Id, "Stealth", "2D+1", 1, true).Succeeded);
Assert.False(service.CreateSkill(ownerSession, Guid.NewGuid(), "Stealth", "2D+1", 1, true).Succeeded); Assert.False(service.CreateSkill(ownerSession, Guid.NewGuid(), "Stealth", "2D+1", 1, true).Succeeded);
Assert.False(service.CreateSkill(otherSession, ownerCharacter.Id, "Stealth", "2D+1", 1, true).Succeeded); Assert.False(service.CreateSkill(otherSession, ownerCharacter.Id, "Stealth", "2D+1", 1, true).Succeeded);
var gmMe = ServiceTestSupport.GetValue(service.GetMe(gmSession));
Assert.Equal(ownerCharacter.Id, gmMe.ActiveCharacterId);
Assert.Equal(campaign.Id, gmMe.CurrentCampaignId);
using (var db = harness.CreateDbContext()) using (var db = harness.CreateDbContext())
{ {
var ownerUser = db.Users.Single(u => u.UsernameNormalized == "OWNER"); var ownerUser = db.Users.Single(u => u.UsernameNormalized == "OWNER");
@@ -120,4 +124,22 @@ public sealed class ServicePersistenceTests
Assert.Equal(3, reloadedSkill.FumbleRange); Assert.Equal(3, reloadedSkill.FumbleRange);
Assert.True(reloadedSkill.RolemasterAutoRetry); Assert.True(reloadedSkill.RolemasterAutoRetry);
} }
[Fact]
public void UserThemePreference_PersistsAcrossDatabaseReload()
{
using var harness = ServiceTestSupport.CreateHarness();
var service = harness.Service;
service.Register("theme-persist", "Password123", "Theme Persist");
var session = ServiceTestSupport.GetValue(service.Login("theme-persist", "Password123")).SessionToken;
var updated = ServiceTestSupport.GetValue(service.UpdateThemePreference(session, "dark"));
Assert.Equal("dark", updated.ThemePreference);
using var reloadedHarness = ServiceTestSupport.CreateHarnessFromPath(harness.DbPath);
var me = ServiceTestSupport.GetValue(reloadedHarness.Service.GetMe(session));
Assert.Equal("dark", me.User.ThemePreference);
}
} }

View File

@@ -41,7 +41,7 @@ public sealed class WorkspaceStateTests
} }
[Fact] [Fact]
public void PlaySelections_FilterToOwnedCharactersAndPreferSelectedThenActive() public void PlaySelections_ForNonGm_FilterToOwnedCharactersAndPreferSelectedThenActive()
{ {
var userId = Guid.NewGuid(); var userId = Guid.NewGuid();
var ownedCharacter = new CharacterSummary(Guid.NewGuid(), "Owned", userId, Guid.NewGuid(), "User"); var ownedCharacter = new CharacterSummary(Guid.NewGuid(), "Owned", userId, Guid.NewGuid(), "User");
@@ -70,6 +70,25 @@ public sealed class WorkspaceStateTests
Assert.Equal(ownedCharacter.Id, state.PlaySelectedCharacterId); Assert.Equal(ownedCharacter.Id, state.PlaySelectedCharacterId);
} }
[Fact]
public void PlaySelections_ForGm_ExposeEntireCampaignAndKeepNonOwnedSelection()
{
var gmId = Guid.NewGuid();
var otherCharacter = new CharacterSummary(Guid.NewGuid(), "Other", Guid.NewGuid(), Guid.NewGuid(), "Other");
var ownedCharacter = new CharacterSummary(Guid.NewGuid(), "Owned", gmId, Guid.NewGuid(), "GM");
var state = new WorkspaceState
{
User = new(gmId, "gm", "GM", []),
SelectedCampaign = new(Guid.NewGuid(), "Alpha", "d6", new(gmId, "GM"),
[ownedCharacter, otherCharacter]),
SelectedCharacterId = otherCharacter.Id
};
Assert.Equal(2, state.PlaySelectedCampaign!.Characters.Length);
Assert.Equal(otherCharacter.Id, state.PlaySelectedCharacterId);
Assert.Equal(otherCharacter.Id, state.PlaySelectedCharacter!.Id);
}
[Fact] [Fact]
public void CampaignAndConnectionFlags_ReflectCurrentState() public void CampaignAndConnectionFlags_ReflectCurrentState()
{ {

View File

@@ -1,4 +1,4 @@
using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Http.HttpResults;
using RpgRoller.Contracts; using RpgRoller.Contracts;
using RpgRoller.Services; using RpgRoller.Services;
@@ -14,6 +14,12 @@ internal static class MeEndpoints
return ApiResultMapper.ToApiResult(result); return ApiResultMapper.ToApiResult(result);
}); });
group.MapPut("/me/theme", Results<Ok<UserSummary>, BadRequest<ApiError>, UnauthorizedHttpResult> (UpdateThemePreferenceRequest request, HttpContext context, IGameService game) =>
{
var result = game.UpdateThemePreference(context.GetRequiredSessionToken(), request.ThemePreference);
return ApiResultMapper.ToApiResult(result);
});
return group; return group;
} }
} }

View File

@@ -1,4 +1,4 @@
@using RpgRoller.Components.Pages.HomeControls @using RpgRoller.Components.Pages.HomeControls
@attribute [ExcludeFromCodeCoverage] @attribute [ExcludeFromCodeCoverage]
<!DOCTYPE html> <!DOCTYPE html>
@@ -8,6 +8,12 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<base href="@BaseHref"/> <base href="@BaseHref"/>
<title>RpgRoller</title> <title>RpgRoller</title>
<script>
document.documentElement.dataset.theme = window.matchMedia &&
window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
</script>
<link rel="stylesheet" href="@Assets["styles.css"]"/> <link rel="stylesheet" href="@Assets["styles.css"]"/>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>

View File

@@ -1,9 +1,8 @@
@using Microsoft.AspNetCore.Components @using Microsoft.AspNetCore.Components
@using RpgRoller.Components.Pages.HomeControls @using RpgRoller.Components.Pages.HomeControls
<CampaignManagementPanel <CampaignManagementPanel
Campaigns="Workspace.State.Campaigns" Campaigns="Workspace.State.Campaigns"
SelectedCampaignId="Workspace.State.SelectedCampaignId"
SelectedCampaign="Workspace.State.SelectedCampaign" SelectedCampaign="Workspace.State.SelectedCampaign"
Rulesets="Workspace.State.Rulesets" Rulesets="Workspace.State.Rulesets"
IsMutating="Workspace.State.IsMutating" IsMutating="Workspace.State.IsMutating"
@@ -11,7 +10,6 @@
CanEditCharacter="Workspace.Campaigns.CanEditCharacter" CanEditCharacter="Workspace.Campaigns.CanEditCharacter"
CanDeleteCharacter="Workspace.Campaigns.CanDeleteCharacter" CanDeleteCharacter="Workspace.Campaigns.CanDeleteCharacter"
CanDeleteCampaign="Workspace.State.CanDeleteSelectedCampaign" CanDeleteCampaign="Workspace.State.CanDeleteSelectedCampaign"
CampaignSelectionChanged="OnCampaignSelectionChangedAsync"
CampaignCreated="Workspace.Campaigns.OnCampaignCreatedAsync" CampaignCreated="Workspace.Campaigns.OnCampaignCreatedAsync"
DeleteCampaignRequested="Workspace.Campaigns.DeleteSelectedCampaignAsync" DeleteCampaignRequested="Workspace.Campaigns.DeleteSelectedCampaignAsync"
CreateCharacterRequested="Workspace.Campaigns.OpenCreateCharacterModal" CreateCharacterRequested="Workspace.Campaigns.OpenCreateCharacterModal"
@@ -22,10 +20,4 @@
@code { @code {
[Parameter, EditorRequired] public WorkspacePageContext Workspace { get; set; } = null!; [Parameter, EditorRequired] public WorkspacePageContext Workspace { get; set; } = null!;
private async Task OnCampaignSelectionChangedAsync(ChangeEventArgs args)
{
await Workspace.Campaigns.OnCampaignSelectionChangedAsync(args);
await Workspace.RequestRefreshAsync();
}
} }

View File

@@ -1,4 +1,4 @@
<header class="workspace-header"> <header class="workspace-header">
<div class="header-row"> <div class="header-row">
<h1>@Title</h1> <h1>@Title</h1>
@if (User is null) @if (User is null)
@@ -15,7 +15,23 @@
} }
@if (ShowCampaign) @if (ShowCampaign)
{ {
<p class="header-campaign">Campaign: <strong>@(CampaignName ?? "No campaign selected")</strong></p> <div class="header-campaign">
<label for="@CampaignSelectId">Campaign</label>
@if (Campaigns.Count == 0)
{
<span>No campaigns yet</span>
}
else
{
<select id="@CampaignSelectId"
@onchange="CampaignSelectionChanged">
@foreach (var campaign in Campaigns)
{
<option value="@campaign.Id" selected="@(campaign.Id == SelectedCampaignId)">@campaign.Name</option>
}
</select>
}
</div>
} }
<div class="header-connection-cell"> <div class="header-connection-cell">
@if (ShowConnectionState) @if (ShowConnectionState)
@@ -24,6 +40,13 @@
} }
</div> </div>
<a href="" class="logout-link" @onclick:preventDefault="true" @onclick="LogoutRequested">Logout</a> <a href="" class="logout-link" @onclick:preventDefault="true" @onclick="LogoutRequested">Logout</a>
<button type="button"
class="theme-toggle"
aria-label="@ThemeToggleAriaLabel"
title="@ThemeToggleAriaLabel"
@onclick="ThemeToggleRequested">
<span aria-hidden="true">@ThemeToggleLabel</span>
</button>
@if (MenuItems.Count > 0) @if (MenuItems.Count > 0)
{ {
<div class="header-menu-wrap"> <div class="header-menu-wrap">

View File

@@ -1,4 +1,4 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using RpgRoller.Contracts; using RpgRoller.Contracts;
@@ -12,31 +12,64 @@ public partial class AppHeader
return item.OnSelected?.Invoke() ?? Task.CompletedTask; return item.OnSelected?.Invoke() ?? Task.CompletedTask;
} }
[Parameter] public string Title { get; set; } = "RpgRoller"; private string ThemeToggleAriaLabel => string.Equals(Theme, "dark", StringComparison.OrdinalIgnoreCase) ? "Switch to light theme" : "Switch to dark theme";
[Parameter] public UserSummary? User { get; set; } [Parameter]
public string Title { get; set; } = "RpgRoller";
[Parameter] public bool ShowCampaign { get; set; } [Parameter]
public UserSummary? User { get; set; }
[Parameter] public string? CampaignName { get; set; } [Parameter]
public bool ShowCampaign { get; set; }
[Parameter] public bool ShowConnectionState { get; set; } = true; [Parameter]
public IReadOnlyList<CampaignSummary> Campaigns { get; set; } = [];
[Parameter] public string ConnectionStateLabel { get; set; } = "Offline fallback"; [Parameter]
public Guid? SelectedCampaignId { get; set; }
[Parameter] public string ConnectionStateCssClass { get; set; } = "offline"; [Parameter]
public string CampaignSelectId { get; set; } = "header-campaign-select";
[Parameter] public bool IsMenuOpen { get; set; } [Parameter]
public EventCallback<ChangeEventArgs> CampaignSelectionChanged { get; set; }
[Parameter] public string MenuButtonId { get; set; } = "screen-menu-button"; [Parameter]
public bool ShowConnectionState { get; set; } = true;
[Parameter] public string MenuId { get; set; } = "screen-menu"; [Parameter]
public string ConnectionStateLabel { get; set; } = "Offline fallback";
[Parameter] public IReadOnlyList<AppHeaderMenuItem> MenuItems { get; set; } = []; [Parameter]
public string ConnectionStateCssClass { get; set; } = "offline";
[Parameter] public EventCallback ToggleMenuRequested { get; set; } [Parameter]
public bool IsMenuOpen { get; set; }
[Parameter] public EventCallback LogoutRequested { get; set; } [Parameter]
public string MenuButtonId { get; set; } = "screen-menu-button";
[Parameter]
public string MenuId { get; set; } = "screen-menu";
[Parameter]
public IReadOnlyList<AppHeaderMenuItem> MenuItems { get; set; } = [];
[Parameter]
public EventCallback ToggleMenuRequested { get; set; }
[Parameter]
public string Theme { get; set; } = "light";
[Parameter]
public string ThemeToggleLabel { get; set; } = "☀️";
[Parameter]
public EventCallback ThemeToggleRequested { get; set; }
[Parameter]
public EventCallback LogoutRequested { get; set; }
} }
public sealed class AppHeaderMenuItem public sealed class AppHeaderMenuItem

View File

@@ -1,4 +1,4 @@
<main class="management-screen"> <main class="management-screen">
<section class="card"> <section class="card">
<div class="section-head"> <div class="section-head">
<h2>Campaign</h2> <h2>Campaign</h2>
@@ -9,13 +9,14 @@
} }
else else
{ {
<label for="campaign-select">Current campaign</label> <div class="campaign-current">
<select id="campaign-select" @onchange="CampaignSelectionChanged"> <span>Current campaign</span>
@foreach (var campaign in Campaigns) <strong>@(SelectedCampaign is null ? "No campaign selected" : SelectedCampaign.Name)</strong>
@if (SelectedCampaign is not null)
{ {
<option value="@campaign.Id" selected="@(campaign.Id == SelectedCampaignId)">@campaign.Name (@campaign.RulesetId), GM: @campaign.Gm.DisplayName, @campaign.CharacterCount characters</option> <p>@SelectedCampaign.RulesetId, GM: @SelectedCampaign.Gm.DisplayName, @SelectedCampaign.Characters.Length characters</p>
} }
</select> </div>
} }
<button type="button" <button type="button"

View File

@@ -1,4 +1,4 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using RpgRoller.Contracts; using RpgRoller.Contracts;
@@ -74,9 +74,6 @@ public partial class CampaignManagementPanel
[Parameter] [Parameter]
public IReadOnlyList<CampaignSummary> Campaigns { get; set; } = []; public IReadOnlyList<CampaignSummary> Campaigns { get; set; } = [];
[Parameter]
public Guid? SelectedCampaignId { get; set; }
[Parameter] [Parameter]
public CampaignRoster? SelectedCampaign { get; set; } public CampaignRoster? SelectedCampaign { get; set; }
@@ -98,9 +95,6 @@ public partial class CampaignManagementPanel
[Parameter] [Parameter]
public bool CanDeleteCampaign { get; set; } public bool CanDeleteCampaign { get; set; }
[Parameter]
public EventCallback<ChangeEventArgs> CampaignSelectionChanged { get; set; }
[Parameter] [Parameter]
public EventCallback<Guid> CampaignCreated { get; set; } public EventCallback<Guid> CampaignCreated { get; set; }

View File

@@ -1,4 +1,4 @@
<section class="card character-panel"> <section class="card character-panel">
@if (IsCampaignDataLoading) @if (IsCampaignDataLoading)
{ {
<div class="skeleton-stack"> <div class="skeleton-stack">
@@ -9,7 +9,7 @@
} }
else if (SelectedCampaign is null) else if (SelectedCampaign is null)
{ {
<p class="empty">No campaign selected. Choose one in Campaign Management.</p> <p class="empty">No campaign selected. Choose one in the header.</p>
} }
else if (SelectedCampaign.Characters.Length == 0) else if (SelectedCampaign.Characters.Length == 0)
{ {

View File

@@ -1,4 +1,4 @@
@using RpgRoller.Components.Pages.HomeControls @using RpgRoller.Components.Pages.HomeControls
<div class="@AppCssClass"> <div class="@AppCssClass">
<p class="sr-only" aria-live="polite">@State.LiveAnnouncement</p> <p class="sr-only" aria-live="polite">@State.LiveAnnouncement</p>
@@ -17,7 +17,9 @@
<AppHeader <AppHeader
User="State.User" User="State.User"
ShowCampaign="@ShowCampaignInHeader" ShowCampaign="@ShowCampaignInHeader"
CampaignName="@State.SelectedCampaignName" Campaigns="State.Campaigns"
SelectedCampaignId="State.SelectedCampaignId"
CampaignSelectionChanged="OnHeaderCampaignSelectionChangedAsync"
ShowConnectionState="@ShowConnectionStateInHeader" ShowConnectionState="@ShowConnectionStateInHeader"
ConnectionStateLabel="@State.ConnectionStateLabel" ConnectionStateLabel="@State.ConnectionStateLabel"
ConnectionStateCssClass="@State.ConnectionStateCssClass" ConnectionStateCssClass="@State.ConnectionStateCssClass"
@@ -26,6 +28,9 @@
MenuId="workspace-screen-menu" MenuId="workspace-screen-menu"
MenuItems="HeaderMenuItems" MenuItems="HeaderMenuItems"
ToggleMenuRequested="ToggleScreenMenu" ToggleMenuRequested="ToggleScreenMenu"
Theme="@State.ThemePreference"
ThemeToggleLabel="@State.ThemeToggleLabel"
ThemeToggleRequested="Session.ToggleThemePreferenceAsync"
LogoutRequested="Session.LogoutAsync"/> LogoutRequested="Session.LogoutAsync"/>
@if (ChildContent is not null) @if (ChildContent is not null)

View File

@@ -1,4 +1,4 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop; using Microsoft.JSInterop;
using RpgRoller.Components.Pages.HomeControls; using RpgRoller.Components.Pages.HomeControls;
@@ -84,6 +84,12 @@ public partial class Workspace : IAsyncDisposable
return Task.CompletedTask; return Task.CompletedTask;
} }
private async Task OnHeaderCampaignSelectionChangedAsync(ChangeEventArgs args)
{
await Campaigns.OnCampaignSelectionChangedAsync(args);
await RequestRefreshAsync();
}
private Task RedirectToPlayAsync() private Task RedirectToPlayAsync()
{ {
if (IsPlayRoute) if (IsPlayRoute)

View File

@@ -328,7 +328,7 @@ public sealed class WorkspacePlayCoordinator(
return; return;
var character = state.SelectedCampaign.Characters.FirstOrDefault(c => c.Id == state.SelectedCharacterId.Value); var character = state.SelectedCampaign.Characters.FirstOrDefault(c => c.Id == state.SelectedCharacterId.Value);
if (character is null || !CanActivateCharacter(character, state.User) || if (character is null || !CanActivateCharacter(character) ||
state.ActiveCharacterId == character.Id) state.ActiveCharacterId == character.Id)
return; return;
@@ -410,9 +410,10 @@ public sealed class WorkspacePlayCoordinator(
state.FreshCampaignLogRollId = rollId; state.FreshCampaignLogRollId = rollId;
} }
private static bool CanActivateCharacter(CharacterSummary character, UserSummary? user) private bool CanActivateCharacter(CharacterSummary character)
{ {
return user is not null && character.OwnerUserId == user.Id; return state.User is not null &&
(character.OwnerUserId == state.User.Id || state.IsCurrentUserGm);
} }
private static CampaignRollDetail ToCampaignRollDetail(RollResult roll) private static CampaignRollDetail ToCampaignRollDetail(RollResult roll)

View File

@@ -1,25 +1,10 @@
using Microsoft.JSInterop; using Microsoft.JSInterop;
using RpgRoller.Contracts; using RpgRoller.Contracts;
using RpgRoller.Domain;
namespace RpgRoller.Components.Pages; namespace RpgRoller.Components.Pages;
public sealed class WorkspaceSessionCoordinator( 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)
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() public async Task InitializeAsync()
{ {
@@ -27,8 +12,7 @@ public sealed class WorkspaceSessionCoordinator(
if (string.Equals(storedPanel, "log", StringComparison.OrdinalIgnoreCase)) if (string.Equals(storedPanel, "log", StringComparison.OrdinalIgnoreCase))
state.MobilePanel = "log"; state.MobilePanel = "log";
var storedRollVisibility = var storedRollVisibility = await js.InvokeAsync<string?>("rpgRollerApi.getSessionValue", RollVisibilitySessionKey);
await js.InvokeAsync<string?>("rpgRollerApi.getSessionValue", RollVisibilitySessionKey);
state.RollVisibility = NormalizeRollVisibility(storedRollVisibility); state.RollVisibility = NormalizeRollVisibility(storedRollVisibility);
Guid? preferredCampaignId = null; Guid? preferredCampaignId = null;
@@ -101,6 +85,33 @@ public sealed class WorkspaceSessionCoordinator(
await requestRefreshAsync(); await requestRefreshAsync();
} }
public async Task ToggleThemePreferenceAsync()
{
if (state.User is null || state.IsMutating)
return;
var previousTheme = state.ThemePreference;
var nextTheme = state.NextThemePreference;
state.ThemePreference = nextTheme;
await js.InvokeVoidAsync("rpgRollerApi.applyTheme", nextTheme);
await requestRefreshAsync();
try
{
state.User = await apiClient.RequestAsync<UserSummary>("PUT", "/api/me/theme", new UpdateThemePreferenceRequest(nextTheme));
state.ThemePreference = NormalizeThemePreference(state.User.ThemePreference);
await js.InvokeVoidAsync("rpgRollerApi.applyTheme", state.ThemePreference);
}
catch (ApiRequestException ex)
{
state.ThemePreference = previousTheme;
await js.InvokeVoidAsync("rpgRollerApi.applyTheme", previousTheme);
feedback.SetStatus(ex.Message, true);
}
await requestRefreshAsync();
}
public void ClearAuthenticatedState() public void ClearAuthenticatedState()
{ {
state.User = null; state.User = null;
@@ -117,6 +128,7 @@ public sealed class WorkspaceSessionCoordinator(
state.SelectedCharacterId = null; state.SelectedCharacterId = null;
state.LastRoll = null; state.LastRoll = null;
state.KnownUsernames = []; state.KnownUsernames = [];
state.ThemePreference = ThemePreferences.Light;
state.ShowCreateCharacterModal = false; state.ShowCreateCharacterModal = false;
state.ShowEditCharacterModal = false; state.ShowEditCharacterModal = false;
state.CanEditCharacterOwner = false; state.CanEditCharacterOwner = false;
@@ -161,6 +173,7 @@ public sealed class WorkspaceSessionCoordinator(
state.User = me.User; state.User = me.User;
state.ActiveCharacterId = me.ActiveCharacterId; state.ActiveCharacterId = me.ActiveCharacterId;
await EnsureThemePreferenceAsync();
if (!await EnsureRouteAccessAsync()) if (!await EnsureRouteAccessAsync())
return true; return true;
@@ -211,6 +224,38 @@ public sealed class WorkspaceSessionCoordinator(
return string.Equals(visibility, "private", StringComparison.OrdinalIgnoreCase) ? "private" : "public"; return string.Equals(visibility, "private", StringComparison.OrdinalIgnoreCase) ? "private" : "public";
} }
private async Task EnsureThemePreferenceAsync()
{
if (state.User is null)
return;
var themePreference = state.User.ThemePreference;
if (ThemePreferences.IsSupported(themePreference))
{
state.ThemePreference = ThemePreferences.Normalize(themePreference!);
await js.InvokeVoidAsync("rpgRollerApi.applyTheme", state.ThemePreference);
return;
}
var systemThemePreference = await js.InvokeAsync<string>("rpgRollerApi.getSystemTheme");
state.ThemePreference = NormalizeThemePreference(systemThemePreference);
await js.InvokeVoidAsync("rpgRollerApi.applyTheme", state.ThemePreference);
try
{
state.User = await apiClient.RequestAsync<UserSummary>("PUT", "/api/me/theme", new UpdateThemePreferenceRequest(state.ThemePreference));
}
catch (ApiRequestException ex)
{
feedback.SetStatus(ex.Message, true);
}
}
private static string NormalizeThemePreference(string? themePreference)
{
return ThemePreferences.IsSupported(themePreference) ? ThemePreferences.Normalize(themePreference!) : ThemePreferences.Light;
}
private const string CampaignSessionKey = "campaign"; private const string CampaignSessionKey = "campaign";
private const string MobilePanelSessionKey = "play-panel"; private const string MobilePanelSessionKey = "play-panel";
private const string RollVisibilitySessionKey = "roll-visibility"; private const string RollVisibilitySessionKey = "roll-visibility";

View File

@@ -1,4 +1,4 @@
using RpgRoller.Components.Pages.HomeControls; using RpgRoller.Components.Pages.HomeControls;
using RpgRoller.Contracts; using RpgRoller.Contracts;
using RpgRoller.Domain; using RpgRoller.Domain;
@@ -17,9 +17,7 @@ public sealed class WorkspaceState
if (ownerUserId == SelectedCampaign.Gm.Id) if (ownerUserId == SelectedCampaign.Gm.Id)
return $"{SelectedCampaign.Gm.DisplayName} (GM)"; return $"{SelectedCampaign.Gm.DisplayName} (GM)";
var ownerDisplayName = SelectedCampaign.Characters.Where(character => character.OwnerUserId == ownerUserId) var ownerDisplayName = SelectedCampaign.Characters.Where(character => character.OwnerUserId == ownerUserId).Select(character => character.OwnerDisplayName).FirstOrDefault(displayName => !string.IsNullOrWhiteSpace(displayName));
.Select(character => character.OwnerDisplayName)
.FirstOrDefault(displayName => !string.IsNullOrWhiteSpace(displayName));
return string.IsNullOrWhiteSpace(ownerDisplayName) ? "Unknown owner" : ownerDisplayName; return string.IsNullOrWhiteSpace(ownerDisplayName) ? "Unknown owner" : ownerDisplayName;
} }
@@ -28,10 +26,8 @@ public sealed class WorkspaceState
{ {
if (!string.Equals(SelectedCampaign?.RulesetId, "d6", StringComparison.OrdinalIgnoreCase)) if (!string.Equals(SelectedCampaign?.RulesetId, "d6", StringComparison.OrdinalIgnoreCase))
{ {
if (string.Equals(SelectedCampaign?.RulesetId, RulesetFormHelpers.RulesetIds.Rolemaster, if (string.Equals(SelectedCampaign?.RulesetId, RulesetFormHelpers.RulesetIds.Rolemaster, StringComparison.OrdinalIgnoreCase))
StringComparison.OrdinalIgnoreCase)) return RulesetFormHelpers.DescribeRolemasterExpression(skill.DiceRollDefinition, skill.FumbleRange, skill.RolemasterAutoRetry);
return RulesetFormHelpers.DescribeRolemasterExpression(skill.DiceRollDefinition, skill.FumbleRange,
skill.RolemasterAutoRetry);
return skill.DiceRollDefinition; return skill.DiceRollDefinition;
} }
@@ -55,6 +51,7 @@ public sealed class WorkspaceState
public RollResult? LastRoll { get; set; } public RollResult? LastRoll { get; set; }
public List<string> KnownUsernames { get; set; } = []; public List<string> KnownUsernames { get; set; } = [];
public string RollVisibility { get; set; } = "public"; public string RollVisibility { get; set; } = "public";
public string ThemePreference { get; set; } = ThemePreferences.Light;
public bool IsMutating { get; set; } public bool IsMutating { get; set; }
public bool IsCampaignDataLoading { get; set; } public bool IsCampaignDataLoading { get; set; }
@@ -91,10 +88,6 @@ public sealed class WorkspaceState
public HashSet<Guid> CampaignLogDetailsLoading { get; } = []; public HashSet<Guid> CampaignLogDetailsLoading { get; } = [];
public Dictionary<Guid, string> CampaignLogDetailErrors { get; } = []; public Dictionary<Guid, string> CampaignLogDetailErrors { get; } = [];
public string? SelectedCampaignName => SelectedCampaign?.Name ??
Campaigns.FirstOrDefault(campaign => campaign.Id == SelectedCampaignId)
?.Name;
public CharacterSummary? SelectedCharacter => public CharacterSummary? SelectedCharacter =>
SelectedCampaign?.Characters.FirstOrDefault(character => character.Id == SelectedCharacterId); SelectedCampaign?.Characters.FirstOrDefault(character => character.Id == SelectedCharacterId);
@@ -106,14 +99,14 @@ public sealed class WorkspaceState
return null; return null;
if (User is 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) if (IsCurrentUserGm)
.ToArray(); return SelectedCampaign;
return new(SelectedCampaign.Id, SelectedCampaign.Name, SelectedCampaign.RulesetId, SelectedCampaign.Gm, var ownedCharacters = SelectedCampaign.Characters.Where(character => character.OwnerUserId == User.Id).ToArray();
ownedCharacters);
return new(SelectedCampaign.Id, SelectedCampaign.Name, SelectedCampaign.RulesetId, SelectedCampaign.Gm, ownedCharacters);
} }
} }
@@ -127,18 +120,14 @@ public sealed class WorkspaceState
if (SelectedCharacterId.HasValue) if (SelectedCharacterId.HasValue)
{ {
var selectedCharacter = var selectedCharacter = playSelectedCampaign.Characters.FirstOrDefault(character => character.Id == SelectedCharacterId.Value);
playSelectedCampaign.Characters.FirstOrDefault(character =>
character.Id == SelectedCharacterId.Value);
if (selectedCharacter is not null) if (selectedCharacter is not null)
return selectedCharacter; return selectedCharacter;
} }
if (ActiveCharacterId.HasValue) if (ActiveCharacterId.HasValue)
{ {
var activeCharacter = var activeCharacter = playSelectedCampaign.Characters.FirstOrDefault(character => character.Id == ActiveCharacterId.Value);
playSelectedCampaign.Characters.FirstOrDefault(character =>
character.Id == ActiveCharacterId.Value);
if (activeCharacter is not null) if (activeCharacter is not null)
return activeCharacter; return activeCharacter;
} }
@@ -182,4 +171,9 @@ public sealed class WorkspaceState
"reconnecting" => "warn", "reconnecting" => "warn",
_ => "offline" _ => "offline"
}; };
public string ThemeToggleLabel => ThemePreference == ThemePreferences.Dark ? "⏾" : "☀️";
public string NextThemePreference =>
ThemePreference == ThemePreferences.Dark ? ThemePreferences.Light : ThemePreferences.Dark;
} }

View File

@@ -1,4 +1,4 @@
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
namespace RpgRoller.Contracts; namespace RpgRoller.Contracts;
@@ -10,10 +10,12 @@ public sealed record RegisterRequest(string Username, string Password, string Di
public sealed record LoginRequest(string Username, string Password); public sealed record LoginRequest(string Username, string Password);
public sealed record UserSummary(Guid Id, string Username, string DisplayName, IReadOnlyList<string> Roles); public sealed record UserSummary(Guid Id, string Username, string DisplayName, IReadOnlyList<string> Roles, string? ThemePreference = null);
public sealed record MeResponse(UserSummary User, Guid? ActiveCharacterId, Guid? CurrentCampaignId); public sealed record MeResponse(UserSummary User, Guid? ActiveCharacterId, Guid? CurrentCampaignId);
public sealed record UpdateThemePreferenceRequest(string ThemePreference);
public sealed record AdminUserSummary(Guid Id, string Username, string DisplayName, IReadOnlyList<string> Roles); public sealed record AdminUserSummary(Guid Id, string Username, string DisplayName, IReadOnlyList<string> Roles);
public sealed record UpdateUserRolesRequest(IReadOnlyList<string> Roles); public sealed record UpdateUserRolesRequest(IReadOnlyList<string> Roles);

View File

@@ -1,4 +1,4 @@
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
namespace RpgRoller.Contracts; namespace RpgRoller.Contracts;
@@ -52,6 +52,7 @@ namespace RpgRoller.Contracts;
[JsonSerializable(typeof(UpdateCharacterRequest))] [JsonSerializable(typeof(UpdateCharacterRequest))]
[JsonSerializable(typeof(UpdateSkillGroupRequest))] [JsonSerializable(typeof(UpdateSkillGroupRequest))]
[JsonSerializable(typeof(UpdateSkillRequest))] [JsonSerializable(typeof(UpdateSkillRequest))]
[JsonSerializable(typeof(UpdateThemePreferenceRequest))]
[JsonSerializable(typeof(UpdateUserRolesRequest))] [JsonSerializable(typeof(UpdateUserRolesRequest))]
[JsonSerializable(typeof(UserSummary))] [JsonSerializable(typeof(UserSummary))]
public partial class RpgRollerJsonSerializerContext : JsonSerializerContext public partial class RpgRollerJsonSerializerContext : JsonSerializerContext

View File

@@ -1,4 +1,4 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using RpgRoller.Domain; using RpgRoller.Domain;
namespace RpgRoller.Data; namespace RpgRoller.Data;
@@ -15,6 +15,7 @@ public sealed class RpgRollerDbContext(DbContextOptions<RpgRollerDbContext> opti
entity.Property(x => x.PasswordHash).IsRequired(); entity.Property(x => x.PasswordHash).IsRequired();
entity.Property(x => x.DisplayName).IsRequired().HasMaxLength(128); entity.Property(x => x.DisplayName).IsRequired().HasMaxLength(128);
entity.Property(x => x.Roles).IsRequired().HasMaxLength(256); entity.Property(x => x.Roles).IsRequired().HasMaxLength(256);
entity.Property(x => x.ThemePreference).IsRequired(false).HasMaxLength(16);
entity.HasIndex(x => x.UsernameNormalized).IsUnique(); entity.HasIndex(x => x.UsernameNormalized).IsUnique();
}); });

View File

@@ -1,4 +1,4 @@
namespace RpgRoller.Domain; namespace RpgRoller.Domain;
public enum RulesetKind public enum RulesetKind
{ {
@@ -22,6 +22,7 @@ public sealed class UserAccount
public required string DisplayName { get; set; } public required string DisplayName { get; set; }
public required string Roles { get; set; } public required string Roles { get; set; }
public Guid? ActiveCharacterId { get; set; } public Guid? ActiveCharacterId { get; set; }
public string? ThemePreference { get; set; }
} }
public static class UserRoles public static class UserRoles

View File

@@ -0,0 +1,17 @@
namespace RpgRoller.Domain;
public static class ThemePreferences
{
public const string Light = "light";
public const string Dark = "dark";
public static bool IsSupported(string? value)
{
return string.Equals(value, Light, StringComparison.OrdinalIgnoreCase) || string.Equals(value, Dark, StringComparison.OrdinalIgnoreCase);
}
public static string Normalize(string value)
{
return string.Equals(value, Dark, StringComparison.OrdinalIgnoreCase) ? Dark : Light;
}
}

View File

@@ -0,0 +1,273 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using RpgRoller.Data;
#nullable disable
namespace RpgRoller.Migrations
{
[DbContext(typeof(RpgRollerDbContext))]
[Migration("20260518183838_AddUserThemePreference")]
partial class AddUserThemePreference
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.2");
modelBuilder.Entity("RpgRoller.Domain.Campaign", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("GmUserId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Ruleset")
.IsRequired()
.HasColumnType("TEXT");
b.Property<long>("Version")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("GmUserId");
b.ToTable("Campaigns");
});
modelBuilder.Entity("RpgRoller.Domain.Character", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid?>("CampaignId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<Guid>("OwnerUserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CampaignId");
b.HasIndex("OwnerUserId");
b.ToTable("Characters");
});
modelBuilder.Entity("RpgRoller.Domain.RollLogEntry", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Breakdown")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<Guid>("CampaignId")
.HasColumnType("TEXT");
b.Property<Guid>("CharacterId")
.HasColumnType("TEXT");
b.Property<string>("Dice")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Result")
.HasColumnType("INTEGER");
b.Property<Guid>("RollerUserId")
.HasColumnType("TEXT");
b.Property<Guid>("SkillId")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("TimestampUtc")
.HasColumnType("TEXT");
b.Property<string>("Visibility")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CampaignId");
b.HasIndex("CharacterId");
b.HasIndex("RollerUserId");
b.HasIndex("SkillId");
b.ToTable("RollLogEntries");
});
modelBuilder.Entity("RpgRoller.Domain.Skill", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<bool>("AllowFumble")
.HasColumnType("INTEGER");
b.Property<Guid>("CharacterId")
.HasColumnType("TEXT");
b.Property<string>("DiceRollDefinition")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int?>("FumbleRange")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<bool>("RolemasterAutoRetry")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<Guid?>("SkillGroupId")
.HasColumnType("TEXT");
b.Property<int>("WildDice")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CharacterId");
b.HasIndex("SkillGroupId");
b.ToTable("Skills");
});
modelBuilder.Entity("RpgRoller.Domain.SkillGroup", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<bool>("AllowFumble")
.HasColumnType("INTEGER");
b.Property<Guid>("CharacterId")
.HasColumnType("TEXT");
b.Property<string>("DiceRollDefinition")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int?>("FumbleRange")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("WildDice")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CharacterId");
b.ToTable("SkillGroups");
});
modelBuilder.Entity("RpgRoller.Domain.UserAccount", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid?>("ActiveCharacterId")
.HasColumnType("TEXT");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Roles")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("ThemePreference")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("UsernameNormalized")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UsernameNormalized")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("RpgRoller.Domain.UserSession", b =>
{
b.Property<string>("Token")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Token");
b.HasIndex("UserId");
b.ToTable("Sessions");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace RpgRoller.Migrations
{
/// <inheritdoc />
public partial class AddUserThemePreference : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(name: "ThemePreference", table: "Users", type: "TEXT", maxLength: 16, nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "ThemePreference", table: "Users");
}
}
}

View File

@@ -224,6 +224,10 @@ namespace RpgRoller.Migrations
.HasMaxLength(256) .HasMaxLength(256)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("ThemePreference")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("Username") b.Property<string>("Username")
.IsRequired() .IsRequired()
.HasMaxLength(64) .HasMaxLength(64)

View File

@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.ResponseCompression; using Microsoft.AspNetCore.ResponseCompression;
using Microsoft.AspNetCore.HttpOverrides;
using RpgRoller.Api; using RpgRoller.Api;
using RpgRoller.Components; using RpgRoller.Components;
using RpgRoller.Contracts; using RpgRoller.Contracts;
@@ -7,6 +8,12 @@ using RpgRoller.Hosting;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRpgRollerCore(builder.Configuration, builder.Environment); builder.Services.AddRpgRollerCore(builder.Configuration, builder.Environment);
builder.Services.AddRazorComponents().AddInteractiveServerComponents(); builder.Services.AddRazorComponents().AddInteractiveServerComponents();
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
options.KnownIPNetworks.Clear();
options.KnownProxies.Clear();
});
builder.Services.AddResponseCompression(options => builder.Services.AddResponseCompression(options =>
{ {
options.EnableForHttps = true; options.EnableForHttps = true;
@@ -18,6 +25,7 @@ builder.Services.AddScoped<WorkspaceQueryService>();
var app = builder.Build(); var app = builder.Build();
app.InitializeRpgRollerState(); app.InitializeRpgRollerState();
app.UseForwardedHeaders();
var configuredPathBase = builder.Configuration["PathBase"]; var configuredPathBase = builder.Configuration["PathBase"];
if (!string.IsNullOrWhiteSpace(configuredPathBase)) if (!string.IsNullOrWhiteSpace(configuredPathBase))

View File

@@ -1,4 +1,4 @@
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using RpgRoller.Contracts; using RpgRoller.Contracts;
using RpgRoller.Domain; using RpgRoller.Domain;
@@ -32,7 +32,8 @@ public sealed class GameAuthService(GameStateStore stateStore, IPasswordHasher<U
DisplayName = displayName.Trim(), DisplayName = displayName.Trim(),
PasswordHash = string.Empty, PasswordHash = string.Empty,
Roles = stateStore.UsersById.Count == 0 ? UserRoles.Admin : string.Empty, Roles = stateStore.UsersById.Count == 0 ? UserRoles.Admin : string.Empty,
ActiveCharacterId = null ActiveCharacterId = null,
ThemePreference = null
}; };
user.PasswordHash = passwordHasher.HashPassword(user, password); user.PasswordHash = passwordHasher.HashPassword(user, password);
@@ -112,6 +113,23 @@ public sealed class GameAuthService(GameStateStore stateStore, IPasswordHasher<U
} }
} }
public ServiceResult<UserSummary> UpdateThemePreference(string sessionToken, string themePreference)
{
lock (stateStore.Gate)
{
var user = GameContextResolver.ResolveUserLocked(stateStore, sessionToken);
if (user is null)
return ServiceResult<UserSummary>.Failure("unauthorized", "You must be logged in.");
if (!ThemePreferences.IsSupported(themePreference))
return ServiceResult<UserSummary>.Failure("invalid_theme_preference", "Theme preference must be light or dark.");
user.ThemePreference = ThemePreferences.Normalize(themePreference);
persistenceService.PersistStateLocked();
return ServiceResult<UserSummary>.Success(GameDtoMapper.ToUserSummary(user));
}
}
private UserSession CreateSession(Guid userId) private UserSession CreateSession(Guid userId)
{ {
var token = Guid.NewGuid().ToString("N"); var token = Guid.NewGuid().ToString("N");

View File

@@ -36,7 +36,8 @@ public sealed class GameCharacterService(GameStateStore stateStore, GamePersiste
} }
} }
public ServiceResult<CharacterSummary> UpdateCharacter(string sessionToken, Guid characterId, string name, Guid? campaignId, string? ownerUsername = null) public ServiceResult<CharacterSummary> UpdateCharacter(string sessionToken, Guid characterId, string name,
Guid? campaignId, string? ownerUsername = null)
{ {
if (string.IsNullOrWhiteSpace(name)) if (string.IsNullOrWhiteSpace(name))
return ServiceResult<CharacterSummary>.Failure("invalid_character_name", "Character name is required."); return ServiceResult<CharacterSummary>.Failure("invalid_character_name", "Character name is required.");
@@ -56,10 +57,13 @@ public sealed class GameCharacterService(GameStateStore stateStore, GamePersiste
var isOwner = character.OwnerUserId == user.Id; var isOwner = character.OwnerUserId == user.Id;
var isAdmin = GameAuthorization.HasRole(user, UserRoles.Admin); var isAdmin = GameAuthorization.HasRole(user, UserRoles.Admin);
var isSourceGm = character.CampaignId.HasValue && stateStore.CampaignsById.TryGetValue(character.CampaignId.Value, out var sourceCampaign) && sourceCampaign.GmUserId == user.Id; var isSourceGm = character.CampaignId.HasValue &&
stateStore.CampaignsById.TryGetValue(character.CampaignId.Value, out var sourceCampaign) &&
sourceCampaign.GmUserId == user.Id;
var isTargetGm = targetCampaign is not null && targetCampaign.GmUserId == user.Id; var isTargetGm = targetCampaign is not null && targetCampaign.GmUserId == user.Id;
if (!isOwner && !isAdmin && !isSourceGm && !isTargetGm) if (!isOwner && !isAdmin && !isSourceGm && !isTargetGm)
return ServiceResult<CharacterSummary>.Failure("forbidden", "Only the owner, GM, or admin can edit this character."); return ServiceResult<CharacterSummary>.Failure("forbidden",
"Only the owner, GM, or admin can edit this character.");
var sourceCampaignId = character.CampaignId; var sourceCampaignId = character.CampaignId;
var previousOwnerUserId = character.OwnerUserId; var previousOwnerUserId = character.OwnerUserId;
@@ -74,10 +78,13 @@ public sealed class GameCharacterService(GameStateStore stateStore, GamePersiste
return ServiceResult<CharacterSummary>.Failure("owner_not_found", "Owner username was not found."); return ServiceResult<CharacterSummary>.Failure("owner_not_found", "Owner username was not found.");
if (targetOwnerUserId != character.OwnerUserId && !isAdmin && !isSourceGm && !isTargetGm) if (targetOwnerUserId != character.OwnerUserId && !isAdmin && !isSourceGm && !isTargetGm)
return ServiceResult<CharacterSummary>.Failure("forbidden", "Only the GM or admin can change character owner."); return ServiceResult<CharacterSummary>.Failure("forbidden",
"Only the GM or admin can change character owner.");
character.OwnerUserId = targetOwnerUserId; character.OwnerUserId = targetOwnerUserId;
if (character.OwnerUserId != previousOwnerUserId && stateStore.UsersById.TryGetValue(previousOwnerUserId, out var previousOwner) && previousOwner.ActiveCharacterId == character.Id) if (character.OwnerUserId != previousOwnerUserId &&
stateStore.UsersById.TryGetValue(previousOwnerUserId, out var previousOwner) &&
previousOwner.ActiveCharacterId == character.Id)
previousOwner.ActiveCharacterId = null; previousOwner.ActiveCharacterId = null;
} }
@@ -130,7 +137,15 @@ public sealed class GameCharacterService(GameStateStore stateStore, GamePersiste
return ServiceResult<bool>.Failure("character_not_found", "Character was not found."); return ServiceResult<bool>.Failure("character_not_found", "Character was not found.");
if (character.OwnerUserId != user.Id) if (character.OwnerUserId != user.Id)
return ServiceResult<bool>.Failure("forbidden", "You can activate only your own character."); {
if (!GameContextResolver.TryResolveCharacterCampaignLocked(stateStore, character, out var campaign,
out var campaignError))
return ServiceResult<bool>.Failure(campaignError!.Code, campaignError.Message);
if (campaign!.GmUserId != user.Id)
return ServiceResult<bool>.Failure("forbidden",
"You can activate only your own character unless you GM its campaign.");
}
user.ActiveCharacterId = character.Id; user.ActiveCharacterId = character.Id;
persistenceService.PersistStateLocked(); persistenceService.PersistStateLocked();
@@ -146,7 +161,9 @@ public sealed class GameCharacterService(GameStateStore stateStore, GamePersiste
if (user is null) if (user is null)
return ServiceResult<IReadOnlyList<CharacterSummary>>.Failure("unauthorized", "You must be logged in."); return ServiceResult<IReadOnlyList<CharacterSummary>>.Failure("unauthorized", "You must be logged in.");
var characters = stateStore.CharactersById.Values.Where(character => character.OwnerUserId == user.Id).OrderBy(character => character.Name, StringComparer.OrdinalIgnoreCase).Select(character => GameDtoMapper.ToCharacterSummary(stateStore, character)).ToArray(); var characters = stateStore.CharactersById.Values.Where(character => character.OwnerUserId == user.Id)
.OrderBy(character => character.Name, StringComparer.OrdinalIgnoreCase)
.Select(character => GameDtoMapper.ToCharacterSummary(stateStore, character)).ToArray();
return ServiceResult<IReadOnlyList<CharacterSummary>>.Success(characters); return ServiceResult<IReadOnlyList<CharacterSummary>>.Success(characters);
} }
@@ -160,11 +177,13 @@ public sealed class GameCharacterService(GameStateStore stateStore, GamePersiste
var campaignId = character.CampaignId; var campaignId = character.CampaignId;
stateStore.CharactersById.Remove(characterId); stateStore.CharactersById.Remove(characterId);
var skillGroupIds = stateStore.SkillGroupsById.Values.Where(group => group.CharacterId == characterId).Select(group => group.Id).ToHashSet(); var skillGroupIds = stateStore.SkillGroupsById.Values.Where(group => group.CharacterId == characterId)
.Select(group => group.Id).ToHashSet();
foreach (var skillGroupId in skillGroupIds) foreach (var skillGroupId in skillGroupIds)
stateStore.SkillGroupsById.Remove(skillGroupId); stateStore.SkillGroupsById.Remove(skillGroupId);
var skillIds = stateStore.SkillsById.Values.Where(skill => skill.CharacterId == characterId).Select(skill => skill.Id).ToHashSet(); var skillIds = stateStore.SkillsById.Values.Where(skill => skill.CharacterId == characterId)
.Select(skill => skill.Id).ToHashSet();
foreach (var skillId in skillIds) foreach (var skillId in skillIds)
stateStore.SkillsById.Remove(skillId); stateStore.SkillsById.Remove(skillId);

View File

@@ -1,4 +1,4 @@
using RpgRoller.Contracts; using RpgRoller.Contracts;
using RpgRoller.Domain; using RpgRoller.Domain;
namespace RpgRoller.Services; namespace RpgRoller.Services;
@@ -7,7 +7,7 @@ public static class GameDtoMapper
{ {
public static UserSummary ToUserSummary(UserAccount user) public static UserSummary ToUserSummary(UserAccount user)
{ {
return new(user.Id, user.Username, user.DisplayName, RoleSerializer.Parse(user.Roles)); return new(user.Id, user.Username, user.DisplayName, RoleSerializer.Parse(user.Roles), user.ThemePreference);
} }
public static AdminUserSummary ToAdminUserSummary(UserAccount user) public static AdminUserSummary ToAdminUserSummary(UserAccount user)

View File

@@ -1,4 +1,4 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using RpgRoller.Data; using RpgRoller.Data;
using RpgRoller.Domain; using RpgRoller.Domain;
@@ -40,7 +40,8 @@ public sealed class GamePersistenceService(IDbContextFactory<RpgRollerDbContext>
PasswordHash = user.PasswordHash, PasswordHash = user.PasswordHash,
DisplayName = user.DisplayName, DisplayName = user.DisplayName,
Roles = string.IsNullOrWhiteSpace(user.Roles) ? string.Empty : RoleSerializer.Serialize(RoleSerializer.Parse(user.Roles)), Roles = string.IsNullOrWhiteSpace(user.Roles) ? string.Empty : RoleSerializer.Serialize(RoleSerializer.Parse(user.Roles)),
ActiveCharacterId = user.ActiveCharacterId ActiveCharacterId = user.ActiveCharacterId,
ThemePreference = string.IsNullOrWhiteSpace(user.ThemePreference) ? null : ThemePreferences.Normalize(user.ThemePreference)
}; };
stateStore.UsersById[storedUser.Id] = storedUser; stateStore.UsersById[storedUser.Id] = storedUser;
stateStore.UserIdsByUsername[normalizedUsername] = storedUser.Id; stateStore.UserIdsByUsername[normalizedUsername] = storedUser.Id;

View File

@@ -1,4 +1,4 @@
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using RpgRoller.Contracts; using RpgRoller.Contracts;
using RpgRoller.Data; using RpgRoller.Data;
@@ -55,6 +55,11 @@ public sealed class GameService : IGameService
return m_AuthService.GetMe(sessionToken); return m_AuthService.GetMe(sessionToken);
} }
public ServiceResult<UserSummary> UpdateThemePreference(string sessionToken, string themePreference)
{
return m_AuthService.UpdateThemePreference(sessionToken, themePreference);
}
public ServiceResult<CampaignSummary> CreateCampaign(string sessionToken, string name, string rulesetId) public ServiceResult<CampaignSummary> CreateCampaign(string sessionToken, string name, string rulesetId)
{ {
return m_CampaignService.CreateCampaign(sessionToken, name, rulesetId); return m_CampaignService.CreateCampaign(sessionToken, name, rulesetId);

View File

@@ -1,4 +1,4 @@
using RpgRoller.Domain; using RpgRoller.Domain;
namespace RpgRoller.Services; namespace RpgRoller.Services;
@@ -14,7 +14,8 @@ public static class GameStateCloneFactory
PasswordHash = user.PasswordHash, PasswordHash = user.PasswordHash,
DisplayName = user.DisplayName, DisplayName = user.DisplayName,
Roles = user.Roles, Roles = user.Roles,
ActiveCharacterId = user.ActiveCharacterId ActiveCharacterId = user.ActiveCharacterId,
ThemePreference = user.ThemePreference
}; };
} }

View File

@@ -1,4 +1,4 @@
using RpgRoller.Contracts; using RpgRoller.Contracts;
namespace RpgRoller.Services; namespace RpgRoller.Services;
@@ -11,6 +11,7 @@ public interface IGameService
void Logout(string sessionToken); void Logout(string sessionToken);
UserSummary? GetUserBySession(string sessionToken); UserSummary? GetUserBySession(string sessionToken);
ServiceResult<MeResponse> GetMe(string sessionToken); ServiceResult<MeResponse> GetMe(string sessionToken);
ServiceResult<UserSummary> UpdateThemePreference(string sessionToken, string themePreference);
ServiceResult<CampaignSummary> CreateCampaign(string sessionToken, string name, string rulesetId); ServiceResult<CampaignSummary> CreateCampaign(string sessionToken, string name, string rulesetId);
ServiceResult<IReadOnlyList<CampaignSummary>> GetCampaigns(string sessionToken); ServiceResult<IReadOnlyList<CampaignSummary>> GetCampaigns(string sessionToken);

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

View File

@@ -1,4 +1,4 @@
window.rpgRollerApi = (() => { window.rpgRollerApi = (() => {
const sessionPrefix = "rpgroller."; const sessionPrefix = "rpgroller.";
const stateStream = { const stateStream = {
source: null, source: null,
@@ -22,6 +22,18 @@ window.rpgRollerApi = (() => {
return new URL(relativeUrl, document.baseURI).toString(); return new URL(relativeUrl, document.baseURI).toString();
} }
function normalizeTheme(theme) {
return theme === "dark" ? "dark" : "light";
}
function getSystemTheme() {
return window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
function applyTheme(theme) {
document.documentElement.dataset.theme = normalizeTheme(theme);
}
function clearReconnectTimer() { function clearReconnectTimer() {
if (stateStream.reconnectTimer) { if (stateStream.reconnectTimer) {
clearTimeout(stateStream.reconnectTimer); clearTimeout(stateStream.reconnectTimer);
@@ -385,6 +397,8 @@ window.rpgRollerApi = (() => {
return { return {
request, request,
applyTheme,
getSystemTheme,
getSessionValue, getSessionValue,
setSessionValue, setSessionValue,
startStateEvents, startStateEvents,

View File

@@ -1,8 +1,9 @@
:root { :root {
color-scheme: light;
--bg-top: #f7f0d8; --bg-top: #f7f0d8;
--bg-bottom: #ecdfc4; --bg-bottom: #ecdfc4;
--button-hover: #dccfb4; --button-hover: #dccfb4;
--card: #fffaf0; --card: #fffaf0e0;
--card-border: #c3b28b; --card-border: #c3b28b;
--text: #2b2418; --text: #2b2418;
--muted: #6a5b3f; --muted: #6a5b3f;
@@ -14,6 +15,147 @@
--public: #2d6645; --public: #2d6645;
--private-self: #4f3a8f; --private-self: #4f3a8f;
--private-gm: #915119; --private-gm: #915119;
--page-background: url("/images/light.webp");
--card-strong: #fff8ea;
--accent-dark: #2f4f34;
--accent-2-hover: #6b2419;
--header-bg: linear-gradient(120deg, #f1e4c9, #efe0bf);
--input-bg: #fffdf5;
--input-border: #8e7b57;
--button-text: #f8f7ef;
--switch-active-text: #fff9ef;
--tab-active-bg: linear-gradient(145deg, #e9d4a4, #d7b672);
--tab-active-border: #9e7328;
--section-border: #a89066;
--skill-group-bg: #f8f0de;
--chip-border: #decbb7;
--menu-shadow: rgba(34, 24, 9, 0.2);
--die-border: #2a2418;
--die-bg: #ffffff;
--die-text: #1f1a13;
--die-wild: #c79913;
--die-crit-bg: #d8ffc2;
--die-crit-text: #18490f;
--die-fumble-bg: #ffb5a8;
--die-fumble-text: #661110;
--die-added-bg: #dbffdf;
--die-added-text: #206029;
--die-removed-bg: #fde0dd;
--die-removed-text: #7f5f55;
--die-neutral-bg: #f8f1df;
--die-neutral-text: #3f2f12;
--die-open-high-bg: #dff6df;
--die-open-high-text: #1d5b26;
--die-open-high-border: #2a7c39;
--die-open-low-bg: #ffe1dc;
--die-open-low-text: #8a2217;
--die-open-low-border: #b74334;
--success-bg: #e8f7e8;
--success-border: #78a978;
--success-text: #1f5425;
--error-bg: #ffe9e5;
--error-border: #bb6e62;
--error-text: #7f2015;
--rare-bg: #fff1c7;
--rare-border: #b48b34;
--rare-text: #6d4c05;
--active-bg: #f6d28d;
--active-border: #8f5f12;
--active-text: #5d3808;
--skeleton-bg: linear-gradient(90deg, #dfd2b7, #efe3c9, #dfd2b7);
--health-bg: #fff2db;
--health-border: #b77a29;
--modal-overlay: rgba(35, 25, 9, 0.55);
--mobile-nav-bg: rgba(241, 228, 201, 0.96);
--toast-shadow: rgba(34, 24, 9, 0.22);
--surface-mix: #ffffff;
--transparent-mix: transparent;
--custom-roll-error-bg: #fff0ee;
--custom-roll-error-border: #6b2015;
--custom-roll-error-shadow: rgba(181, 58, 35, 0.12);
--entry-shadow: rgba(60, 41, 12, 0.07);
--entry-shadow-hover: rgba(60, 41, 12, 0.11);
--fresh-shadow: rgba(199, 153, 19, 0.16);
}
:root[data-theme="dark"] {
color-scheme: dark;
--bg-top: #060b13;
--bg-bottom: #0c1726;
--button-hover: rgba(62, 89, 123, 0.72);
--card: rgba(8, 15, 26, 0.84);
--card-border: #37516c;
--text: #edf5ff;
--muted: #adc1d6;
--accent: #5aa0cf;
--accent-2: #f0b35a;
--warn: #f4c35f;
--danger: #ff8b7a;
--focus: #91d5ff;
--public: #78d08f;
--private-self: #b9a0ff;
--private-gm: #f0b16c;
--page-background: url("/images/dark.webp");
--card-strong: rgba(13, 24, 39, 0.96);
--accent-dark: #2d638f;
--accent-2-hover: #ffd085;
--header-bg: linear-gradient(120deg, rgba(10, 18, 31, 0.94), rgba(17, 31, 48, 0.9));
--input-bg: rgba(7, 14, 24, 0.92);
--input-border: #52708f;
--button-text: #f3f8ff;
--switch-active-text: #0b1420;
--tab-active-bg: linear-gradient(145deg, #244967, #17324d);
--tab-active-border: #6ca6d0;
--section-border: #486986;
--skill-group-bg: rgba(19, 34, 52, 0.72);
--chip-border: #415f7b;
--menu-shadow: rgba(0, 0, 0, 0.42);
--die-border: #9cb8d3;
--die-bg: #0f1c2d;
--die-text: #edf5ff;
--die-wild: #ffd770;
--die-crit-bg: #163f2a;
--die-crit-text: #a5f0b5;
--die-fumble-bg: #4b1c20;
--die-fumble-text: #ffc0b8;
--die-added-bg: #163f2a;
--die-added-text: #a5f0b5;
--die-removed-bg: #3b2630;
--die-removed-text: #e0abb7;
--die-neutral-bg: #14283e;
--die-neutral-text: #e5f1ff;
--die-open-high-bg: #133b2b;
--die-open-high-text: #a5f0b5;
--die-open-high-border: #64c783;
--die-open-low-bg: #482025;
--die-open-low-text: #ffc0b8;
--die-open-low-border: #ff8b7a;
--success-bg: #173b29;
--success-border: #66bd7f;
--success-text: #b6f1c3;
--error-bg: #452126;
--error-border: #d46b62;
--error-text: #ffc2ba;
--rare-bg: #3d3218;
--rare-border: #cfae52;
--rare-text: #ffe09a;
--active-bg: #4a3514;
--active-border: #e0b35d;
--active-text: #ffe1a3;
--skeleton-bg: linear-gradient(90deg, #172438, #263b54, #172438);
--health-bg: #3a2d19;
--health-border: #d09b4c;
--modal-overlay: rgba(1, 6, 13, 0.74);
--mobile-nav-bg: rgba(10, 18, 31, 0.96);
--toast-shadow: rgba(0, 0, 0, 0.42);
--surface-mix: #000000;
--transparent-mix: transparent;
--custom-roll-error-bg: #371c21;
--custom-roll-error-border: #ffc0b8;
--custom-roll-error-shadow: rgba(255, 139, 122, 0.18);
--entry-shadow: rgba(0, 0, 0, 0.24);
--entry-shadow-hover: rgba(0, 0, 0, 0.34);
--fresh-shadow: rgba(255, 215, 112, 0.2);
} }
* { * {
@@ -27,9 +169,16 @@ body {
height: 100%; height: 100%;
} }
html {
background-image: var(--page-background);
background-position: center;
background-repeat: no-repeat;
background-size: cover;
background-attachment: fixed;
}
body { body {
background: radial-gradient(circle at 15% 10%, rgba(255, 255, 255, 0.32), transparent 45%), background: transparent;
linear-gradient(165deg, var(--bg-top), var(--bg-bottom));
color: var(--text); color: var(--text);
font-family: font-family:
"Baloo 2", "Baloo 2",
@@ -93,7 +242,7 @@ h3 {
top: 0; top: 0;
z-index: 10; z-index: 10;
display: flex; display: flex;
background: linear-gradient(120deg, #f1e4c9, #efe0bf); background: var(--header-bg);
border: 1px solid var(--card-border); border: 1px solid var(--card-border);
border-radius: 0.8rem; border-radius: 0.8rem;
padding: 0.5rem 0.7rem; padding: 0.5rem 0.7rem;
@@ -113,14 +262,33 @@ h3 {
font-size: 1.15rem; font-size: 1.15rem;
} }
.header-identity, .header-identity {
.header-campaign {
margin: 0; margin: 0;
white-space: nowrap; white-space: nowrap;
} }
.header-campaign { .header-campaign {
display: flex;
align-items: center;
gap: 0.35rem;
color: var(--muted); color: var(--muted);
min-width: 12rem;
white-space: nowrap;
}
.header-campaign label {
font-weight: 700;
}
.header-campaign select {
max-width: 16rem;
min-width: 9rem;
padding: 0.25rem 0.45rem;
}
.header-campaign span {
font-weight: 700;
color: var(--text);
} }
.header-connection-cell { .header-connection-cell {
@@ -139,7 +307,7 @@ h3 {
} }
.card { .card {
background: color-mix(in srgb, var(--card) 94%, #ffffff 6%); background: var(--card);
border: 1px solid var(--card-border); border: 1px solid var(--card-border);
border-radius: 0.8rem; border-radius: 0.8rem;
padding: 0.7rem; padding: 0.7rem;
@@ -149,6 +317,20 @@ h3 {
gap: 0.75rem; gap: 0.75rem;
} }
.campaign-current {
display: grid;
gap: 0.15rem;
}
.campaign-current span,
.campaign-current p {
color: var(--muted);
}
.campaign-current p {
margin: 0;
}
.auth-grid { .auth-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -169,19 +351,19 @@ select,
button { button {
font: inherit; font: inherit;
border-radius: 0.45rem; border-radius: 0.45rem;
border: 1px solid #8e7b57; border: 1px solid var(--input-border);
padding: 0.55rem 0.65rem; padding: 0.55rem 0.65rem;
} }
input, input,
select { select {
background: #fffdf5; background: var(--input-bg);
color: var(--text); color: var(--text);
} }
button { button {
background: linear-gradient(180deg, var(--accent), #2f4f34); background: linear-gradient(180deg, var(--accent), var(--accent-dark));
color: #f8f7ef; color: var(--button-text);
border-color: transparent; border-color: transparent;
cursor: pointer; cursor: pointer;
} }
@@ -189,19 +371,19 @@ button {
button.ghost { button.ghost {
background: transparent; background: transparent;
color: var(--text); color: var(--text);
border-color: #8e7b57; border-color: var(--input-border);
} }
button.switch { button.switch {
background: transparent; background: transparent;
color: var(--text); color: var(--text);
border-color: #8e7b57; border-color: var(--input-border);
} }
button.switch.active { button.switch.active {
background: var(--accent-2); background: var(--accent-2);
border-color: var(--accent-2); border-color: var(--accent-2);
color: #fff9ef; color: var(--switch-active-text);
} }
button:disabled { button:disabled {
@@ -287,12 +469,12 @@ select:focus-visible {
white-space: nowrap; white-space: nowrap;
background: transparent; background: transparent;
color: var(--text); color: var(--text);
border-color: #8e7b57; border-color: var(--input-border);
} }
.icon-tab.active { .icon-tab.active {
background: linear-gradient(145deg, #e9d4a4, #d7b672); background: var(--tab-active-bg);
border-color: #9e7328; border-color: var(--tab-active-border);
} }
.icon-tab-glyph { .icon-tab-glyph {
@@ -302,7 +484,7 @@ select:focus-visible {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border-radius: 50%; border-radius: 50%;
border: 1px solid #8e7b57; border: 1px solid var(--input-border);
font-weight: 700; font-weight: 700;
font-size: 0.72rem; font-size: 0.72rem;
} }
@@ -313,7 +495,7 @@ select:focus-visible {
} }
.skills-section { .skills-section {
border: 1px dashed #a89066; border: 1px dashed var(--section-border);
border-radius: 0.65rem; border-radius: 0.65rem;
padding: 0.55rem; padding: 0.55rem;
display: flex; display: flex;
@@ -374,8 +556,9 @@ select:focus-visible {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
background-color: #f8f0de; background-color: var(--skill-group-bg);
padding-left: 0.1rem; padding: 0.1rem;
border-top: 1px solid var(--card-border);
gap: 0.5rem; gap: 0.5rem;
} }
@@ -416,11 +599,11 @@ select:focus-visible {
border-radius: 999px; border-radius: 999px;
background: transparent; background: transparent;
color: var(--text); color: var(--text);
border-color: #decbb7; border-color: var(--chip-border);
} }
.chip-button:hover { .chip-button:hover {
border-color: #8e7b57; border-color: var(--input-border);
background: var(--button-hover); background: var(--button-hover);
} }
@@ -428,13 +611,13 @@ select:focus-visible {
grid-template-columns: auto 1fr; grid-template-columns: auto 1fr;
align-items: center; align-items: center;
justify-items: start; justify-items: start;
background: #00000000; background: transparent;
} }
.skill-create-icon { .skill-create-icon {
width: 1.45rem; width: 1.45rem;
height: 1.45rem; height: 1.45rem;
border: 1px solid #8e7b57; border: 1px solid var(--input-border);
border-radius: 999px; border-radius: 999px;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -479,7 +662,7 @@ select:focus-visible {
.menu-toggle { .menu-toggle {
background: transparent; background: transparent;
color: var(--text); color: var(--text);
border-color: #8e7b57; border-color: var(--input-border);
display: inline-flex; display: inline-flex;
gap: 0.4rem; gap: 0.4rem;
align-items: center; align-items: center;
@@ -496,12 +679,12 @@ select:focus-visible {
z-index: 40; z-index: 40;
min-width: 14.5rem; min-width: 14.5rem;
padding: 0.35rem; padding: 0.35rem;
background: #fff8ea; background: var(--card-strong);
border: 1px solid var(--card-border); border: 1px solid var(--card-border);
border-radius: 0.55rem; border-radius: 0.55rem;
display: grid; display: grid;
gap: 0.3rem; gap: 0.3rem;
box-shadow: 0 8px 16px rgba(34, 24, 9, 0.2); box-shadow: 0 8px 16px var(--menu-shadow);
} }
.menu-item { .menu-item {
@@ -509,12 +692,12 @@ select:focus-visible {
text-align: left; text-align: left;
background: transparent; background: transparent;
color: var(--text); color: var(--text);
border-color: #8e7b57; border-color: var(--input-border);
} }
.menu-item.active { .menu-item.active {
background: #ecd8ae; background: var(--button-hover);
border-color: #9a7f43; border-color: var(--tab-active-border);
} }
.logout-link { .logout-link {
@@ -525,7 +708,7 @@ select:focus-visible {
} }
.logout-link:hover { .logout-link:hover {
color: #6b2419; color: var(--accent-2-hover);
} }
.logout-link:focus-visible { .logout-link:focus-visible {
@@ -533,6 +716,22 @@ select:focus-visible {
outline-offset: 2px; outline-offset: 2px;
} }
.theme-toggle {
width: 2.25rem;
height: 2.25rem;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
background: transparent;
color: var(--text);
border-color: var(--input-border);
}
.theme-toggle:hover {
background: var(--button-hover);
}
.roll-total { .roll-total {
font-size: 1.8rem; font-size: 1.8rem;
font-weight: 800; font-weight: 800;
@@ -556,10 +755,10 @@ select:focus-visible {
min-width: 2.1rem; min-width: 2.1rem;
height: 2.1rem; height: 2.1rem;
padding: 0.2rem 0.45rem 0; padding: 0.2rem 0.45rem 0;
border: 2px solid #2a2418; border: 2px solid var(--die-border);
border-radius: 0.45rem; border-radius: 0.45rem;
background: #ffffff; background: var(--die-bg);
color: #1f1a13; color: var(--die-text);
font-size: 2rem; font-size: 2rem;
line-height: 1; line-height: 1;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
@@ -567,27 +766,27 @@ select:focus-visible {
.die-chip.wild { .die-chip.wild {
border-width: 3px; border-width: 3px;
border-color: #c79913; border-color: var(--die-wild);
} }
.die-chip.crit { .die-chip.crit {
background: #d8ffc2; background: var(--die-crit-bg);
color: #18490f; color: var(--die-crit-text);
} }
.die-chip.fumble { .die-chip.fumble {
background: #ffb5a8; background: var(--die-fumble-bg);
color: #661110; color: var(--die-fumble-text);
} }
.die-chip.added { .die-chip.added {
background: #dbffdf; background: var(--die-added-bg);
color: #206029; color: var(--die-added-text);
} }
.die-chip.removed { .die-chip.removed {
background: #fde0dd; background: var(--die-removed-bg);
color: #7f5f55; color: var(--die-removed-text);
border-style: dashed; border-style: dashed;
} }
@@ -605,20 +804,20 @@ select:focus-visible {
.die-chip.rolemaster-initiative, .die-chip.rolemaster-initiative,
.die-chip.rolemaster-percentile, .die-chip.rolemaster-percentile,
.die-chip.rolemaster-open-ended-initial { .die-chip.rolemaster-open-ended-initial {
background: #f8f1df; background: var(--die-neutral-bg);
color: #3f2f12; color: var(--die-neutral-text);
} }
.die-chip.rolemaster-open-ended-high { .die-chip.rolemaster-open-ended-high {
background: #dff6df; background: var(--die-open-high-bg);
color: #1d5b26; color: var(--die-open-high-text);
border-color: #2a7c39; border-color: var(--die-open-high-border);
} }
.die-chip.rolemaster-open-ended-low-subtract { .die-chip.rolemaster-open-ended-low-subtract {
background: #ffe1dc; background: var(--die-open-low-bg);
color: #8a2217; color: var(--die-open-low-text);
border-color: #b74334; border-color: var(--die-open-low-border);
} }
.empty, .empty,
@@ -635,11 +834,11 @@ select:focus-visible {
} }
.custom-roll-panel { .custom-roll-panel {
border-top: 1px solid color-mix(in srgb, var(--card-border) 72%, #ffffff 28%); border-top: 1px solid color-mix(in srgb, var(--card-border) 72%, var(--surface-mix) 28%);
background: color-mix(in srgb, var(--card) 88%, #ffffff 12%); background: color-mix(in srgb, var(--card) 88%, var(--surface-mix) 12%);
border-radius: 0.95rem; border-radius: 0.95rem;
padding: 0.85rem 0.9rem 0.9rem; padding: 0.85rem 0.9rem 0.9rem;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.45); box-shadow: inset 0 1px 0 color-mix(in srgb, var(--surface-mix) 45%, transparent 55%);
} }
.custom-roll-composer { .custom-roll-composer {
@@ -671,14 +870,14 @@ select:focus-visible {
min-width: 0; min-width: 0;
padding: 0.72rem 0.9rem; padding: 0.72rem 0.9rem;
border-radius: 999px; border-radius: 999px;
border: 1px solid color-mix(in srgb, var(--card-border) 78%, #ffffff 22%); border: 1px solid color-mix(in srgb, var(--card-border) 78%, var(--surface-mix) 22%);
background: color-mix(in srgb, var(--card) 90%, #ffffff 10%); background: color-mix(in srgb, var(--card) 90%, var(--surface-mix) 10%);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6); box-shadow: inset 0 1px 0 color-mix(in srgb, var(--surface-mix) 60%, transparent 40%);
transition: border-color 180ms ease, box-shadow 180ms ease, background-color 180ms ease; transition: border-color 180ms ease, box-shadow 180ms ease, background-color 180ms ease;
} }
.custom-roll-input::placeholder { .custom-roll-input::placeholder {
color: color-mix(in srgb, var(--muted) 80%, #ffffff 20%); color: color-mix(in srgb, var(--muted) 80%, var(--surface-mix) 20%);
} }
.custom-roll-input:hover:not(:disabled) { .custom-roll-input:hover:not(:disabled) {
@@ -686,9 +885,9 @@ select:focus-visible {
} }
.custom-roll-input.error { .custom-roll-input.error {
border-color: color-mix(in srgb, var(--danger) 74%, #6b2015 26%); border-color: color-mix(in srgb, var(--danger) 74%, var(--custom-roll-error-border) 26%);
background: color-mix(in srgb, #fff0ee 84%, var(--card) 16%); background: color-mix(in srgb, var(--custom-roll-error-bg) 84%, var(--card) 16%);
box-shadow: 0 0 0 3px rgba(181, 58, 35, 0.12); box-shadow: 0 0 0 3px var(--custom-roll-error-shadow);
} }
.custom-roll-composer-row button { .custom-roll-composer-row button {
@@ -698,11 +897,11 @@ select:focus-visible {
} }
.log-entry { .log-entry {
border: 1px solid color-mix(in srgb, var(--card-border) 84%, #ffffff 16%); border: 1px solid color-mix(in srgb, var(--card-border) 84%, var(--surface-mix) 16%);
border-radius: 0.85rem; border-radius: 0.85rem;
background: color-mix(in srgb, var(--card) 96%, #ffffff 4%); background: color-mix(in srgb, var(--card) 96%, var(--surface-mix) 4%);
overflow: hidden; overflow: hidden;
box-shadow: 0 0.45rem 1.2rem rgba(60, 41, 12, 0.07); box-shadow: 0 0.45rem 1.2rem var(--entry-shadow);
transition: border-color 180ms ease, box-shadow 180ms ease, transform 180ms ease; transition: border-color 180ms ease, box-shadow 180ms ease, transform 180ms ease;
} }
@@ -721,23 +920,23 @@ select:focus-visible {
.log-entry:hover { .log-entry:hover {
border-color: color-mix(in srgb, var(--accent) 28%, var(--card-border) 72%); border-color: color-mix(in srgb, var(--accent) 28%, var(--card-border) 72%);
box-shadow: 0 0.7rem 1.55rem rgba(60, 41, 12, 0.11); box-shadow: 0 0.7rem 1.55rem var(--entry-shadow-hover);
} }
.log-entry.private-self { .log-entry.private-self {
border-left: 0.35rem solid color-mix(in srgb, var(--private-self) 78%, #ffffff 22%); border-left: 0.35rem solid color-mix(in srgb, var(--private-self) 78%, var(--surface-mix) 22%);
} }
.log-entry.private-gm { .log-entry.private-gm {
border-left: 0.35rem solid color-mix(in srgb, var(--private-gm) 78%, #ffffff 22%); border-left: 0.35rem solid color-mix(in srgb, var(--private-gm) 78%, var(--surface-mix) 22%);
} }
.log-entry.public { .log-entry.public {
border-left: 0.35rem solid color-mix(in srgb, var(--public) 70%, #ffffff 30%); border-left: 0.35rem solid color-mix(in srgb, var(--public) 70%, var(--surface-mix) 30%);
} }
.log-entry.private-generic { .log-entry.private-generic {
border-left: 0.35rem solid color-mix(in srgb, var(--muted) 52%, #ffffff 48%); border-left: 0.35rem solid color-mix(in srgb, var(--muted) 52%, var(--surface-mix) 48%);
} }
.log-entry.expanded { .log-entry.expanded {
@@ -745,12 +944,12 @@ select:focus-visible {
} }
.log-entry.fresh { .log-entry.fresh {
border-color: color-mix(in srgb, #c79913 52%, var(--card-border) 48%); border-color: color-mix(in srgb, var(--die-wild) 52%, var(--card-border) 48%);
box-shadow: 0 0.9rem 1.8rem rgba(199, 153, 19, 0.16); box-shadow: 0 0.9rem 1.8rem var(--fresh-shadow);
} }
.log-entry-toggle:hover { .log-entry-toggle:hover {
background: color-mix(in srgb, var(--card) 84%, #ffffff 16%); background: color-mix(in srgb, var(--card) 84%, var(--surface-mix) 16%);
} }
.log-entry-toggle:focus-visible { .log-entry-toggle:focus-visible {
@@ -814,21 +1013,21 @@ select:focus-visible {
} }
.log-event-badge.positive { .log-event-badge.positive {
border-color: #79a85d; border-color: var(--success-border);
background: #e7f6da; background: var(--success-bg);
color: #235217; color: var(--success-text);
} }
.log-event-badge.danger { .log-event-badge.danger {
border-color: #c56b5a; border-color: var(--error-border);
background: #ffe3dc; background: var(--error-bg);
color: #7d1f17; color: var(--error-text);
} }
.log-event-badge.rare { .log-event-badge.rare {
border-color: #b48b34; border-color: var(--rare-border);
background: #fff1c7; background: var(--rare-bg);
color: #6d4c05; color: var(--rare-text);
} }
.log-meta { .log-meta {
@@ -844,7 +1043,7 @@ select:focus-visible {
margin: 0 0.65rem 0.65rem; margin: 0 0.65rem 0.65rem;
padding: 0.7rem 0.8rem 0.75rem; padding: 0.7rem 0.8rem 0.75rem;
border-top: 1px solid color-mix(in srgb, var(--card-border) 38%, transparent 62%); border-top: 1px solid color-mix(in srgb, var(--card-border) 38%, transparent 62%);
background: color-mix(in srgb, #ffffff 42%, var(--card) 58%); background: color-mix(in srgb, var(--surface-mix) 42%, var(--card) 58%);
border-radius: 0.7rem; border-radius: 0.7rem;
} }
@@ -863,31 +1062,31 @@ select:focus-visible {
} }
.badge.active { .badge.active {
border-color: #8f5f12; border-color: var(--active-border);
background: #f6d28d; background: var(--active-bg);
color: #5d3808; color: var(--active-text);
} }
.badge.public { .badge.public {
background: color-mix(in srgb, var(--public) 14%, #ffffff 86%); background: color-mix(in srgb, var(--public) 14%, var(--surface-mix) 86%);
color: var(--public); color: var(--public);
border-color: color-mix(in srgb, var(--public) 34%, transparent 66%); border-color: color-mix(in srgb, var(--public) 34%, transparent 66%);
} }
.badge.private-self { .badge.private-self {
background: color-mix(in srgb, var(--private-self) 12%, #ffffff 88%); background: color-mix(in srgb, var(--private-self) 12%, var(--surface-mix) 88%);
color: var(--private-self); color: var(--private-self);
border-color: color-mix(in srgb, var(--private-self) 30%, transparent 70%); border-color: color-mix(in srgb, var(--private-self) 30%, transparent 70%);
} }
.badge.private-gm { .badge.private-gm {
background: color-mix(in srgb, var(--private-gm) 12%, #ffffff 88%); background: color-mix(in srgb, var(--private-gm) 12%, var(--surface-mix) 88%);
color: var(--private-gm); color: var(--private-gm);
border-color: color-mix(in srgb, var(--private-gm) 30%, transparent 70%); border-color: color-mix(in srgb, var(--private-gm) 30%, transparent 70%);
} }
.badge.private-generic { .badge.private-generic {
background: color-mix(in srgb, var(--muted) 12%, #ffffff 88%); background: color-mix(in srgb, var(--muted) 12%, var(--surface-mix) 88%);
color: var(--muted); color: var(--muted);
border-color: color-mix(in srgb, var(--muted) 28%, transparent 72%); border-color: color-mix(in srgb, var(--muted) 28%, transparent 72%);
} }
@@ -952,7 +1151,7 @@ select:focus-visible {
.skeleton-line { .skeleton-line {
height: 0.85rem; height: 0.85rem;
border-radius: 0.4rem; border-radius: 0.4rem;
background: linear-gradient(90deg, #dfd2b7, #efe3c9, #dfd2b7); background: var(--skeleton-bg);
background-size: 220% 100%; background-size: 220% 100%;
animation: shimmer 1.1s linear infinite; animation: shimmer 1.1s linear infinite;
} }
@@ -962,8 +1161,8 @@ select:focus-visible {
} }
.health-banner { .health-banner {
border: 1px solid #b77a29; border: 1px solid var(--health-border);
background: #fff2db; background: var(--health-bg);
border-radius: 0.75rem; border-radius: 0.75rem;
padding: 0.75rem; padding: 0.75rem;
display: flex; display: flex;
@@ -985,7 +1184,7 @@ select:focus-visible {
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
border: 1px solid #b8a37b; border: 1px solid var(--card-border);
border-radius: 0.55rem; border-radius: 0.55rem;
padding: 0.5rem; padding: 0.5rem;
} }
@@ -1006,9 +1205,9 @@ select:focus-visible {
gap: 0.45rem; gap: 0.45rem;
align-self: flex-start; align-self: flex-start;
padding: 0.55rem 0.65rem; padding: 0.55rem 0.65rem;
border: 1px solid #b39f79; border: 1px solid var(--card-border);
border-radius: 0.45rem; border-radius: 0.45rem;
background: #f9f2e2; background: var(--input-bg);
color: var(--text); color: var(--text);
font-weight: 700; font-weight: 700;
text-decoration: none; text-decoration: none;
@@ -1028,15 +1227,15 @@ select:focus-visible {
align-items: center; align-items: center;
gap: 0.45rem; gap: 0.45rem;
align-self: flex-start; align-self: flex-start;
background: #f9f2e2; background: var(--input-bg);
color: var(--text); color: var(--text);
border: 1px solid #b39f79; border: 1px solid var(--card-border);
} }
.add-row-icon { .add-row-icon {
width: 1.2rem; width: 1.2rem;
height: 1.2rem; height: 1.2rem;
border: 1px solid #8e7b57; border: 1px solid var(--input-border);
border-radius: 999px; border-radius: 999px;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -1047,7 +1246,7 @@ select:focus-visible {
.modal-overlay { .modal-overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
background: rgba(35, 25, 9, 0.55); background: var(--modal-overlay);
display: grid; display: grid;
place-items: center; place-items: center;
z-index: 20; z-index: 20;
@@ -1077,7 +1276,7 @@ select:focus-visible {
padding: 0.55rem; padding: 0.55rem;
display: none; display: none;
gap: 0.45rem; gap: 0.45rem;
background: rgba(241, 228, 201, 0.96); background: var(--mobile-nav-bg);
border-top: 1px solid var(--card-border); border-top: 1px solid var(--card-border);
} }
@@ -1095,7 +1294,7 @@ select:focus-visible {
border-radius: 0.6rem; border-radius: 0.6rem;
border: 1px solid; border: 1px solid;
padding: 0.55rem 0.7rem; padding: 0.55rem 0.7rem;
box-shadow: 0 6px 14px rgba(34, 24, 9, 0.22); box-shadow: 0 6px 14px var(--toast-shadow);
backdrop-filter: blur(4px); backdrop-filter: blur(4px);
} }
@@ -1105,15 +1304,15 @@ select:focus-visible {
} }
.toast.success { .toast.success {
background: #e8f7e8; background: var(--success-bg);
border-color: #78a978; border-color: var(--success-border);
color: #1f5425; color: var(--success-text);
} }
.toast.error { .toast.error {
background: #ffe9e5; background: var(--error-bg);
border-color: #bb6e62; border-color: var(--error-border);
color: #7f2015; color: var(--error-text);
} }
.sr-only { .sr-only {
@@ -1172,6 +1371,15 @@ select:focus-visible {
white-space: normal; white-space: normal;
} }
.header-campaign {
flex-wrap: wrap;
min-width: 0;
}
.header-campaign select {
max-width: 100%;
}
.mobile-bottom-nav { .mobile-bottom-nav {
display: flex; display: flex;
} }

332
TASKS.md
View File

@@ -1,332 +0,0 @@
# 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.
`PLANS.md` is checked into the repository root. This document must be maintained in accordance with `PLANS.md`.
## Purpose / Big Picture
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 `/`.
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-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: 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 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 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: `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: 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: 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: 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: 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: 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: 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: 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: 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
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
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`.
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 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`.
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.
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
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.
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.
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.
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.
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, 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
Run all commands from the repository root, which is `/home/frank/Code/RpgRoller`.
Start by inspecting the current route and auth files before editing:
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
When implementing Milestone 1, update the host test first so the intended redirect behavior is explicit:
dotnet test RpgRoller.Tests/RpgRoller.Tests.csproj --filter FrontendHostTests
Expected direction after the edits:
RootPath_RedirectsToLogin_WhenAnonymous
RootPath_RedirectsToPlay_WhenAuthenticated
LoginPath_ServesStaticAuthMarkup
After wiring `/login` and the root redirect, run the app locally:
dotnet run --project RpgRoller/RpgRoller.csproj
Then verify in a browser:
open http://localhost:5000/
observe: anonymous request lands on /login
submit valid credentials
observe: browser lands on /play
When implementing route pages and navigation, prefer running the focused smoke suite against a temporary database:
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
The final implementation is acceptable only if all of the following behaviors are true and visible.
Anonymous navigation:
`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`.
Authenticated navigation:
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 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.
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
Current evidence that must be retired by this rewrite:
RpgRoller.Tests/Api/FrontendHostTests.cs
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Contains("_framework/blazor.web.js", html);
tests/e2e/smoke.js
browser checks for anonymous `/`, static `/login`, authenticated `/`, and the authenticated workspace flows
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
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.
At the end of Milestone 1, the codebase must contain a host-level entry point with behavior equivalent to:
GET /
if session cookie maps to a valid user: redirect to /play
otherwise: redirect to /login
At the end of Milestone 2, the codebase must contain route components equivalent to:
/play
/campaigns
/admin
Each of those routes must be directly navigable and refreshable.
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:
RpgRoller/Components/Pages/AuthenticatedShell.razor
RpgRoller/Components/Pages/PlayPage.razor
RpgRoller/Components/Pages/CampaignsPage.razor
RpgRoller/Components/Pages/AdminPage.razor
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.
## Revision Note
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.

View File

@@ -1,17 +0,0 @@
param(
[string]$Password,
[switch]$SkipRecycle,
[switch]$SkipMigrations
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$scriptPath = Join-Path $PSScriptRoot "scripts/deploy-ftp1.ps1"
$profilePath = Join-Path $PSScriptRoot "scripts/deploy-ftp.profile.psd1"
& $scriptPath `
-ProfilePath $profilePath `
-Password $Password `
-SkipRecycle:$SkipRecycle `
-SkipMigrations:$SkipMigrations

View File

@@ -1,4 +1,4 @@
{ {
"openapi": "3.0.1", "openapi": "3.0.1",
"info": { "info": {
"title": "RpgRoller API", "title": "RpgRoller API",
@@ -156,6 +156,46 @@
} }
} }
}, },
"/api/me/theme": {
"put": {
"operationId": "updateThemePreference",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateThemePreferenceRequest"
}
}
}
},
"responses": {
"200": {
"description": "Updated current user.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserSummary"
}
}
}
},
"400": {
"description": "Validation error.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"401": {
"description": "Unauthorized."
}
}
}
},
"/api/campaigns": { "/api/campaigns": {
"get": { "get": {
"operationId": "getCampaigns", "operationId": "getCampaigns",
@@ -701,12 +741,27 @@
}, },
"displayName": { "displayName": {
"type": "string" "type": "string"
},
"roles": {
"type": "array",
"items": {
"type": "string"
}
},
"themePreference": {
"type": "string",
"nullable": true,
"enum": [
"light",
"dark"
]
} }
}, },
"required": [ "required": [
"id", "id",
"username", "username",
"displayName" "displayName",
"roles"
] ]
}, },
"MeResponse": { "MeResponse": {
@@ -730,6 +785,21 @@
"user" "user"
] ]
}, },
"UpdateThemePreferenceRequest": {
"type": "object",
"properties": {
"themePreference": {
"type": "string",
"enum": [
"light",
"dark"
]
}
},
"required": [
"themePreference"
]
},
"RulesetDefinition": { "RulesetDefinition": {
"type": "object", "type": "object",
"properties": { "properties": {

View File

@@ -1,23 +0,0 @@
@{
ProjectPath = "..\RpgRoller\RpgRoller.csproj"
Configuration = "Release"
Runtime = "win-x64"
PublishDir = "%TEMP%\RpgRoller-publish"
SelfContained = $false
WinScpPath = "C:\Users\frank\AppData\Local\Programs\WinSCP\WinSCP.com"
RemoteDir = "/httpdocs/rpgroller"
BasePath = "/rpgroller"
FtpHost = "xTr1m.com"
FtpUser = "xTr1m"
RecycleAppPool = $true
AppPoolName = "xTr1m.com(domain)(4.0)(pool)"
WinRmComputer = "xTr1m.com"
WinRmCredentialUser = "Administrator"
UseWinRmHttps = $true
WinRmAuth = "Basic"
RunEfMigrations = $false
RemoteSitePath = "C:\Inetpub\vhosts\xTr1m.com\httpdocs\rpgroller"
}

View File

@@ -1,255 +0,0 @@
param(
[string]$ProfilePath = (Join-Path $PSScriptRoot "deploy-ftp.profile.psd1"),
[string]$Password,
[switch]$SkipRecycle,
[switch]$SkipMigrations
)
<#
.SYNOPSIS
Publish the app and mirror output to an FTP-deployed IIS site.
.DESCRIPTION
- Reads environment-specific settings from a PowerShell data file profile.
- Builds with dotnet publish.
- Uses WinSCP to mirror publish output into remote directory (deletes extraneous files).
- Optionally recycles IIS app pool and runs EF migrations remotely over WinRM.
.EXAMPLE
pwsh ./scripts/deploy-ftp.ps1 -ProfilePath ./scripts/deploy-ftp.profile.psd1
#>
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
function Assert-Tool {
param([Parameter(Mandatory = $true)][string]$Name)
if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) {
throw "Required tool '$Name' not found. Install it or update your deploy profile."
}
}
function Require-ConfigValue {
param(
[Parameter(Mandatory = $true)][hashtable]$Config,
[Parameter(Mandatory = $true)][string]$Key
)
if (-not $Config.ContainsKey($Key) -or [string]::IsNullOrWhiteSpace([string]$Config[$Key])) {
throw "Missing required deploy profile value '$Key'."
}
}
function Resolve-ProfilePath {
param(
[Parameter(Mandatory = $true)][string]$BaseDirectory,
[Parameter(Mandatory = $true)][string]$PathValue
)
$expanded = [Environment]::ExpandEnvironmentVariables($PathValue)
if ([System.IO.Path]::IsPathRooted($expanded)) {
return $expanded
}
return [System.IO.Path]::GetFullPath((Join-Path $BaseDirectory $expanded))
}
function Read-PlainOrPrompt {
param(
[string]$Value,
[Parameter(Mandatory = $true)][string]$Prompt,
[bool]$Secure = $false
)
if (-not [string]::IsNullOrWhiteSpace($Value)) {
return $Value
}
if ($Secure) {
$pwd = Read-Host -Prompt $Prompt -AsSecureString
$ptr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($pwd)
try {
return [Runtime.InteropServices.Marshal]::PtrToStringUni($ptr)
}
finally {
if ($ptr -ne [IntPtr]::Zero) {
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($ptr)
}
}
}
return Read-Host -Prompt $Prompt
}
function Invoke-WinRmScript {
param(
[Parameter(Mandatory = $true)][hashtable]$Config,
[Parameter(Mandatory = $true)][string]$PasswordValue,
[Parameter(Mandatory = $true)][scriptblock]$ScriptBlock,
[object[]]$ArgumentList = @()
)
Require-ConfigValue $Config "WinRmComputer"
Require-ConfigValue $Config "WinRmCredentialUser"
$secure = ConvertTo-SecureString $PasswordValue -AsPlainText -Force
$cred = New-Object pscredential($Config.WinRmCredentialUser, $secure)
$invokeParams = @{
ComputerName = $Config.WinRmComputer
Credential = $cred
ScriptBlock = $ScriptBlock
ArgumentList = $ArgumentList
}
if ($Config.ContainsKey("UseWinRmHttps") -and [bool]$Config.UseWinRmHttps) {
$invokeParams["UseSSL"] = $true
}
if ($Config.ContainsKey("WinRmAuth") -and -not [string]::IsNullOrWhiteSpace([string]$Config.WinRmAuth)) {
$invokeParams["Authentication"] = [string]$Config.WinRmAuth
}
Invoke-Command @invokeParams
}
if (-not (Test-Path $ProfilePath)) {
throw "Deploy profile not found: $ProfilePath. Copy scripts/deploy-ftp.profile.sample.psd1 and fill environment-specific values."
}
$resolvedProfilePath = (Resolve-Path $ProfilePath).Path
$profileDirectory = Split-Path -Parent $resolvedProfilePath
$config = Import-PowerShellDataFile -Path $resolvedProfilePath
Require-ConfigValue $config "ProjectPath"
Require-ConfigValue $config "Configuration"
Require-ConfigValue $config "Runtime"
Require-ConfigValue $config "PublishDir"
Require-ConfigValue $config "WinScpPath"
Require-ConfigValue $config "RemoteDir"
$winScpSessionName = if ($config.ContainsKey("WinScpSessionName")) { [string]$config.WinScpSessionName } else { "" }
$useStoredSession = -not [string]::IsNullOrWhiteSpace($winScpSessionName)
if (-not $useStoredSession) {
Require-ConfigValue $config "FtpHost"
Require-ConfigValue $config "FtpUser"
}
$projectPath = Resolve-ProfilePath $profileDirectory ([string]$config.ProjectPath)
$publishDir = Resolve-ProfilePath $profileDirectory ([string]$config.PublishDir)
$winScpPath = Resolve-ProfilePath $profileDirectory ([string]$config.WinScpPath)
$selfContained = if ($config.ContainsKey("SelfContained")) { [bool]$config.SelfContained } else { $false }
$recycleAppPool = if ($config.ContainsKey("RecycleAppPool")) { [bool]$config.RecycleAppPool } else { $false }
$runEfMigrations = if ($config.ContainsKey("RunEfMigrations")) { [bool]$config.RunEfMigrations } else { $false }
$recycleAppPool = $recycleAppPool -and -not $SkipRecycle
$runEfMigrations = $runEfMigrations -and -not $SkipMigrations
$passwordFromEnv = $env:PICKNPLAY_FTP_PASSWORD
$passwordFromInput = if (-not [string]::IsNullOrWhiteSpace($Password)) { $Password } else { $passwordFromEnv }
$needsFtpPassword = -not $useStoredSession
$needsWinRmPassword = $recycleAppPool -or $runEfMigrations
$sharedPassword = ""
if ($needsFtpPassword -or $needsWinRmPassword) {
$prompt = if ($needsFtpPassword -and $needsWinRmPassword) { "FTP/WinRM password" } elseif ($needsFtpPassword) { "FTP password" } else { "WinRM password" }
$sharedPassword = Read-PlainOrPrompt -Value $passwordFromInput -Prompt $prompt -Secure $true
}
$passwordForSession = if ($needsFtpPassword) { $sharedPassword } else { "" }
$passwordForWinRm = if ($needsWinRmPassword) { $sharedPassword } else { "" }
Assert-Tool "dotnet"
Assert-Tool $winScpPath
Write-Host "1) Publishing..." -ForegroundColor Cyan
if (Test-Path $publishDir) {
Remove-Item $publishDir -Recurse -Force -ErrorAction SilentlyContinue
}
New-Item -ItemType Directory -Force -Path $publishDir | Out-Null
$publishArgs = @("publish", $projectPath, "-c", [string]$config.Configuration, "-r", [string]$config.Runtime, "-o", $publishDir)
if (-not $selfContained) {
$publishArgs += "--self-contained=false"
}
dotnet @publishArgs
Write-Host "2) Skipping legacy index.html app-base rewrite (Blazor frontend)." -ForegroundColor Cyan
if ($recycleAppPool) {
Require-ConfigValue $config "AppPoolName"
$appPoolName = [string]$config.AppPoolName
Write-Host "2) Stopping IIS app pool via WinRM..." -ForegroundColor Cyan
try {
Invoke-WinRmScript -Config $config -PasswordValue $passwordForWinRm -ScriptBlock {
param($poolName)
Import-Module WebAdministration
Stop-WebAppPool -Name $poolName -ErrorAction SilentlyContinue
Get-Process GameList -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
Get-Process dotnet -ErrorAction SilentlyContinue | Where-Object { $_.Path -like "*picknplay*" } | Stop-Process -Force -ErrorAction SilentlyContinue
} -ArgumentList @($appPoolName)
}
catch {
Write-Warning "WinRM stop failed: $($_.Exception.Message)."
}
}
Write-Host "3) Syncing via WinSCP..." -ForegroundColor Cyan
$openCommand = if ($useStoredSession) {
"open `"$winScpSessionName`""
}
else {
$ftpUser = [Uri]::EscapeDataString([string]$config.FtpUser)
$ftpPassword = [Uri]::EscapeDataString($passwordForSession.Replace("`n", "").Replace("`r", ""))
$ftpHost = [string]$config.FtpHost
"open ftp://$ftpUser`:$ftpPassword@$ftpHost"
}
$tempScript = New-TemporaryFile
@(
"option batch continue"
"option confirm off"
$openCommand
"lcd `"$publishDir`""
"cd $([string]$config.RemoteDir)"
"synchronize remote . -delete -filemask=`"|web.config;App_Data/;logs/;GameList.Tests/`""
"exit"
) | Set-Content -Path $tempScript -Encoding UTF8
& $winScpPath "/ini=nul" "/script=$tempScript"
Remove-Item $tempScript -ErrorAction SilentlyContinue
if ($recycleAppPool) {
Write-Host "4) Starting IIS app pool via WinRM..." -ForegroundColor Cyan
try {
Invoke-WinRmScript -Config $config -PasswordValue $passwordForWinRm -ScriptBlock {
param($poolName)
Import-Module WebAdministration
Start-WebAppPool -Name $poolName
} -ArgumentList @($appPoolName)
}
catch {
Write-Warning "WinRM start failed: $($_.Exception.Message)."
}
}
if ($runEfMigrations) {
Require-ConfigValue $config "RemoteSitePath"
Write-Host "5) Running EF Core migrations on remote site..." -ForegroundColor Cyan
try {
Invoke-WinRmScript -Config $config -PasswordValue $passwordForWinRm -ScriptBlock {
param($sitePath)
Set-Location $sitePath
if (-not (Get-Command dotnet -ErrorAction SilentlyContinue)) {
throw "dotnet is not available on remote host."
}
dotnet ef database update --no-build
} -ArgumentList @([string]$config.RemoteSitePath)
}
catch {
Write-Warning "WinRM migrations failed: $($_.Exception.Message)."
}
}
Write-Host "Done." -ForegroundColor Green

View File

@@ -1,14 +0,0 @@
param(
[string]$ProfilePath = (Join-Path $PSScriptRoot "deploy-ftp.profile.psd1"),
[string]$Password,
[switch]$SkipRecycle,
[switch]$SkipMigrations
)
$scriptPath = Join-Path $PSScriptRoot "deploy-ftp.ps1"
& $scriptPath `
-ProfilePath $ProfilePath `
-Password $Password `
-SkipRecycle:$SkipRecycle `
-SkipMigrations:$SkipMigrations

183
scripts/deploy.ps1 Normal file
View File

@@ -0,0 +1,183 @@
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
function Require-Tool {
param(
[Parameter(Mandatory = $true)][string]$Name
)
if ($null -eq (Get-Command $Name -ErrorAction SilentlyContinue)) {
throw "Required tool not found: $Name"
}
}
function Invoke-NativeCommand {
param(
[Parameter(Mandatory = $true)][string]$Name,
[Parameter(Mandatory = $true)][scriptblock]$Action
)
Write-Host $Name
& $Action
if ($LASTEXITCODE -ne 0) {
throw "Step failed: $Name"
}
}
function ConvertTo-LinuxLineEnding {
param(
[Parameter(Mandatory = $true)][string]$Value
)
return $Value.Replace("`r`n", "`n").Replace("`r", "`n")
}
function Get-RemoteScript {
param(
[Parameter(Mandatory = $true)][string]$RemoteReleaseDir,
[Parameter(Mandatory = $true)][string]$RemoteCurrentLink,
[Parameter(Mandatory = $true)][string]$ContainerName,
[Parameter(Mandatory = $true)][string]$ImageName,
[Parameter(Mandatory = $true)][string]$ReleaseTimestamp,
[Parameter(Mandatory = $true)][string]$RemoteDataDir,
[Parameter(Mandatory = $true)][string]$ContainerPort,
[Parameter(Mandatory = $true)][string]$HostPort
)
$script = @'
set -euo pipefail
remote_release_dir='__REMOTE_RELEASE_DIR__'
remote_current_link='__REMOTE_CURRENT_LINK__'
container_name='__CONTAINER_NAME__'
image_name='__IMAGE_NAME__'
release_timestamp='__RELEASE_TIMESTAMP__'
remote_data_dir='__REMOTE_DATA_DIR__'
container_port='__CONTAINER_PORT__'
host_port='__HOST_PORT__'
previous_current_target=''
if [ -L "${remote_current_link}" ]; then
previous_current_target="$(readlink -f "${remote_current_link}")"
fi
docker build -t "${image_name}:${release_timestamp}" -t "${image_name}:latest" "${remote_release_dir}"
ln -sfn "${remote_release_dir}" "${remote_current_link}"
if docker ps -aq --filter "name=^/${container_name}$" | grep -q .; then
docker rm -f "${container_name}" >/dev/null
fi
if ! docker run -d \
--name "${container_name}" \
--restart unless-stopped \
-p "127.0.0.1:${host_port}:${container_port}" \
-e ASPNETCORE_ENVIRONMENT=Production \
-e ASPNETCORE_URLS="http://+:${container_port}" \
-e ConnectionStrings__RpgRoller="Data Source=/app/data/rpgroller.db" \
-v "${remote_data_dir}:/app/data" \
"${image_name}:${release_timestamp}" >/dev/null; then
if [ -n "${previous_current_target}" ]; then
ln -sfn "${previous_current_target}" "${remote_current_link}"
fi
exit 1
fi
'@
$script = $script.
Replace("__REMOTE_RELEASE_DIR__", $RemoteReleaseDir).
Replace("__REMOTE_CURRENT_LINK__", $RemoteCurrentLink).
Replace("__CONTAINER_NAME__", $ContainerName).
Replace("__IMAGE_NAME__", $ImageName).
Replace("__RELEASE_TIMESTAMP__", $ReleaseTimestamp).
Replace("__REMOTE_DATA_DIR__", $RemoteDataDir).
Replace("__CONTAINER_PORT__", $ContainerPort).
Replace("__HOST_PORT__", $HostPort)
return ConvertTo-LinuxLineEnding -Value $script
}
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$repoRoot = Split-Path -Parent $scriptDir
$projectPath = Join-Path $repoRoot "RpgRoller\RpgRoller.csproj"
$remoteHost = "myvserver"
$remoteRoot = "/root/docker/rpgroller"
$remoteReleasesDir = "$remoteRoot/releases"
$remoteCurrentLink = "$remoteRoot/current"
$remoteDataDir = "$remoteRoot/data"
$containerName = "rpgroller"
$imageName = "rpgroller"
$containerPort = "8080"
$hostPort = "8082"
$releaseTimestamp = (Get-Date).ToUniversalTime().ToString("yyyyMMddHHmmss")
$localStageDir = Join-Path $repoRoot "artifacts\deploy\$releaseTimestamp"
$localPublishDir = Join-Path $localStageDir "publish"
$remoteReleaseDir = "$remoteReleasesDir/$releaseTimestamp"
Write-Host "Deploying release $releaseTimestamp"
Require-Tool -Name "dotnet"
Require-Tool -Name "ssh"
Require-Tool -Name "scp"
try {
New-Item -ItemType Directory -Path $localPublishDir -Force | Out-Null
Invoke-NativeCommand -Name "1) Publishing app locally..." -Action {
dotnet publish $projectPath -c Release -o $localPublishDir
}
$dockerfile = @'
FROM mcr.microsoft.com/dotnet/aspnet:10.0
WORKDIR /app
ENV ASPNETCORE_URLS=http://+:8080
ENV DOTNET_EnableDiagnostics=0
EXPOSE 8080
COPY publish/ ./
RUN mkdir -p /app/data
ENTRYPOINT ["dotnet", "RpgRoller.dll"]
'@
ConvertTo-LinuxLineEnding -Value $dockerfile |
Set-Content -Path (Join-Path $localStageDir "Dockerfile") -NoNewline
$remoteScript = Get-RemoteScript `
-RemoteReleaseDir $remoteReleaseDir `
-RemoteCurrentLink $remoteCurrentLink `
-ContainerName $containerName `
-ImageName $imageName `
-ReleaseTimestamp $releaseTimestamp `
-RemoteDataDir $remoteDataDir `
-ContainerPort $containerPort `
-HostPort $hostPort
ConvertTo-LinuxLineEnding -Value $remoteScript |
Set-Content -Path (Join-Path $localStageDir "deploy-remote.sh") -NoNewline
Invoke-NativeCommand -Name "2) Preparing remote release directory..." -Action {
ssh $remoteHost "mkdir -p '$remoteReleasesDir' '$remoteDataDir' && test ! -e '$remoteReleaseDir' && mkdir -p '$remoteReleaseDir'"
}
Invoke-NativeCommand -Name "3) Uploading release payload..." -Action {
Push-Location $localStageDir
try {
scp -r "Dockerfile" "deploy-remote.sh" "publish" "${remoteHost}:$remoteReleaseDir/"
}
finally {
Pop-Location
}
}
Invoke-NativeCommand -Name "4) Building image and restarting container on remote host..." -Action {
ssh $remoteHost "bash '$remoteReleaseDir/deploy-remote.sh'"
}
Write-Host "5) Deployment complete."
}
finally {
if (Test-Path $localStageDir) {
Remove-Item -Path $localStageDir -Recurse -Force -ErrorAction SilentlyContinue
}
}

106
scripts/deploy.sh Executable file
View File

@@ -0,0 +1,106 @@
#!/usr/bin/env bash
set -euo pipefail
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
readonly PROJECT_PATH="${REPO_ROOT}/RpgRoller/RpgRoller.csproj"
readonly REMOTE_HOST="myvserver"
readonly REMOTE_ROOT="/root/docker/rpgroller"
readonly REMOTE_RELEASES_DIR="${REMOTE_ROOT}/releases"
readonly REMOTE_CURRENT_LINK="${REMOTE_ROOT}/current"
readonly REMOTE_DATA_DIR="${REMOTE_ROOT}/data"
readonly CONTAINER_NAME="rpgroller"
readonly IMAGE_NAME="rpgroller"
readonly CONTAINER_PORT="8080"
readonly HOST_PORT="8082"
readonly RELEASE_TIMESTAMP="$(date -u +%Y%m%d%H%M%S)"
readonly LOCAL_STAGE_DIR="${REPO_ROOT}/artifacts/deploy/${RELEASE_TIMESTAMP}"
readonly LOCAL_PUBLISH_DIR="${LOCAL_STAGE_DIR}/publish"
readonly REMOTE_RELEASE_DIR="${REMOTE_RELEASES_DIR}/${RELEASE_TIMESTAMP}"
cleanup() {
rm -rf "${LOCAL_STAGE_DIR}"
}
trap cleanup EXIT
require_tool() {
local tool_name="$1"
if ! command -v "${tool_name}" >/dev/null 2>&1; then
printf 'Required tool not found: %s\n' "${tool_name}" >&2
exit 1
fi
}
printf 'Deploying release %s\n' "${RELEASE_TIMESTAMP}"
require_tool dotnet
require_tool rsync
require_tool ssh
mkdir -p "${LOCAL_PUBLISH_DIR}"
printf '1) Publishing app locally...\n'
dotnet publish "${PROJECT_PATH}" -c Release -o "${LOCAL_PUBLISH_DIR}"
cat > "${LOCAL_STAGE_DIR}/Dockerfile" <<'EOF'
FROM mcr.microsoft.com/dotnet/aspnet:10.0
WORKDIR /app
ENV ASPNETCORE_URLS=http://+:8080
ENV DOTNET_EnableDiagnostics=0
EXPOSE 8080
COPY publish/ ./
RUN mkdir -p /app/data
ENTRYPOINT ["dotnet", "RpgRoller.dll"]
EOF
printf '2) Preparing remote release directory...\n'
ssh "${REMOTE_HOST}" "mkdir -p '${REMOTE_RELEASES_DIR}' '${REMOTE_DATA_DIR}' && test ! -e '${REMOTE_RELEASE_DIR}'"
printf '3) Uploading release payload...\n'
rsync -az --delete "${LOCAL_STAGE_DIR}/" "${REMOTE_HOST}:${REMOTE_RELEASE_DIR}/"
printf '4) Building image and restarting container on remote host...\n'
ssh "${REMOTE_HOST}" "bash -se" <<EOF
set -euo pipefail
remote_release_dir='${REMOTE_RELEASE_DIR}'
remote_current_link='${REMOTE_CURRENT_LINK}'
container_name='${CONTAINER_NAME}'
image_name='${IMAGE_NAME}'
release_timestamp='${RELEASE_TIMESTAMP}'
remote_data_dir='${REMOTE_DATA_DIR}'
container_port='${CONTAINER_PORT}'
host_port='${HOST_PORT}'
previous_current_target=''
if [ -L "\${remote_current_link}" ]; then
previous_current_target="\$(readlink -f "\${remote_current_link}")"
fi
docker build -t "\${image_name}:\${release_timestamp}" -t "\${image_name}:latest" "\${remote_release_dir}"
ln -sfn "\${remote_release_dir}" "\${remote_current_link}"
if docker ps -aq --filter "name=^/\${container_name}\$" | grep -q .; then
docker rm -f "\${container_name}" >/dev/null
fi
if ! docker run -d \
--name "\${container_name}" \
--restart unless-stopped \
-p "127.0.0.1:\${host_port}:\${container_port}" \
-e ASPNETCORE_ENVIRONMENT=Production \
-e ASPNETCORE_URLS="http://+:\${container_port}" \
-e ConnectionStrings__RpgRoller="Data Source=/app/data/rpgroller.db" \
-v "\${remote_data_dir}:/app/data" \
"\${image_name}:\${release_timestamp}" >/dev/null; then
if [ -n "\${previous_current_target}" ]; then
ln -sfn "\${previous_current_target}" "\${remote_current_link}"
fi
exit 1
fi
EOF
printf '5) Deployment complete.\n'