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

17
AGENTS.linux.md Normal file
View File

@@ -0,0 +1,17 @@
# Agent Guide
Also see the other related technical documentation in the docs folder.
## Tools
These tools are installed and available: Python3, geckodriver, Selenium
## Rules
- After every iteration, run `jb cleanupcode --build=False '$file1' '$file2' ...` for every C# file you touched.
- After every frontend change, verify the results using a geckodriver+Selenium run.
### Dotnet CLI
- If you need a separate output directory, use a subfolder under `artifacts`, and clean it up afterwards.
- Avoid running `dotnet build` and `dotnet test` in parallel in this repo; that can cause file-lock failures in `obj\Debug\net10.0`.

View File

@@ -1,41 +1,26 @@
# Agent Guide
Also see the other related technical documentation: README.md.
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
- This is a Windows environment, WSL is not installed (i.e. sed is not available). You're running under PowerShell 7.6.0. Due to platform restrictions, file deletions are not possible. Replacing the entire file content via a context diff is a viable alternative.
- PowerShell doesn't support bash-style heredocs. If complex scripts need to be executed, consider using python. Run Python code using python -c with inline commands instead of python - <<'PY'.
- web.config in the server is different than locally, it must be exluded from deployment.
- Before beginning with the edit phase, always present a plan first. Only begin editing after the user approves the plan.
- 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.
- After every iteration, run `jb cleanupcode --build=False '$file1' '$file2'` for every file you touched.
- After every iteration, run "scripts/ci-local.ps1" and ensure that nothing broke.
- After every iteration, update all related documentation according to the change.
- Update the wording of touched concerns instead of introducing incremental change reports
- The documentation should always represent the current state in its entirety and not derail into a historical development log.
- After every frontend change, verify the results using an ephemeral playwright.
- After every iteration, do a git commit with a brief summary of the changes as a commit message.
- 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.
- If a required tool is missing (for example `dotnet-ef`), install/configure the tool (prefer repo-local setup such as `dotnet tool manifest`) instead of weakening validations or muting warnings. If installation is blocked, stop and ask before changing validation strictness.
- After changing the database, if your build is blocked by a running dotnet process, feel free to kill the process and retry the operation once.
## Output generation
### User
- For the user, you talk like a caveman. Speak only short grunts. Communicate in english.
- Give no explanations unless explicitly asked.
- No fillers like 'happy to help'. Do task first. Show result. Stop.
- For tools: "Tool work." then output the result and words like "Problem.", "Plan?", "Done."
- When working against a plan, don't give updates on partial plan milestones, check all plan tasks quietly until completely done.
### Reasoning
- Feel free to speak to yourself in whatever language suits you best, focusing on concise, compact and essential information exchange.
## ExecPlans
- When writing complex features or significant refactors, use an ExecPlan (as described in PLANS.md) from design to implementation.

39
AGENTS.windows.md Normal file
View File

@@ -0,0 +1,39 @@
# Agent Guide
Also see the other related technical documentation in the docs folder.
## Tools
These tool paths should be used instead of any entry in the PATH environment variable:
- Python is installed in `C:\Users\frank\AppData\Local\Programs\Python\Python314`.
- MiKTeX portable is installed in `D:\Code\miktex-portable\texmfs\install\miktex\bin\x64`.
- Tesseract is installed in `C:\Program Files\Sejda PDF Desktop\resources\vendor\tesseract-windows-x64`.
## Rules
- After every iteration, run `dotnet jb cleanupcode --build=False '$file1' '$file2' ...` for every C# file you touched.
- 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 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.
- 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.
- 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.
### PowerShell
- This is a Windows environment, WSL is not installed (i.e. sed is not available). You're running under PowerShell 7.5.5. Due to platform restrictions, file deletions are not possible. Replacing the entire file content via a context diff is a viable alternative.
- PowerShell doesn't support bash-style heredocs. If complex scripts need to be executed, consider using python as a last resort. Run Python code using python -c with inline commands instead of python - <<'PY'.
- Parallel PowerShell calls are flaky, stick to sequential reads and command execution.
- Commands like `rg` and `Get-Content` are always allowed.
### Dotnet CLI
- If a build fails with 0 errors / 0 warnings:
- Do not keep retrying the same build command
- Consider using --no-restore.
- Consider using `$env:DOTNET_SKIP_FIRST_TIME_EXPERIENCE = '1'`
- Consider using `$env:NUGET_PACKAGES = Join-Path $env:USERPROFILE '.nuget\packages'`
- If you need a separate output directory, use a subfolder under `artifacts`, and clean it up afterwards.
- Avoid running `dotnet build` and `dotnet test` in parallel in this repo; that can cause file-lock failures in `obj\Debug\net10.0`.

198
README.md
View File

@@ -1,10 +1,12 @@
# RpgRoller
# RpgRoller
RpgRoller is an ASP.NET Core + Blazor Server app for lightweight tabletop campaign play, character sheets, and dice workflows.
RpgRoller is an ASP.NET Core and Blazor Server app for lightweight tabletop campaign play, character sheets, and dice workflows.
- `RpgRoller/`: web app, API endpoints, domain model, EF Core persistence, Blazor components, and static assets
- `RpgRoller.Tests/`: xUnit coverage for API behavior, services, hosting, payload budgets, and persistence/migration paths
- `RpgRoller.Tests/`: xUnit coverage for API behavior, services, hosting, payload budgets, and persistence and migration paths
- `RpgRoller.sln`: solution used by local development and repo scripts
- `POSTMORTEM.md`: architecture analysis of the May 2026 Firefox and RoboForm failure in the authenticated workspace
- `TASKS.md`: the completed execution log for the route-first authenticated shell rewrite
Test layout:
@@ -16,41 +18,49 @@ Test layout:
Backend:
- `RpgRoller/Program.cs`: app bootstrap, JSON options, compression, API/component mapping, and optional `PathBase`
- `RpgRoller/Program.cs`: app bootstrap, JSON options, compression, API and component mapping, and optional `PathBase`
- `RpgRoller/Hosting/`: service registration, startup initialization, SQLite path resolution, and schema upgrades
- `RpgRoller/Api/`: minimal API endpoint groups, request mappings, cookie/session helpers, and result mapping
- `RpgRoller/Api/`: minimal API endpoint groups, request mappings, cookie and session helpers, and result mapping
- `RpgRoller/Services/`: gameplay and account workflows behind `IGameService`
- `RpgRoller/Services/GameService.cs`: facade over composed domain services
- `RpgRoller/Services/GameAuthService.cs`: registration, login, logout, session lookup, and `GetMe`
- `RpgRoller/Services/GameCampaignService.cs`: campaign creation, listing, roster reads, campaign options, and deletion
- `RpgRoller/Services/GameCharacterService.cs`: character creation, updates, activation, deletion, transfer, and owner-scoped reads
- `RpgRoller/Services/GameSkillService.cs`: skill-group CRUD, skill CRUD, sheet shaping, and ruleset validation
- `RpgRoller/Services/GameRollService.cs`: skill/custom rolls, compact log pages, roll detail, and campaign state snapshots
- `RpgRoller/Services/GameRollService.cs`: skill and custom rolls, compact log pages, roll detail, and campaign state snapshots
- `RpgRoller/Services/GameUserAdministrationService.cs`: username reads, admin user listing, role updates, and account deletion
- `RpgRoller/Services/GameStateStore.cs`, `GameStateCloneFactory.cs`, and `GamePersistenceService.cs`: in-memory runtime state, campaign-state version tracking, and SQLite load/save boundaries
- `RpgRoller/Services/GameAuthorization.cs`, `GameContextResolver.cs`, and `GameDtoMapper.cs`: shared authorization, session/campaign resolution, and backend read-model mapping
- `RpgRoller/Services/GameStateStore.cs`, `GameStateCloneFactory.cs`, and `GamePersistenceService.cs`: in-memory runtime state, campaign-state version tracking, and SQLite load and save boundaries
- `RpgRoller/Services/GameAuthorization.cs`, `GameContextResolver.cs`, and `GameDtoMapper.cs`: shared authorization, session and campaign resolution, and backend read-model mapping
- `RpgRoller/Services/RollEngine.cs`, `StandardRollEngine.cs`, `D6RollEngine.cs`, `RolemasterRollEngine.cs`, `RollBreakdownFormatter.cs`, and `CampaignLogSummaryBuilder.cs`: ruleset-specific dice execution, breakdown formatting, and compact campaign-log summaries
- `RpgRoller/Services/SkillDefinitionValidator.cs`, `RoleSerializer.cs`, `RollVisibilityParser.cs`, and `CustomRollOptionsResolver.cs`: shared rules and parsing helpers
Frontend:
- `RpgRoller/Components/`: Blazor app shell, routes, layout, page components, and query/client helpers
- `RpgRoller/Components/Pages/Home.razor`: gateway that switches between loading, auth, and authenticated workspace views, and force-reloads after login so the authenticated play workspace is built from the fresh session cookie
- `RpgRoller/Components/Pages/Home.razor.cs`: gateway/session orchestration for `Home`
- `RpgRoller/Components/Pages/Workspace.razor`: authenticated workspace UI
- `RpgRoller/Components/Pages/Workspace.razor.cs`: workspace composition root, coordinator wiring, lifecycle, and JS-invokable entry points
- `RpgRoller/Components/App.razor`: HTML shell that serves the static `/login` auth document or the per-page interactive authenticated route set based on request path
- `RpgRoller/Components/Routes.razor`: Blazor router and layout hookup
- `RpgRoller/Components/Layout/MainLayout.razor`: default layout
- `RpgRoller/Components/Pages/LoginPage.razor`: route marker for the static `/login` auth document
- `RpgRoller/Components/Pages/PlayPage.razor`, `CampaignsPage.razor`, and `AdminPage.razor`: authenticated route entry points for the interactive workspace
- `RpgRoller/Components/Pages/AuthenticatedPageBase.cs`: shared logout-to-`/login` redirect helper for authenticated route pages
- `RpgRoller/Components/Pages/Workspace.razor`: authenticated shell with shared header, health banner, toast stack, and route-owned body slot
- `RpgRoller/Components/Pages/Workspace.razor.cs`: shell composition root, coordinator wiring, route initialization entry point, JS-invokable state-event hooks, and menu item construction
- `RpgRoller/Components/Pages/WorkspaceRouteView.razor`: route-local first-render bootstrapper that initializes the interactive workspace after the page mounts
- `RpgRoller/Components/Pages/PlayWorkspaceContent.razor`, `CampaignsWorkspaceContent.razor`, and `AdminWorkspaceContent.razor`: route-owned authenticated page subtrees
- `RpgRoller/Components/Pages/CharacterManagementModals.razor`: shared create and edit character modals used by play and campaign-management routes
- `RpgRoller/Components/Pages/WorkspaceState.cs`: workspace UI state plus pure computed and formatting projections used directly by the Razor view
- `RpgRoller/Components/Pages/WorkspaceSessionCoordinator.cs`, `WorkspaceCampaignCoordinator.cs`, `WorkspaceCampaignScopeCoordinator.cs`, `WorkspacePlayCoordinator.cs`, `WorkspaceAdminCoordinator.cs`, `WorkspaceLiveStateController.cs`, `WorkspaceFeedbackService.cs`, and `WorkspaceToast.cs`: session/bootstrap, campaign scope, play/log, admin, live update, and toast concerns used by `Workspace`
- `RpgRoller/Components/Pages/HomeControls/`: workspace and auth child components, forms, header, panels, and modal controls
- `RpgRoller/Components/Pages/WorkspaceSessionCoordinator.cs`, `WorkspaceCampaignCoordinator.cs`, `WorkspaceCampaignScopeCoordinator.cs`, `WorkspacePlayCoordinator.cs`, `WorkspaceAdminCoordinator.cs`, `WorkspaceLiveStateController.cs`, `WorkspaceFeedbackService.cs`, and `WorkspaceToast.cs`: session bootstrap, campaign scope, play and log, admin, live update, and toast concerns used by `Workspace`
- `RpgRoller/Components/Pages/HomeControls/StaticAuthPage.razor`: plain HTML login and registration page used at `/login`
- `RpgRoller/Components/Pages/HomeControls/`: workspace child components, forms, header, panels, and modal controls
- `RpgRoller/Components/RpgRollerApiClient.cs`: browser API client for write actions
- `RpgRoller/Components/WorkspaceQueryService.cs`: server-side read model access for workspace data
- `RpgRoller/wwwroot/js/rpgroller-api.js`: browser interop for session storage, SSE wiring, and DOM helpers
- `RpgRoller/wwwroot/styles.css`: app styling and responsive layout
- `RpgRoller/Components/WorkspaceQueryService.cs`: browser-facing read client for workspace data
- `RpgRoller/wwwroot/js/rpgroller-api.js`: browser interop for auth forms, session storage, SSE wiring, and DOM helpers
- `RpgRoller/wwwroot/styles.css`: app styling, 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:
- `TASKS.md` records the completed decomposition work and the final execution notes for this refactor.
- This README describes the code as it exists today. It does not treat blueprint items in `TASKS.md` as finished unless they are already present in the repo.
- `POSTMORTEM.md` documents why the previous authenticated workspace architecture was fragile under Firefox plus RoboForm.
- `TASKS.md` records the route-first rewrite and the final Blazor configuration change that resolved the Firefox plus RoboForm crash.
## Runtime and Persistence
@@ -66,10 +76,11 @@ Current repo note:
- Supported campaign rulesets: D6 System, D&D 5e, and Rolemaster
- Account registration, login, session-based auth, and role-aware authorization
- Admin tools for user listing, role updates, account deletion, and direct SQLite database download
- Campaign creation, roster reads, participant-scoped visibility, and owner/admin deletion
- Character creation, activation, owner transfer, campaign reassignment or unlinking, and owner/admin deletion
- Per-user light and dark theme preference with OS-based initial selection
- Campaign creation, roster reads, participant-scoped visibility, and owner and admin deletion
- Character creation, activation, owner transfer, campaign reassignment or unlinking, and owner and admin deletion
- Skill groups with reusable defaults plus skill and skill-group create, edit, reassign, and delete flows
- Owner-scoped play workspace that lists only the current user's characters while preserving GM/admin management capabilities
- Play workspace that lists the current user's characters, or the full active campaign roster when the user is that campaign's GM
- Campaign log paging, lazy-loaded roll detail, compact summaries, and live state refresh through SSE
- Custom roll submission from the play screen without creating a persisted skill
- Instant skill filtering in the character panel
@@ -87,39 +98,92 @@ Rolemaster support:
- Open-ended high chaining and low-end subtraction with ordered die metadata in roll detail
- Compact log badges and summaries for open-ended, retry, and fumble-related events, including `Retry +5` and `Retry +10`
## Current Frontend Architecture
The frontend now uses a route-first authenticated shell that keeps the anonymous auth document outside the interactive Blazor subtree.
`/` is an auth-aware entry redirect:
- anonymous `GET /` redirects to `/login`
- authenticated `GET /` redirects to `/play`
- `RpgRoller/Components/App.razor` serves the static `/login` document or the interactive route set based on the request path, not auth state
Inside the authenticated app, `/play`, `/campaigns`, and `/admin` are real Blazor routes, and the hamburger menu navigates between those URLs. `Workspace.razor` is now a shared shell only. Each authenticated route owns its own main content subtree through a route-specific component.
Authenticated interactivity is route-local instead of global:
- `App.razor` no longer applies `@rendermode` to `Routes` or `HeadOutlet`
- `PlayPage.razor`, `CampaignsPage.razor`, and `AdminPage.razor` each opt into `InteractiveServerRenderMode(prerender: false)` directly
- Blazor startup is manual with `Blazor.start({ ssr: { disableDomPreservation: true } })` so the app can disable enhanced SSR DOM preservation during interactive attach
- Header route changes now use full document navigation so moving between authenticated routes remounts the target per-page interactive root instead of trying to reuse the previous page circuit
Firefox plus RoboForm resolution:
- the route-first rewrite reduced the authenticated surface area, but it was not the final fix
- the crash stopped only after the app stopped using global Blazor interactivity
- the working combination is:
- per-page `InteractiveServerRenderMode(prerender: false)` on `/play`, `/campaigns`, and `/admin`
- manual `Blazor.start({ ssr: { disableDomPreservation: true } })`
- full document navigation between authenticated routes with `forceLoad: true`
- earlier phased first-render shells and heavy diagnostics were investigative steps and have been removed
Interactive bootstrap is now route-local:
- `WorkspaceRouteView.razor` performs the first-render JS-dependent session initialization for the authenticated route that mounted
- `Workspace.razor.cs` no longer uses `OnAfterRenderAsync` as the shell bootstrap orchestrator
- play-specific post-render behavior is limited to page-local controls such as log auto-scroll and modal autofocus inside child components
Remaining architectural constraints are deliberate:
- `/login` stays plain HTML plus JavaScript so the anonymous auth path avoids Blazor form ownership entirely
- authenticated reads and writes still depend on JS interop-backed `fetch`, so first interactive initialization must still happen after mount
- live updates still use SSE and route-aware synchronization, with `/play` as the only route that keeps the play log and selected character sheet live
## Route-First Authenticated Shell
- `/` becomes an auth-aware entry point that redirects to `/login` or `/play`
- `/login` hosts the anonymous auth experience
- `/play`, `/campaigns`, and `/admin` become real authenticated routes
- the hamburger menu becomes route navigation instead of in-memory screen switching
- SSE and heavy play bootstrap stay scoped to `/play`
- the large `Workspace` component is split so each route owns a smaller, more stable subtree
This rewrite is complete. See `TASKS.md` for the execution history and milestone notes.
## Local Development
Prerequisites:
- .NET SDK 10.0+
- PowerShell 7+
- Node.js 22+
- Firefox
- geckodriver
Initial setup:
```powershell
```bash
dotnet tool restore
npm ci
npm exec playwright install chromium
```
Run locally:
1. Start the app:
```powershell
```bash
dotnet run --project RpgRoller/RpgRoller.csproj
```
2. Open `http://localhost:5000` or the URL printed in the console.
3. Expect `/` to redirect to `/login` when anonymous and to `/play` when a valid session cookie already exists.
Playwright helpers:
Browser smoke helpers:
- Run the checked-in smoke suite against an isolated temporary SQLite database:
```powershell
pwsh ./scripts/run-playwright.ps1
```bash
node ./scripts/run-selenium.js
```
- Run Playwright directly when the app is already running:
```powershell
npm run e2e
- Run the Selenium smoke suite directly when the app is already running:
```bash
npm run e2e:smoke
```
VS Code launch profiles in `.vscode/launch.json`:
@@ -128,6 +192,65 @@ VS Code launch profiles in `.vscode/launch.json`:
- `RpgRoller: Server + Edge (F5)`
- `RpgRoller: Server + Firefox (F5)`
## Deployment
Deploy to the Linux server with:
```bash
bash ./scripts/deploy.sh
```
The script publishes the app locally, uploads a release to `/root/docker/rpgroller/releases/<UTC timestamp>`, updates `/root/docker/rpgroller/current`, rebuilds the `rpgroller` image, and recreates the `rpgroller` container. The SQLite database is preserved because the container keeps using the existing bind mount at `/root/docker/rpgroller/data`.
Reverse proxy requirements for production:
- Use `rpgroller.franktovar.de` as the only canonical host.
- Forward `X-Forwarded-For` and `X-Forwarded-Proto` so ASP.NET Core can mark the session cookie as secure behind TLS termination.
- Proxy `/_blazor` with WebSocket upgrade headers.
- Proxy `/api/events/state` as Server-Sent Events with buffering disabled, for example:
```nginx
server {
server_name rpgroller.franktovar.de;
location /_blazor {
proxy_pass http://127.0.0.1:8082;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 300;
}
location /api/events/state {
proxy_pass http://127.0.0.1:8082;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_cache off;
gzip off;
proxy_read_timeout 3600;
add_header X-Accel-Buffering no;
}
location / {
proxy_pass http://127.0.0.1:8082;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300;
}
}
```
Environment overrides:
- Set `ConnectionStrings__RpgRoller` to point at a custom SQLite database.
@@ -145,14 +268,17 @@ SQLite migration rule:
## Frontend Runtime
- The UI runs as Blazor Server with interactive components.
- The UI runs as route-local Blazor Server components for authenticated routes and as plain HTML plus JavaScript for the anonymous `/login` document.
- Static assets are linked through Blazor's `@Assets[...]` pipeline for fingerprinted cache-busting URLs.
- Workspace reads are resolved server-side through `WorkspaceQueryService`; browser interop stays limited to browser-only concerns.
- Workspace reads are resolved through API requests in `WorkspaceQueryService`; browser interop stays focused on auth forms, session storage, SSE wiring, and small DOM helpers.
- Interactive authenticated startup begins in `WorkspaceRouteView.razor` after first render because `RpgRollerApiClient` still depends on JS interop-backed `fetch`.
- Authenticated routes avoid global `Routes @rendermode` because upstream issue `dotnet/aspnetcore#58824` reports Firefox-specific failures with global interactivity and explicitly calls out per-page mode as the safer path.
- Authenticated route changes use full document navigations so each route remounts its own per-page interactive root.
- Live workspace refresh compares separate roster, per-character sheet, and log versions so unrelated changes do not trigger full reloads.
- Campaign log data is loaded in bounded slices: campaign summaries, one selected roster, one selected character sheet, and a 25-row incremental log window from `/api/campaigns/{campaignId}/log/page`.
- Log rows return compact summary data first and lazy-load full detail from `/api/rolls/{rollId}` when expanded.
- Newly appended local rolls auto-expand in the play workspace and reuse the roll response as the initial detail payload.
- Custom roll submission uses the selected character context; D6 uses baseline wild-die/fumble behavior, while D&D 5e and Rolemaster use the submitted expression directly.
- Custom roll submission uses the selected character context; D6 uses baseline wild-die and fumble behavior, while D&D 5e and Rolemaster use the submitted expression directly.
- API JSON contracts use the source-generated `RpgRollerJsonSerializerContext`.
- HTTP JSON responses are gzip-compressed when the client advertises support.
- The OpenAPI contract source lives at `openapi/RpgRoller.json`.

View File

@@ -1,4 +1,4 @@
namespace RpgRoller.Tests;
namespace RpgRoller.Tests;
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");
Assert.Equal(registerResult.Id, me.User.Id);
Assert.Null(me.User.ThemePreference);
Assert.Null(me.ActiveCharacterId);
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"));
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]
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");
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 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);
var activateResponse = await gmClient.PostAsync($"/api/characters/{gmCharacter.Id}/activate", null);
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(0, createdSkill.WildDice);
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("2d12+3", updatedSkill.DiceRollDefinition);
Assert.Equal(0, updatedSkill.WildDice);
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);
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("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(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]
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 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);
}
@@ -84,23 +128,32 @@ public sealed class CampaignApiTests(WebApplicationFactory<Program> factory) : A
await RegisterAsync(gmClient, "gm-rm-skill", "Password123", "Game Master");
await LoginAsync(gmClient, "gm-rm-skill", "Password123");
var campaign = 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 campaign =
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);
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);
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);
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.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.True(updatedSkill.RolemasterAutoRetry);
@@ -128,23 +181,31 @@ public sealed class CampaignApiTests(WebApplicationFactory<Program> factory) : A
await RegisterAsync(receiverClient, "receiver2", "Password123", "Receiver");
await LoginAsync(receiverClient, "receiver2", "Password123");
var campaign = 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 campaign =
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 renamedGroup = await PutAsync<UpdateSkillGroupRequest, SkillGroupSummary>(gmClient, $"/api/skill-groups/{createdGroup.Id}", new("Battle", "3D+2", 2, false));
var createdGroup = await PostAsync<CreateSkillGroupRequest, SkillGroupSummary>(ownerClient,
$"/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("3D+2", renamedGroup.DiceRollDefinition);
Assert.Equal(2, renamedGroup.WildDice);
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);
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);
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);
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}");
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("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.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));
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns", 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));
var campaign =
await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns",
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"));
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 LoginAsync(playerClient, "player-options", "Password123");
var firstCampaign = 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 firstCampaign =
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");
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.Contains(playerCampaignOptions, option => option.Id == firstCampaign.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 LoginAsync(otherClient, "other-delete", "Password123");
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns", 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 campaign =
await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns",
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}");
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 LoginAsync(playerClient, "player-log-cap", "Password123");
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns", new("Log Cap", "d6"));
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 campaign =
await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns", new("Log Cap", "d6"));
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>();
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);
}
@@ -369,14 +450,19 @@ public sealed class CampaignApiTests(WebApplicationFactory<Program> factory) : A
await RegisterAsync(playerClient, "player-log-page", "Password123", "Player");
await LoginAsync(playerClient, "player-log-page", "Password123");
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns", new("Log Page", "d6"));
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 campaign =
await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns", new("Log Page", "d6"));
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>();
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);
}
@@ -393,8 +479,10 @@ public sealed class CampaignApiTests(WebApplicationFactory<Program> factory) : A
Assert.False(string.IsNullOrWhiteSpace(entry.VisibilityLabel));
});
var latestRoll = 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");
var latestRoll =
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.Equal(latestRoll.RollId, incrementalPage.Entries[0].RollId);

View File

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

View File

@@ -1,4 +1,4 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Builder;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -159,6 +159,7 @@ public sealed class HostingCoverageTests
usersColumns.Add(usersTableInfoReader.GetString(1));
Assert.Contains("Roles", usersColumns);
Assert.Contains("ThemePreference", usersColumns);
using var usersRoleCommand = verifyConnection.CreateCommand();
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';";
var retryHistoryCount = Convert.ToInt32(retryHistoryCommand.ExecuteScalar());
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]
@@ -359,6 +365,11 @@ public sealed class HostingCoverageTests
retryHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260414204309_AddRolemasterAutoRetry';";
var retryHistoryCount = Convert.ToInt32(retryHistoryCommand.ExecuteScalar());
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]
@@ -481,6 +492,15 @@ public sealed class HostingCoverageTests
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();
authorizationRolesHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260226170000_AddAuthorizationRoles';";
var authorizationRolesHistoryCount = Convert.ToInt32(authorizationRolesHistoryCommand.ExecuteScalar());
@@ -490,5 +510,10 @@ public sealed class HostingCoverageTests
retryHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260414204309_AddRolemasterAutoRetry';";
var retryHistoryCount = Convert.ToInt32(retryHistoryCommand.ExecuteScalar());
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
{
@@ -74,4 +74,26 @@ public sealed class ServiceAuthTests
var usernames = ServiceTestSupport.GetValue(service.GetUsernames(session));
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
{
@@ -32,12 +32,16 @@ public sealed class ServicePersistenceTests
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.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.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(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())
{
var ownerUser = db.Users.Single(u => u.UsernameNormalized == "OWNER");
@@ -120,4 +124,22 @@ public sealed class ServicePersistenceTests
Assert.Equal(3, reloadedSkill.FumbleRange);
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

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Http.HttpResults;
using RpgRoller.Contracts;
using RpgRoller.Services;
@@ -14,6 +14,12 @@ internal static class MeEndpoints
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;
}
}

Binary file not shown.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
<header class="workspace-header">
<header class="workspace-header">
<div class="header-row">
<h1>@Title</h1>
@if (User is null)
@@ -15,15 +15,38 @@
}
@if (ShowCampaign)
{
<p class="header-campaign">Campaign: <strong>@(CampaignName ?? "No campaign selected")</strong></p>
}
@if (ShowConnectionState)
{
<div class="header-connection-cell">
<p class="connection @ConnectionStateCssClass">@ConnectionStateLabel</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">
@if (ShowConnectionState)
{
<p class="connection @ConnectionStateCssClass">@ConnectionStateLabel</p>
}
</div>
<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)
{
<div class="header-menu-wrap">
@@ -54,4 +77,4 @@
</div>
}
</div>
</header>
</header>

View File

@@ -1,4 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Components;
using RpgRoller.Contracts;
@@ -12,6 +12,8 @@ public partial class AppHeader
return item.OnSelected?.Invoke() ?? Task.CompletedTask;
}
private string ThemeToggleAriaLabel => string.Equals(Theme, "dark", StringComparison.OrdinalIgnoreCase) ? "Switch to light theme" : "Switch to dark theme";
[Parameter]
public string Title { get; set; } = "RpgRoller";
@@ -22,7 +24,16 @@ public partial class AppHeader
public bool ShowCampaign { get; set; }
[Parameter]
public string? CampaignName { get; set; }
public IReadOnlyList<CampaignSummary> Campaigns { get; set; } = [];
[Parameter]
public Guid? SelectedCampaignId { get; set; }
[Parameter]
public string CampaignSelectId { get; set; } = "header-campaign-select";
[Parameter]
public EventCallback<ChangeEventArgs> CampaignSelectionChanged { get; set; }
[Parameter]
public bool ShowConnectionState { get; set; } = true;
@@ -48,6 +59,15 @@ public partial class AppHeader
[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; }
}

View File

@@ -108,4 +108,4 @@
}
</form>
</section>
</aside>
</aside>

View File

@@ -29,7 +29,8 @@ public partial class CampaignLogPanel
catch (JSDisconnectedException)
{
}
catch (InvalidOperationException ex) when (ex.Message.Contains("statically rendered", StringComparison.OrdinalIgnoreCase))
catch (InvalidOperationException ex) when (ex.Message.Contains("statically rendered",
StringComparison.OrdinalIgnoreCase))
{
}
}
@@ -58,11 +59,12 @@ public partial class CampaignLogPanel
IsSubmittingCustomRoll = true;
try
{
var roll = await ApiClient.RequestAsync<RollResult>("POST", $"/api/characters/{SelectedCharacterId.Value}/custom-rolls", new
{
expression,
visibility = NormalizedRollVisibility
});
var roll = await ApiClient.RequestAsync<RollResult>("POST",
$"/api/characters/{SelectedCharacterId.Value}/custom-rolls", new
{
expression,
visibility = NormalizedRollVisibility
});
CustomRollState.Model.Expression = string.Empty;
CustomRollState.ResetValidation();
@@ -71,7 +73,8 @@ public partial class CampaignLogPanel
await JS.InvokeVoidAsync("rpgRollerApi.clearInputValue", CustomRollInputRef);
await InvokeAsync(StateHasChanged);
}
catch (ApiRequestException ex) when (string.Equals(ex.ErrorCode, "invalid_expression", StringComparison.Ordinal))
catch (ApiRequestException ex) when
(string.Equals(ex.ErrorCode, "invalid_expression", StringComparison.Ordinal))
{
SetCustomRollError(ex.Message);
await InvokeAsync(StateHasChanged);
@@ -93,7 +96,8 @@ public partial class CampaignLogPanel
private static IReadOnlyList<EventBadgeView> GetEventBadges(CampaignLogListEntry entry)
{
return (entry.EventBadges ?? []).Select(ToEventBadgeView).Where(badge => badge is not null).Cast<EventBadgeView>().ToArray();
return (entry.EventBadges ?? []).Select(ToEventBadgeView).Where(badge => badge is not null)
.Cast<EventBadgeView>().ToArray();
}
private static bool HasSummary(CampaignLogListEntry entry)
@@ -105,16 +109,16 @@ public partial class CampaignLogPanel
{
return code switch
{
"w6" => new("Wild 6", "positive"),
"w1" => new("Wild 1", "danger"),
"n20" => new("Nat 20", "positive"),
"n1" => new("Nat 1", "danger"),
"rf" => new("Fumble", "danger"),
"w6" => new("Wild 6", "positive"),
"w1" => new("Wild 1", "danger"),
"n20" => new("Nat 20", "positive"),
"n1" => new("Nat 1", "danger"),
"rf" => new("Fumble", "danger"),
"r100" => new("100", "rare"),
"r66" => new("66", "rare"),
"rs5" => new("Retry +5", "rare"),
"r66" => new("66", "rare"),
"rs5" => new("Retry +5", "rare"),
"rs10" => new("Retry +10", "rare"),
_ => null
_ => null
};
}
@@ -130,11 +134,9 @@ public partial class CampaignLogPanel
return string.Join(" ", classes);
}
[Inject]
private IJSRuntime JS { get; set; } = null!;
[Inject] private IJSRuntime JS { get; set; } = null!;
[Inject]
private RpgRollerApiClient ApiClient { get; set; } = null!;
[Inject] private RpgRollerApiClient ApiClient { get; set; } = null!;
private ElementReference LogPanelRef { get; set; }
private ElementReference LogFeedRef { get; set; }
@@ -145,54 +147,44 @@ public partial class CampaignLogPanel
private FormState<CustomRollFormModel> CustomRollState { get; } = new();
private bool IsSubmittingCustomRoll { get; set; }
[Parameter]
public bool IsCampaignDataLoading { get; set; }
[Parameter] public bool IsCampaignDataLoading { get; set; }
[Parameter]
public IReadOnlyList<CampaignLogListEntry> CampaignLog { get; set; } = [];
[Parameter] public IReadOnlyList<CampaignLogListEntry> CampaignLog { get; set; } = [];
[Parameter]
public Guid? ExpandedRollId { get; set; }
[Parameter] public Guid? ExpandedRollId { get; set; }
[Parameter]
public Guid? FreshRollId { get; set; }
[Parameter] public Guid? FreshRollId { get; set; }
[Parameter]
public EventCallback<Guid> ToggleRollDetailRequested { get; set; }
[Parameter] public EventCallback<Guid> ToggleRollDetailRequested { get; set; }
[Parameter]
public Func<Guid, CampaignRollDetail?> ResolveRollDetail { get; set; } = _ => null;
[Parameter] public Func<Guid, CampaignRollDetail?> ResolveRollDetail { get; set; } = _ => null;
[Parameter]
public Func<Guid, bool> IsRollDetailLoading { get; set; } = _ => false;
[Parameter] public Func<Guid, bool> IsRollDetailLoading { get; set; } = _ => false;
[Parameter]
public Func<Guid, string?> GetRollDetailError { get; set; } = _ => null;
[Parameter] public Func<Guid, string?> GetRollDetailError { get; set; } = _ => null;
[Parameter]
public Guid? SelectedCharacterId { get; set; }
[Parameter] public Guid? SelectedCharacterId { get; set; }
[Parameter]
public string? SelectedCharacterName { get; set; }
[Parameter] public string? SelectedCharacterName { get; set; }
[Parameter]
public string SelectedCampaignRulesetId { get; set; } = string.Empty;
[Parameter] public string SelectedCampaignRulesetId { get; set; } = string.Empty;
[Parameter]
public string RollVisibility { get; set; } = "public";
[Parameter] public string RollVisibility { get; set; } = "public";
[Parameter]
public bool IsMutating { get; set; }
[Parameter] public Func<string>? ResolveRollVisibility { get; set; }
[Parameter]
public EventCallback<RollResult> CustomRollCreated { get; set; }
[Parameter] public bool IsMutating { get; set; }
[Parameter]
public EventCallback<string> ErrorOccurred { get; set; }
[Parameter] public EventCallback<RollResult> CustomRollCreated { get; set; }
[Parameter] public EventCallback<string> ErrorOccurred { get; set; }
private bool HasCustomRollError => CustomRollState.Errors.ContainsKey("expression");
private string? CustomRollErrorMessage => CustomRollState.Errors.GetValueOrDefault("expression");
private bool IsCustomRollDisabled => IsCampaignDataLoading || IsMutating || IsSubmittingCustomRoll || !SelectedCharacterId.HasValue;
private bool IsCustomRollDisabled =>
IsCampaignDataLoading || IsMutating || IsSubmittingCustomRoll || !SelectedCharacterId.HasValue;
private string CustomRollInputCssClass => HasCustomRollError ? "custom-roll-input error" : "custom-roll-input";
private string? CustomRollInputTitle => HasCustomRollError ? CustomRollErrorMessage : null;
private string CustomRollErrorElementId => "custom-roll-expression-error";
@@ -200,23 +192,33 @@ public partial class CampaignLogPanel
private string CustomRollPlaceholder => SelectedCampaignRulesetId.ToLowerInvariant() switch
{
RulesetFormHelpers.RulesetIds.D6 => "e.g. 5D+4",
RulesetFormHelpers.RulesetIds.Dnd5e => "e.g. 2d12+2",
RulesetFormHelpers.RulesetIds.D6 => "e.g. 5D+4",
RulesetFormHelpers.RulesetIds.Dnd5e => "e.g. 2d12+2",
RulesetFormHelpers.RulesetIds.Rolemaster => "e.g. d10, 15d10, d100!+85",
_ => "Enter a roll expression"
_ => "Enter a roll expression"
};
private string CustomRollStatusText => SelectedCharacterId.HasValue && !string.IsNullOrWhiteSpace(SelectedCharacterName) ? $"For {SelectedCharacterName} • {RollVisibilityLabel}" : "Select a character to enable";
private string CustomRollStatusText =>
SelectedCharacterId.HasValue && !string.IsNullOrWhiteSpace(SelectedCharacterName)
? $"For {SelectedCharacterName} • Uses {RollVisibilityLabel.ToLowerInvariant()} visibility"
: $"Select a character to enable • {RollVisibilityLabel} visibility selected";
private string CustomRollHelpText => SelectedCampaignRulesetId.ToLowerInvariant() switch
{
RulesetFormHelpers.RulesetIds.D6 => "Uses the campaign ruleset. D6 custom rolls use one wild die and allow fumbles.",
RulesetFormHelpers.RulesetIds.Rolemaster => $"{RulesetFormHelpers.RolemasterExampleText()}. Logged for the selected character.",
_ => "Uses the selected campaign ruleset and current visibility."
RulesetFormHelpers.RulesetIds.D6 =>
"Uses the campaign ruleset. D6 custom rolls use one wild die and allow fumbles.",
RulesetFormHelpers.RulesetIds.Rolemaster =>
$"{RulesetFormHelpers.RolemasterExampleText()}. Logged for the selected character.",
_ => "Uses the selected campaign ruleset and current visibility."
};
private string RollVisibilityLabel => string.Equals(NormalizedRollVisibility, "private", StringComparison.OrdinalIgnoreCase) ? "Private" : "Public";
private string NormalizedRollVisibility => string.Equals(RollVisibility, "private", StringComparison.OrdinalIgnoreCase) ? "private" : "public";
private string RollVisibilityLabel =>
string.Equals(NormalizedRollVisibility, "private", StringComparison.OrdinalIgnoreCase) ? "Private" : "Public";
private string NormalizedRollVisibility =>
string.Equals(ResolveRollVisibility?.Invoke() ?? RollVisibility, "private", StringComparison.OrdinalIgnoreCase)
? "private"
: "public";
private string CustomRollExpression
{

View File

@@ -1,4 +1,4 @@
<main class="management-screen">
<main class="management-screen">
<section class="card">
<div class="section-head">
<h2>Campaign</h2>
@@ -9,13 +9,14 @@
}
else
{
<label for="campaign-select">Current campaign</label>
<select id="campaign-select" @onchange="CampaignSelectionChanged">
@foreach (var campaign in Campaigns)
<div class="campaign-current">
<span>Current campaign</span>
<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"
@@ -129,4 +130,4 @@
</form>
</section>
</div>
}
}

View File

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

View File

@@ -1,4 +1,4 @@
<section class="card character-panel">
<section class="card character-panel">
@if (IsCampaignDataLoading)
{
<div class="skeleton-stack">
@@ -9,7 +9,7 @@
}
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)
{
@@ -59,7 +59,9 @@
</div>
<div class="chip-toolbar">
<label class="visibility-control" for="roll-visibility">Visibility</label>
<select id="roll-visibility" value="@(RollVisibility == "private" ? "private" : "public")" @onchange="OnRollVisibilityChangedAsync">
<select id="roll-visibility"
value="@(RollVisibility == "private" ? "private" : "public")"
@onchange="OnRollVisibilityChangedAsync">
<option value="public">Public</option>
<option value="private">Private</option>
</select>
@@ -247,4 +249,4 @@
AvailableSkillGroups="SelectedCharacterSkillGroups"
IsMutating="IsMutating"
SkillSaved="OnSkillUpdatedAsync"
CancelRequested="CloseSkillModals"/>
CancelRequested="CloseSkillModals"/>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,15 +1,13 @@
using Microsoft.JSInterop;
using Microsoft.JSInterop;
using RpgRoller.Contracts;
using RpgRoller.Domain;
namespace RpgRoller.Components.Pages;
public sealed class WorkspaceSessionCoordinator(WorkspaceState state, WorkspaceFeedbackService feedback, IJSRuntime js, RpgRollerApiClient apiClient, WorkspaceQueryService workspaceQuery, Func<Guid?, Task> reloadCampaignsAsync, Func<Task> reloadCharacterCampaignOptionsAsync, Func<Task> refreshCampaignScopeAsync, Func<Task> syncStateEventsAsync, Func<Task> stopStateEventsAsync, Func<Task> ensureAdminUsersLoadedAsync, Action resetCampaignLogDetailState, Func<Task> requestRefreshAsync, Func<string?, Task> onLoggedOutAsync)
public sealed class WorkspaceSessionCoordinator(WorkspaceState state, WorkspaceFeedbackService feedback, IJSRuntime js, RpgRollerApiClient apiClient, WorkspaceQueryService workspaceQuery, Func<bool> isAdminRoute, Func<Task> redirectToPlayAsync, Func<Guid?, Task> reloadCampaignsAsync, Func<Task> reloadCharacterCampaignOptionsAsync, Func<Task> refreshCampaignScopeAsync, Func<Task> requestRefreshAsync, Func<Task> syncStateEventsAsync, Func<Task> stopStateEventsAsync, Func<Task> ensureAdminUsersLoadedAsync, Action resetCampaignLogDetailState, Func<string?, Task> onLoggedOutAsync)
{
public async Task InitializeAsync()
{
var storedScreen = await js.InvokeAsync<string?>("rpgRollerApi.getSessionValue", ScreenSessionKey);
state.CurrentScreen = NormalizeRequestedScreen(storedScreen) ?? ScreenPlay;
var storedPanel = await js.InvokeAsync<string?>("rpgRollerApi.getSessionValue", MobilePanelSessionKey);
if (string.Equals(storedPanel, "log", StringComparison.OrdinalIgnoreCase))
state.MobilePanel = "log";
@@ -18,12 +16,14 @@ public sealed class WorkspaceSessionCoordinator(WorkspaceState state, WorkspaceF
state.RollVisibility = NormalizeRollVisibility(storedRollVisibility);
Guid? preferredCampaignId = null;
var storedCampaignId = await js.InvokeAsync<string?>("rpgRollerApi.getSessionValue", CampaignSessionKey);
if (Guid.TryParse(storedCampaignId, out var parsedCampaignId))
preferredCampaignId = parsedCampaignId;
if (!isAdminRoute())
{
var storedCampaignId = await js.InvokeAsync<string?>("rpgRollerApi.getSessionValue", CampaignSessionKey);
if (Guid.TryParse(storedCampaignId, out var parsedCampaignId))
preferredCampaignId = parsedCampaignId;
}
await CheckHealthAsync();
await LoadRulesetsAsync();
var reloaded = await ReloadAuthenticatedSessionAsync(preferredCampaignId);
if (!reloaded)
@@ -78,34 +78,38 @@ public sealed class WorkspaceSessionCoordinator(WorkspaceState state, WorkspaceF
await onLoggedOutAsync("Logged out.");
}
public async Task SwitchScreenAsync(string screen)
{
var targetScreen = NormalizeRequestedScreen(screen) ?? ScreenPlay;
if (string.Equals(targetScreen, ScreenAdmin, StringComparison.OrdinalIgnoreCase) && !state.IsCurrentUserAdmin)
targetScreen = ScreenPlay;
state.CurrentScreen = targetScreen;
state.IsScreenMenuOpen = false;
await PersistScreenPreferenceAsync(state.CurrentScreen);
await requestRefreshAsync();
if (state.User is not null)
{
await refreshCampaignScopeAsync();
await syncStateEventsAsync();
}
if (state.IsAdminScreen)
{
await ensureAdminUsersLoadedAsync();
await requestRefreshAsync();
}
}
public async Task OnRollVisibilityChangedAsync(string visibility)
{
state.RollVisibility = NormalizeRollVisibility(visibility);
await js.InvokeVoidAsync("rpgRollerApi.setSessionValue", RollVisibilitySessionKey, state.RollVisibility);
await requestRefreshAsync();
}
public 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()
@@ -124,6 +128,7 @@ public sealed class WorkspaceSessionCoordinator(WorkspaceState state, WorkspaceF
state.SelectedCharacterId = null;
state.LastRoll = null;
state.KnownUsernames = [];
state.ThemePreference = ThemePreferences.Light;
state.ShowCreateCharacterModal = false;
state.ShowEditCharacterModal = false;
state.CanEditCharacterOwner = false;
@@ -168,16 +173,24 @@ public sealed class WorkspaceSessionCoordinator(WorkspaceState state, WorkspaceF
state.User = me.User;
state.ActiveCharacterId = me.ActiveCharacterId;
await EnsureScreenAccessAsync();
await EnsureThemePreferenceAsync();
if (!await EnsureRouteAccessAsync())
return true;
if (isAdminRoute())
{
await stopStateEventsAsync();
state.ConnectionState = "offline";
await ensureAdminUsersLoadedAsync();
return true;
}
await LoadRulesetsAsync();
await reloadCampaignsAsync(preferredCampaignId ?? me.CurrentCampaignId);
await reloadCharacterCampaignOptionsAsync();
await refreshCampaignScopeAsync();
await syncStateEventsAsync();
if (state.IsAdminScreen)
await ensureAdminUsersLoadedAsync();
return true;
}
@@ -193,33 +206,17 @@ public sealed class WorkspaceSessionCoordinator(WorkspaceState state, WorkspaceF
}
}
private async Task EnsureScreenAccessAsync()
private async Task<bool> EnsureRouteAccessAsync()
{
if (state.IsCurrentUserAdmin)
return;
if (state.IsCurrentUserAdmin || !isAdminRoute())
{
return true;
}
state.AdminUsers = [];
state.HasLoadedAdminUsers = false;
if (!state.IsAdminScreen)
return;
state.CurrentScreen = ScreenPlay;
await PersistScreenPreferenceAsync(state.CurrentScreen);
}
private async Task PersistScreenPreferenceAsync(string screen)
{
try
{
await js.InvokeVoidAsync("rpgRollerApi.setSessionValue", ScreenSessionKey, screen);
}
catch (JSDisconnectedException)
{
}
catch (InvalidOperationException ex) when (IsStaticRenderInteropException(ex))
{
}
await redirectToPlayAsync();
return false;
}
private static string NormalizeRollVisibility(string? visibility)
@@ -227,29 +224,38 @@ public sealed class WorkspaceSessionCoordinator(WorkspaceState state, WorkspaceF
return string.Equals(visibility, "private", StringComparison.OrdinalIgnoreCase) ? "private" : "public";
}
private static string? NormalizeRequestedScreen(string? screen)
private async Task EnsureThemePreferenceAsync()
{
if (string.Equals(screen, ScreenAdmin, StringComparison.OrdinalIgnoreCase))
return ScreenAdmin;
if (state.User is null)
return;
if (string.Equals(screen, ScreenManagement, StringComparison.OrdinalIgnoreCase))
return ScreenManagement;
var themePreference = state.User.ThemePreference;
if (ThemePreferences.IsSupported(themePreference))
{
state.ThemePreference = ThemePreferences.Normalize(themePreference!);
await js.InvokeVoidAsync("rpgRollerApi.applyTheme", state.ThemePreference);
return;
}
if (string.Equals(screen, ScreenPlay, StringComparison.OrdinalIgnoreCase))
return ScreenPlay;
var systemThemePreference = await js.InvokeAsync<string>("rpgRollerApi.getSystemTheme");
state.ThemePreference = NormalizeThemePreference(systemThemePreference);
await js.InvokeVoidAsync("rpgRollerApi.applyTheme", state.ThemePreference);
return null;
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 bool IsStaticRenderInteropException(InvalidOperationException exception)
private static string NormalizeThemePreference(string? themePreference)
{
return exception.Message.Contains("JavaScript interop calls cannot be issued", StringComparison.OrdinalIgnoreCase);
return ThemePreferences.IsSupported(themePreference) ? ThemePreferences.Normalize(themePreference!) : ThemePreferences.Light;
}
private const string ScreenPlay = "play";
private const string ScreenManagement = "management";
private const string ScreenAdmin = "admin";
private const string ScreenSessionKey = "screen";
private const string CampaignSessionKey = "campaign";
private const string MobilePanelSessionKey = "play-panel";
private const string RollVisibilitySessionKey = "roll-visibility";

View File

@@ -1,4 +1,4 @@
using RpgRoller.Components.Pages.HomeControls;
using RpgRoller.Components.Pages.HomeControls;
using RpgRoller.Contracts;
using RpgRoller.Domain;
@@ -51,6 +51,7 @@ public sealed class WorkspaceState
public RollResult? LastRoll { get; set; }
public List<string> KnownUsernames { get; set; } = [];
public string RollVisibility { get; set; } = "public";
public string ThemePreference { get; set; } = ThemePreferences.Light;
public bool IsMutating { get; set; }
public bool IsCampaignDataLoading { get; set; }
@@ -59,7 +60,6 @@ public sealed class WorkspaceState
public bool HasHealthIssue { get; set; }
public string HealthIssueMessage { get; set; } = "Retry to restore the API connection.";
public List<WorkspaceToast> Toasts { get; } = [];
public string CurrentScreen { get; set; } = "play";
public string MobilePanel { get; set; } = "character";
public string ConnectionState { get; set; } = "offline";
public string LiveAnnouncement { get; set; } = string.Empty;
@@ -88,8 +88,6 @@ public sealed class WorkspaceState
public HashSet<Guid> CampaignLogDetailsLoading { get; } = [];
public Dictionary<Guid, string> CampaignLogDetailErrors { get; } = [];
public string? SelectedCampaignName => SelectedCampaign?.Name ?? Campaigns.FirstOrDefault(campaign => campaign.Id == SelectedCampaignId)?.Name;
public CharacterSummary? SelectedCharacter =>
SelectedCampaign?.Characters.FirstOrDefault(character => character.Id == SelectedCharacterId);
@@ -103,6 +101,9 @@ public sealed class WorkspaceState
if (User is null)
return new(SelectedCampaign.Id, SelectedCampaign.Name, SelectedCampaign.RulesetId, SelectedCampaign.Gm, []);
if (IsCurrentUserGm)
return SelectedCampaign;
var ownedCharacters = SelectedCampaign.Characters.Where(character => character.OwnerUserId == User.Id).ToArray();
return new(SelectedCampaign.Id, SelectedCampaign.Name, SelectedCampaign.RulesetId, SelectedCampaign.Gm, ownedCharacters);
@@ -157,10 +158,6 @@ public sealed class WorkspaceState
public bool IsSelectedCampaignD6 =>
string.Equals(SelectedCampaign?.RulesetId, "d6", StringComparison.OrdinalIgnoreCase);
public bool IsPlayScreen => string.Equals(CurrentScreen, "play", StringComparison.OrdinalIgnoreCase);
public bool IsManagementScreen => string.Equals(CurrentScreen, "management", StringComparison.OrdinalIgnoreCase);
public bool IsAdminScreen => string.Equals(CurrentScreen, "admin", StringComparison.OrdinalIgnoreCase);
public string ConnectionStateLabel => ConnectionState switch
{
"connected" => "Connected",
@@ -175,5 +172,8 @@ public sealed class WorkspaceState
_ => "offline"
};
public string AppCssClass => IsPlayScreen ? "rr-app app-play" : "rr-app";
public string ThemeToggleLabel => ThemePreference == ThemePreferences.Dark ? "⏾" : "☀️";
public string NextThemePreference =>
ThemePreference == ThemePreferences.Dark ? ThemePreferences.Light : ThemePreferences.Dark;
}

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
using System.Text.Json.Serialization;
using System.Text.Json.Serialization;
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 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 UpdateThemePreferenceRequest(string ThemePreference);
public sealed record AdminUserSummary(Guid Id, string Username, string DisplayName, 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;
namespace RpgRoller.Contracts;
@@ -52,6 +52,7 @@ namespace RpgRoller.Contracts;
[JsonSerializable(typeof(UpdateCharacterRequest))]
[JsonSerializable(typeof(UpdateSkillGroupRequest))]
[JsonSerializable(typeof(UpdateSkillRequest))]
[JsonSerializable(typeof(UpdateThemePreferenceRequest))]
[JsonSerializable(typeof(UpdateUserRolesRequest))]
[JsonSerializable(typeof(UserSummary))]
public partial class RpgRollerJsonSerializerContext : JsonSerializerContext

View File

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

View File

@@ -1,4 +1,4 @@
namespace RpgRoller.Domain;
namespace RpgRoller.Domain;
public enum RulesetKind
{
@@ -22,6 +22,7 @@ public sealed class UserAccount
public required string DisplayName { get; set; }
public required string Roles { get; set; }
public Guid? ActiveCharacterId { get; set; }
public string? ThemePreference { get; set; }
}
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)
.HasColumnType("TEXT");
b.Property<string>("ThemePreference")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(64)

View File

@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.ResponseCompression;
using Microsoft.AspNetCore.HttpOverrides;
using RpgRoller.Api;
using RpgRoller.Components;
using RpgRoller.Contracts;
@@ -7,19 +8,24 @@ using RpgRoller.Hosting;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRpgRollerCore(builder.Configuration, builder.Environment);
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 =>
{
options.EnableForHttps = true;
options.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(["application/json"]);
});
builder.Services.ConfigureHttpJsonOptions(options => RpgRollerJson.Configure(options.SerializerOptions));
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<RpgRollerApiClient>();
builder.Services.AddScoped<WorkspaceSessionTokenAccessor>();
builder.Services.AddScoped<WorkspaceQueryService>();
var app = builder.Build();
app.InitializeRpgRollerState();
app.UseForwardedHeaders();
var configuredPathBase = builder.Configuration["PathBase"];
if (!string.IsNullOrWhiteSpace(configuredPathBase))
@@ -37,6 +43,7 @@ app.UseResponseCompression();
app.UseAntiforgery();
app.MapRpgRollerApi();
app.MapFrontendEntryEndpoints();
app.MapStaticAssets();
app.MapRazorComponents<App>().AddInteractiveServerRenderMode();
app.Run();

View File

@@ -1,4 +1,4 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity;
using RpgRoller.Contracts;
using RpgRoller.Domain;
@@ -32,7 +32,8 @@ public sealed class GameAuthService(GameStateStore stateStore, IPasswordHasher<U
DisplayName = displayName.Trim(),
PasswordHash = string.Empty,
Roles = stateStore.UsersById.Count == 0 ? UserRoles.Admin : string.Empty,
ActiveCharacterId = null
ActiveCharacterId = null,
ThemePreference = null
};
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)
{
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))
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 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;
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 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.");
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;
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;
}
@@ -130,7 +137,15 @@ public sealed class GameCharacterService(GameStateStore stateStore, GamePersiste
return ServiceResult<bool>.Failure("character_not_found", "Character was not found.");
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;
persistenceService.PersistStateLocked();
@@ -146,7 +161,9 @@ public sealed class GameCharacterService(GameStateStore stateStore, GamePersiste
if (user is null)
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);
}
@@ -160,11 +177,13 @@ public sealed class GameCharacterService(GameStateStore stateStore, GamePersiste
var campaignId = character.CampaignId;
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)
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)
stateStore.SkillsById.Remove(skillId);

View File

@@ -1,4 +1,4 @@
using RpgRoller.Contracts;
using RpgRoller.Contracts;
using RpgRoller.Domain;
namespace RpgRoller.Services;
@@ -7,7 +7,7 @@ public static class GameDtoMapper
{
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)

View File

@@ -1,4 +1,4 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using RpgRoller.Data;
using RpgRoller.Domain;
@@ -40,7 +40,8 @@ public sealed class GamePersistenceService(IDbContextFactory<RpgRollerDbContext>
PasswordHash = user.PasswordHash,
DisplayName = user.DisplayName,
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.UserIdsByUsername[normalizedUsername] = storedUser.Id;

View File

@@ -1,4 +1,4 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using RpgRoller.Contracts;
using RpgRoller.Data;
@@ -55,6 +55,11 @@ public sealed class GameService : IGameService
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)
{
return m_CampaignService.CreateCampaign(sessionToken, name, rulesetId);

View File

@@ -1,4 +1,4 @@
using RpgRoller.Domain;
using RpgRoller.Domain;
namespace RpgRoller.Services;
@@ -14,7 +14,8 @@ public static class GameStateCloneFactory
PasswordHash = user.PasswordHash,
DisplayName = user.DisplayName,
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;
@@ -11,6 +11,7 @@ public interface IGameService
void Logout(string sessionToken);
UserSummary? GetUserBySession(string sessionToken);
ServiceResult<MeResponse> GetMe(string sessionToken);
ServiceResult<UserSummary> UpdateThemePreference(string sessionToken, string themePreference);
ServiceResult<CampaignSummary> CreateCampaign(string sessionToken, string name, string rulesetId);
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 stateStream = {
source: null,
@@ -22,6 +22,18 @@ window.rpgRollerApi = (() => {
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() {
if (stateStream.reconnectTimer) {
clearTimeout(stateStream.reconnectTimer);
@@ -141,7 +153,7 @@ window.rpgRollerApi = (() => {
let response;
try {
response = await fetch(toAppUrl(url), options);
} catch (error) {
} catch {
return {
ok: false,
status: 0,
@@ -214,8 +226,179 @@ window.rpgRollerApi = (() => {
element.value = "";
}
function initializeAuthPage() {
const root = document.querySelector("[data-auth-page]");
if (!root) {
return;
}
const statusElement = root.querySelector("[data-auth-status]");
const forms = root.querySelectorAll("[data-auth-form]");
forms.forEach((form) => {
form.addEventListener("submit", async (event) => {
event.preventDefault();
await submitAuthForm(root, form, statusElement);
});
});
}
function clearAuthErrors(root) {
const statusElement = root.querySelector("[data-auth-status]");
if (statusElement) {
statusElement.hidden = true;
statusElement.textContent = "";
statusElement.classList.remove("error", "success");
}
root.querySelectorAll("[data-form-error], [data-field-error]").forEach((element) => {
element.hidden = true;
element.textContent = "";
});
}
function setAuthStatus(statusElement, message, isError) {
if (!statusElement) {
return;
}
statusElement.hidden = !message;
statusElement.textContent = message || "";
statusElement.classList.toggle("error", !!message && isError);
statusElement.classList.toggle("success", !!message && !isError);
}
function setFormError(form, message) {
const errorElement = form.querySelector("[data-form-error]");
if (!errorElement) {
return;
}
errorElement.hidden = !message;
errorElement.textContent = message || "";
}
function setFieldError(form, fieldName, message) {
const errorElement = form.querySelector(`[data-field-error="${fieldName}"]`);
if (!errorElement) {
return;
}
errorElement.hidden = !message;
errorElement.textContent = message || "";
}
function setInvalidCredentialsErrors(form) {
setFieldError(form, "username", "Invalid username or password.");
setFieldError(form, "password", "Invalid username or password.");
}
function readFormData(form) {
return Object.fromEntries(new FormData(form).entries());
}
function validateAuthForm(formType, payload) {
const errors = {};
if (!payload.username || !payload.username.trim()) {
errors.username = "Username is required.";
}
if (formType === "register") {
if (!payload.displayName || !payload.displayName.trim()) {
errors.displayName = "Display name is required.";
}
if (!payload.password || payload.password.length < 8) {
errors.password = "Password must be at least 8 characters.";
}
} else if (!payload.password) {
errors.password = "Password is required.";
}
return errors;
}
function setSubmitting(form, isSubmitting) {
const submitButton = form.querySelector("button[type=\"submit\"]");
if (!submitButton) {
return;
}
submitButton.disabled = isSubmitting;
submitButton.textContent = isSubmitting
? submitButton.dataset.submittingLabel || submitButton.textContent
: submitButton.dataset.submitLabel || submitButton.textContent;
}
async function submitAuthForm(root, form, statusElement) {
clearAuthErrors(root);
const formType = form.dataset.authForm;
const payload = readFormData(form);
const errors = validateAuthForm(formType, payload);
Object.entries(errors).forEach(([fieldName, message]) => {
setFieldError(form, fieldName, message);
});
if (Object.keys(errors).length > 0) {
setAuthStatus(statusElement, "Resolve validation issues before submitting.", true);
setFormError(form, "Resolve validation issues before submitting.");
return;
}
const endpoint = formType === "register" ? "/api/auth/register" : "/api/auth/login";
const requestBody = formType === "register"
? {
username: payload.username.trim(),
displayName: payload.displayName.trim(),
password: payload.password
}
: {
username: payload.username.trim(),
password: payload.password
};
setSubmitting(form, true);
try {
const response = await request("POST", endpoint, requestBody);
if (!response.ok) {
setAuthStatus(statusElement, response.error || "Request failed.", true);
if (formType === "register" && response.code === "duplicate_username") {
setFieldError(form, "username", "Username is already taken. Choose another one.");
} else if (formType === "login" && response.code === "invalid_credentials") {
setInvalidCredentialsErrors(form);
} else {
setFormError(form, response.error || "Request failed.");
}
setFormError(form, response.error || "Request failed.");
return;
}
if (formType === "login") {
window.location.assign(toAppUrl("/play"));
return;
}
form.reset();
setAuthStatus(statusElement, "Registration successful. You can log in now.", false);
} finally {
setSubmitting(form, false);
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initializeAuthPage, { once: true });
} else {
initializeAuthPage();
}
return {
request,
applyTheme,
getSystemTheme,
getSessionValue,
setSessionValue,
startStateEvents,

View File

@@ -1,8 +1,9 @@
:root {
:root {
color-scheme: light;
--bg-top: #f7f0d8;
--bg-bottom: #ecdfc4;
--button-hover: #dccfb4;
--card: #fffaf0;
--card: #fffaf0e0;
--card-border: #c3b28b;
--text: #2b2418;
--muted: #6a5b3f;
@@ -14,6 +15,147 @@
--public: #2d6645;
--private-self: #4f3a8f;
--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%;
}
html {
background-image: var(--page-background);
background-position: center;
background-repeat: no-repeat;
background-size: cover;
background-attachment: fixed;
}
body {
background: radial-gradient(circle at 15% 10%, rgba(255, 255, 255, 0.32), transparent 45%),
linear-gradient(165deg, var(--bg-top), var(--bg-bottom));
background: transparent;
color: var(--text);
font-family:
"Baloo 2",
@@ -93,7 +242,7 @@ h3 {
top: 0;
z-index: 10;
display: flex;
background: linear-gradient(120deg, #f1e4c9, #efe0bf);
background: var(--header-bg);
border: 1px solid var(--card-border);
border-radius: 0.8rem;
padding: 0.5rem 0.7rem;
@@ -113,14 +262,33 @@ h3 {
font-size: 1.15rem;
}
.header-identity,
.header-campaign {
.header-identity {
margin: 0;
white-space: nowrap;
}
.header-campaign {
display: flex;
align-items: center;
gap: 0.35rem;
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 {
@@ -139,7 +307,7 @@ h3 {
}
.card {
background: color-mix(in srgb, var(--card) 94%, #ffffff 6%);
background: var(--card);
border: 1px solid var(--card-border);
border-radius: 0.8rem;
padding: 0.7rem;
@@ -149,6 +317,20 @@ h3 {
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 {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -169,19 +351,19 @@ select,
button {
font: inherit;
border-radius: 0.45rem;
border: 1px solid #8e7b57;
border: 1px solid var(--input-border);
padding: 0.55rem 0.65rem;
}
input,
select {
background: #fffdf5;
background: var(--input-bg);
color: var(--text);
}
button {
background: linear-gradient(180deg, var(--accent), #2f4f34);
color: #f8f7ef;
background: linear-gradient(180deg, var(--accent), var(--accent-dark));
color: var(--button-text);
border-color: transparent;
cursor: pointer;
}
@@ -189,19 +371,19 @@ button {
button.ghost {
background: transparent;
color: var(--text);
border-color: #8e7b57;
border-color: var(--input-border);
}
button.switch {
background: transparent;
color: var(--text);
border-color: #8e7b57;
border-color: var(--input-border);
}
button.switch.active {
background: var(--accent-2);
border-color: var(--accent-2);
color: #fff9ef;
color: var(--switch-active-text);
}
button:disabled {
@@ -287,12 +469,12 @@ select:focus-visible {
white-space: nowrap;
background: transparent;
color: var(--text);
border-color: #8e7b57;
border-color: var(--input-border);
}
.icon-tab.active {
background: linear-gradient(145deg, #e9d4a4, #d7b672);
border-color: #9e7328;
background: var(--tab-active-bg);
border-color: var(--tab-active-border);
}
.icon-tab-glyph {
@@ -302,7 +484,7 @@ select:focus-visible {
align-items: center;
justify-content: center;
border-radius: 50%;
border: 1px solid #8e7b57;
border: 1px solid var(--input-border);
font-weight: 700;
font-size: 0.72rem;
}
@@ -313,7 +495,7 @@ select:focus-visible {
}
.skills-section {
border: 1px dashed #a89066;
border: 1px dashed var(--section-border);
border-radius: 0.65rem;
padding: 0.55rem;
display: flex;
@@ -374,8 +556,9 @@ select:focus-visible {
display: flex;
align-items: center;
justify-content: space-between;
background-color: #f8f0de;
padding-left: 0.1rem;
background-color: var(--skill-group-bg);
padding: 0.1rem;
border-top: 1px solid var(--card-border);
gap: 0.5rem;
}
@@ -416,11 +599,11 @@ select:focus-visible {
border-radius: 999px;
background: transparent;
color: var(--text);
border-color: #decbb7;
border-color: var(--chip-border);
}
.chip-button:hover {
border-color: #8e7b57;
border-color: var(--input-border);
background: var(--button-hover);
}
@@ -428,13 +611,13 @@ select:focus-visible {
grid-template-columns: auto 1fr;
align-items: center;
justify-items: start;
background: #00000000;
background: transparent;
}
.skill-create-icon {
width: 1.45rem;
height: 1.45rem;
border: 1px solid #8e7b57;
border: 1px solid var(--input-border);
border-radius: 999px;
display: inline-flex;
align-items: center;
@@ -479,7 +662,7 @@ select:focus-visible {
.menu-toggle {
background: transparent;
color: var(--text);
border-color: #8e7b57;
border-color: var(--input-border);
display: inline-flex;
gap: 0.4rem;
align-items: center;
@@ -496,12 +679,12 @@ select:focus-visible {
z-index: 40;
min-width: 14.5rem;
padding: 0.35rem;
background: #fff8ea;
background: var(--card-strong);
border: 1px solid var(--card-border);
border-radius: 0.55rem;
display: grid;
gap: 0.3rem;
box-shadow: 0 8px 16px rgba(34, 24, 9, 0.2);
box-shadow: 0 8px 16px var(--menu-shadow);
}
.menu-item {
@@ -509,12 +692,12 @@ select:focus-visible {
text-align: left;
background: transparent;
color: var(--text);
border-color: #8e7b57;
border-color: var(--input-border);
}
.menu-item.active {
background: #ecd8ae;
border-color: #9a7f43;
background: var(--button-hover);
border-color: var(--tab-active-border);
}
.logout-link {
@@ -525,7 +708,7 @@ select:focus-visible {
}
.logout-link:hover {
color: #6b2419;
color: var(--accent-2-hover);
}
.logout-link:focus-visible {
@@ -533,6 +716,22 @@ select:focus-visible {
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 {
font-size: 1.8rem;
font-weight: 800;
@@ -556,10 +755,10 @@ select:focus-visible {
min-width: 2.1rem;
height: 2.1rem;
padding: 0.2rem 0.45rem 0;
border: 2px solid #2a2418;
border: 2px solid var(--die-border);
border-radius: 0.45rem;
background: #ffffff;
color: #1f1a13;
background: var(--die-bg);
color: var(--die-text);
font-size: 2rem;
line-height: 1;
font-variant-numeric: tabular-nums;
@@ -567,27 +766,27 @@ select:focus-visible {
.die-chip.wild {
border-width: 3px;
border-color: #c79913;
border-color: var(--die-wild);
}
.die-chip.crit {
background: #d8ffc2;
color: #18490f;
background: var(--die-crit-bg);
color: var(--die-crit-text);
}
.die-chip.fumble {
background: #ffb5a8;
color: #661110;
background: var(--die-fumble-bg);
color: var(--die-fumble-text);
}
.die-chip.added {
background: #dbffdf;
color: #206029;
background: var(--die-added-bg);
color: var(--die-added-text);
}
.die-chip.removed {
background: #fde0dd;
color: #7f5f55;
background: var(--die-removed-bg);
color: var(--die-removed-text);
border-style: dashed;
}
@@ -605,20 +804,20 @@ select:focus-visible {
.die-chip.rolemaster-initiative,
.die-chip.rolemaster-percentile,
.die-chip.rolemaster-open-ended-initial {
background: #f8f1df;
color: #3f2f12;
background: var(--die-neutral-bg);
color: var(--die-neutral-text);
}
.die-chip.rolemaster-open-ended-high {
background: #dff6df;
color: #1d5b26;
border-color: #2a7c39;
background: var(--die-open-high-bg);
color: var(--die-open-high-text);
border-color: var(--die-open-high-border);
}
.die-chip.rolemaster-open-ended-low-subtract {
background: #ffe1dc;
color: #8a2217;
border-color: #b74334;
background: var(--die-open-low-bg);
color: var(--die-open-low-text);
border-color: var(--die-open-low-border);
}
.empty,
@@ -635,11 +834,11 @@ select:focus-visible {
}
.custom-roll-panel {
border-top: 1px solid color-mix(in srgb, var(--card-border) 72%, #ffffff 28%);
background: color-mix(in srgb, var(--card) 88%, #ffffff 12%);
border-top: 1px solid color-mix(in srgb, var(--card-border) 72%, var(--surface-mix) 28%);
background: color-mix(in srgb, var(--card) 88%, var(--surface-mix) 12%);
border-radius: 0.95rem;
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 {
@@ -671,14 +870,14 @@ select:focus-visible {
min-width: 0;
padding: 0.72rem 0.9rem;
border-radius: 999px;
border: 1px solid color-mix(in srgb, var(--card-border) 78%, #ffffff 22%);
background: color-mix(in srgb, var(--card) 90%, #ffffff 10%);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6);
border: 1px solid color-mix(in srgb, var(--card-border) 78%, var(--surface-mix) 22%);
background: color-mix(in srgb, var(--card) 90%, var(--surface-mix) 10%);
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;
}
.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) {
@@ -686,9 +885,9 @@ select:focus-visible {
}
.custom-roll-input.error {
border-color: color-mix(in srgb, var(--danger) 74%, #6b2015 26%);
background: color-mix(in srgb, #fff0ee 84%, var(--card) 16%);
box-shadow: 0 0 0 3px rgba(181, 58, 35, 0.12);
border-color: color-mix(in srgb, var(--danger) 74%, var(--custom-roll-error-border) 26%);
background: color-mix(in srgb, var(--custom-roll-error-bg) 84%, var(--card) 16%);
box-shadow: 0 0 0 3px var(--custom-roll-error-shadow);
}
.custom-roll-composer-row button {
@@ -698,11 +897,11 @@ select:focus-visible {
}
.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;
background: color-mix(in srgb, var(--card) 96%, #ffffff 4%);
background: color-mix(in srgb, var(--card) 96%, var(--surface-mix) 4%);
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;
}
@@ -721,23 +920,23 @@ select:focus-visible {
.log-entry:hover {
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 {
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 {
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 {
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 {
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 {
@@ -745,12 +944,12 @@ select:focus-visible {
}
.log-entry.fresh {
border-color: color-mix(in srgb, #c79913 52%, var(--card-border) 48%);
box-shadow: 0 0.9rem 1.8rem rgba(199, 153, 19, 0.16);
border-color: color-mix(in srgb, var(--die-wild) 52%, var(--card-border) 48%);
box-shadow: 0 0.9rem 1.8rem var(--fresh-shadow);
}
.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 {
@@ -814,21 +1013,21 @@ select:focus-visible {
}
.log-event-badge.positive {
border-color: #79a85d;
background: #e7f6da;
color: #235217;
border-color: var(--success-border);
background: var(--success-bg);
color: var(--success-text);
}
.log-event-badge.danger {
border-color: #c56b5a;
background: #ffe3dc;
color: #7d1f17;
border-color: var(--error-border);
background: var(--error-bg);
color: var(--error-text);
}
.log-event-badge.rare {
border-color: #b48b34;
background: #fff1c7;
color: #6d4c05;
border-color: var(--rare-border);
background: var(--rare-bg);
color: var(--rare-text);
}
.log-meta {
@@ -844,7 +1043,7 @@ select:focus-visible {
margin: 0 0.65rem 0.65rem;
padding: 0.7rem 0.8rem 0.75rem;
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;
}
@@ -863,31 +1062,31 @@ select:focus-visible {
}
.badge.active {
border-color: #8f5f12;
background: #f6d28d;
color: #5d3808;
border-color: var(--active-border);
background: var(--active-bg);
color: var(--active-text);
}
.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);
border-color: color-mix(in srgb, var(--public) 34%, transparent 66%);
}
.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);
border-color: color-mix(in srgb, var(--private-self) 30%, transparent 70%);
}
.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);
border-color: color-mix(in srgb, var(--private-gm) 30%, transparent 70%);
}
.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);
border-color: color-mix(in srgb, var(--muted) 28%, transparent 72%);
}
@@ -952,7 +1151,7 @@ select:focus-visible {
.skeleton-line {
height: 0.85rem;
border-radius: 0.4rem;
background: linear-gradient(90deg, #dfd2b7, #efe3c9, #dfd2b7);
background: var(--skeleton-bg);
background-size: 220% 100%;
animation: shimmer 1.1s linear infinite;
}
@@ -962,8 +1161,8 @@ select:focus-visible {
}
.health-banner {
border: 1px solid #b77a29;
background: #fff2db;
border: 1px solid var(--health-border);
background: var(--health-bg);
border-radius: 0.75rem;
padding: 0.75rem;
display: flex;
@@ -985,7 +1184,7 @@ select:focus-visible {
justify-content: space-between;
align-items: center;
gap: 0.5rem;
border: 1px solid #b8a37b;
border: 1px solid var(--card-border);
border-radius: 0.55rem;
padding: 0.5rem;
}
@@ -1006,9 +1205,9 @@ select:focus-visible {
gap: 0.45rem;
align-self: flex-start;
padding: 0.55rem 0.65rem;
border: 1px solid #b39f79;
border: 1px solid var(--card-border);
border-radius: 0.45rem;
background: #f9f2e2;
background: var(--input-bg);
color: var(--text);
font-weight: 700;
text-decoration: none;
@@ -1028,15 +1227,15 @@ select:focus-visible {
align-items: center;
gap: 0.45rem;
align-self: flex-start;
background: #f9f2e2;
background: var(--input-bg);
color: var(--text);
border: 1px solid #b39f79;
border: 1px solid var(--card-border);
}
.add-row-icon {
width: 1.2rem;
height: 1.2rem;
border: 1px solid #8e7b57;
border: 1px solid var(--input-border);
border-radius: 999px;
display: inline-flex;
align-items: center;
@@ -1047,7 +1246,7 @@ select:focus-visible {
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(35, 25, 9, 0.55);
background: var(--modal-overlay);
display: grid;
place-items: center;
z-index: 20;
@@ -1077,7 +1276,7 @@ select:focus-visible {
padding: 0.55rem;
display: none;
gap: 0.45rem;
background: rgba(241, 228, 201, 0.96);
background: var(--mobile-nav-bg);
border-top: 1px solid var(--card-border);
}
@@ -1095,7 +1294,7 @@ select:focus-visible {
border-radius: 0.6rem;
border: 1px solid;
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);
}
@@ -1105,15 +1304,15 @@ select:focus-visible {
}
.toast.success {
background: #e8f7e8;
border-color: #78a978;
color: #1f5425;
background: var(--success-bg);
border-color: var(--success-border);
color: var(--success-text);
}
.toast.error {
background: #ffe9e5;
border-color: #bb6e62;
color: #7f2015;
background: var(--error-bg);
border-color: var(--error-border);
color: var(--error-text);
}
.sr-only {
@@ -1172,6 +1371,15 @@ select:focus-visible {
white-space: normal;
}
.header-campaign {
flex-wrap: wrap;
min-width: 0;
}
.header-campaign select {
max-width: 100%;
}
.mobile-bottom-nav {
display: flex;
}

198
TASKS.md
View File

@@ -1,198 +0,0 @@
# Rolemaster skill roll situational modifier modal
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, clicking the dice button for any Rolemaster skill on the play screen will no longer roll immediately. Instead, a modal dialog will open first and ask for a one-time situational modifier for that upcoming roll. The player can leave it blank for zero, enter a positive number such as `20` for a bonus, or enter a negative number such as `-15` for a penalty. Pressing Enter will confirm the roll, pressing Escape will cancel it, and clicking outside the modal will also cancel it.
The important user-visible rule is that this temporary modifier must be applied everywhere the skill roll logic already uses the skills built-in modifier. For a skill stored as `d100!+50`, entering `20` means the first Rolemaster attempt is evaluated as `roll + 50 + 20`, not as a post-processing adjustment. That means an initial result of `8` becomes `8+50+20=78`, which falls into the existing automatic retry band and therefore triggers the retry flow. The retry attempt must also include the same `+20` situational modifier.
## Progress
- [x] (2026-04-14 21:27:50Z) Created the initial ExecPlan in `TASKS.md`, grounded in the current workspace play flow, API contract, and Rolemaster retry implementation.
- [x] (2026-04-14 21:39:42Z) Added transient `SituationalModifier` support to the skill-roll request, API endpoint, service facade, and roll pipeline without adding persistence or schema changes.
- [x] (2026-04-14 21:51:33Z) Added a Rolemaster-only pre-roll modal on the play screen with autofocus, Escape dismissal, Enter submit, outside-click dismissal, and inline validation for signed integer input.
- [x] (2026-04-14 21:39:42Z) Updated Rolemaster roll execution and breakdown formatting so temporary modifiers are shown explicitly and feed retry-band evaluation plus retry attempts.
- [x] (2026-04-14 21:51:33Z) Added service, API, and Playwright coverage for the new behavior, updated `README.md`, and prepared the touched files for cleanup, full CI, and commit.
## Surprises & Discoveries
- Observation: `TASKS.md` was empty before this plan was written, so this ExecPlan now defines the full intended work from scratch.
Evidence: `Get-Item D:\Code\RpgRoller\TASKS.md | Format-List Length` reported `Length : 0`.
- Observation: the situational modifier fit cleanly as transient request data. No `Skill`, `RollLogEntry`, migration, or EF model change was needed for the first implementation slice.
Evidence: the change only touched `RollSkillRequest`, the roll endpoint/service path, and Rolemaster roll formatting/execution files.
- Observation: the current Rolemaster retry rule is already based on the fully computed first attempt total, not just the raw die result, which matches the new requirement once the temporary modifier is included in that total.
Evidence: `RpgRoller/Services/RolemasterRollEngine.cs` resolves retry bands from `firstAttempt.Total`.
- Observation: the repository already uses Blazor modal patterns with overlays and `ElementReference.FocusAsync()` for autofocus, so the new modal can follow an existing local pattern instead of inventing a second approach.
Evidence: `RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor(.cs)` renders a modal and focuses the name input in `OnAfterRenderAsync`.
- Observation: compact Rolemaster retry summaries still preview the trigger die, not the fully modified first-attempt arithmetic. The authoritative arithmetic belongs in the breakdown string.
Evidence: with a situational modifier, the new service test now expects `8 | open-ended | retry +5` in the compact summary while the detailed breakdown is `8+50+20=78; retry(+5): 42+50+20=112; final=117`.
- Observation: Razor string parameters in the new modal call site need explicit `@` binding or the UI renders the property name as literal text.
Evidence: the first Playwright failure snapshot showed the dialog rendering `State.PendingRolemasterSkillRollError` instead of the actual inline validation message until the binding was corrected in `Workspace.razor`.
## Decision Log
- Decision: the situational modifier will be transient request data only and will not be stored on `Skill`, `RollLogEntry`, or in a migration.
Rationale: the feature is explicitly “once for the upcoming roll.” Persisting it would create stale state, require schema work, and misrepresent the feature.
Date/Author: 2026-04-14 / Codex
- Decision: `RollSkillRequest` will gain an integer `SituationalModifier` field with a default of `0`, and server-side skill-roll methods will accept the same value.
Rationale: zero is the normal case, avoids null semantics through the stack, and keeps the request payload simple for both tests and UI code.
Date/Author: 2026-04-14 / Codex
- Decision: non-zero situational modifiers will be accepted only for Rolemaster skill rolls. Non-Rolemaster skill rolls will continue to execute immediately without showing the modal, and the server will reject any accidental non-zero modifier sent for another ruleset.
Rationale: the user asked for this behavior only for the Rolemaster system. The server-side guard prevents future UI regressions from silently broadening the feature.
Date/Author: 2026-04-14 / Codex
- Decision: the modal input will be stored as raw text in UI state and parsed on confirm rather than bound directly to an `int`.
Rationale: blank must mean zero, signed values must be easy to type, and raw text avoids awkward intermediate states such as a lone `-` while the user is editing.
Date/Author: 2026-04-14 / Codex
- Decision: Rolemaster breakdown strings will show the base skill modifier and the one-shot situational modifier as separate visible terms instead of folding them into one combined number.
Rationale: the user needs to audit why a retry happened. `8+50+20=78` is clearer than `8+70=78`, especially when comparing the stored skill expression with the one-time adjustment.
Date/Author: 2026-04-14 / Codex
- Decision: compact log badges do not need a new “situational modifier” badge.
Rationale: the result number already changes, the detailed breakdown will show the exact temporary modifier, and adding a new badge for a one-shot value would create clutter with little value.
Date/Author: 2026-04-14 / Codex
## Outcomes & Retrospective
The feature is now complete end to end. Rolemaster skill rolls no longer execute immediately from the play screen; they first open a modal that accepts an optional one-shot situational modifier, focuses the input automatically, closes on Escape or backdrop click, and validates whole-number input inline. Confirmed rolls send the temporary modifier through the existing skill-roll API, Rolemaster breakdowns show the base and situational modifiers as separate terms, and automatic retry math reuses the same situational modifier on both attempts. Service, API, and Playwright coverage now prove the backend math, the Rolemaster-only guard, and the browser interaction flow.
## Context and Orientation
This repository is an ASP.NET Core and Blazor application rooted at `D:\Code\RpgRoller`. The user-facing play screen is assembled in `RpgRoller/Components/Pages/Workspace.razor`. That page wires `CharacterPanel` to the coordinator class `RpgRoller/Components/Pages/WorkspacePlayCoordinator.cs`, which currently handles skill rolls by calling `Play.RollSkillAsync`.
The skill list and its dice buttons live in `RpgRoller/Components/Pages/HomeControls/SkillGroupBlock.razor`. Each button currently emits only the `Guid` skill identifier. `RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor` and `CharacterPanel.razor.cs` forward that identifier upward through the `RollRequested` callback without opening any pre-roll UI.
The request contract for a skill roll lives in `RpgRoller/Contracts/ApiContracts.cs` as `RollSkillRequest`. The HTTP endpoint is `POST /api/skills/{skillId}/roll` in `RpgRoller/Api/SkillEndpoints.cs`. The service contract is `IGameService.RollSkill(...)` in `RpgRoller/Services/IGameService.cs`, implemented by `RpgRoller/Services/GameService.cs`, and executed by `RpgRoller/Services/GameRollService.cs`.
Rolemaster rolling behavior is implemented in `RpgRoller/Services/RollEngine.cs`, `RolemasterRollEngine.cs`, `RolemasterRetryPolicy.cs`, and `RollBreakdownFormatter.cs`. A “situational modifier” in this plan means a temporary integer that is added to or subtracted from the stored skill expression for one roll only. The stored skill expression is still the canonical thing saved on the skill, such as `d100!+50`. The situational modifier exists only in the request that triggers one roll and in the recorded breakdown text that explains that roll afterward.
UI and regression coverage already exist in the repository areas that matter here. `RpgRoller.Tests/Services/ServiceRolemasterRollTests.cs` covers Rolemaster engine behavior. `RpgRoller.Tests/Api/RolemasterApiTests.cs` covers Rolemaster HTTP behavior. `tests/e2e/smoke.spec.js` covers the browser play flow and already contains Rolemaster smoke tests, including the automatic retry badge path. These are the places to extend rather than creating disconnected new test files.
## Plan of Work
Start by widening the skill-roll request path, but keep the feature transient. In `RpgRoller/Contracts/ApiContracts.cs`, change `RollSkillRequest` so it carries both `Visibility` and `SituationalModifier`, defaulting the latter to `0`. Update `RpgRoller/Contracts/RpgRollerJsonSerializerContext.cs` only if the source generator needs to reflect the changed shape. Then thread the new integer through `RpgRoller/Api/SkillEndpoints.cs`, `RpgRoller/Services/IGameService.cs`, `RpgRoller/Services/GameService.cs`, and `RpgRoller/Services/GameRollService.cs`. In `GameRollService.RollSkill`, resolve the campaign and parsed expression exactly as today, then reject a non-zero situational modifier unless the campaign ruleset is Rolemaster. Reuse the existing authorization and visibility checks unchanged. No database or domain model changes are needed for this part.
After the request pipeline can accept the modifier, update the Rolemaster execution path so the temporary value participates in the actual roll math rather than being bolted on afterward. The cleanest repository-local shape is to extend `RollEngine.Roll(...)` and `RolemasterRollEngine.Roll(...)` with an optional `situationalModifier = 0` argument. Only the Rolemaster branch should consume it. In `RolemasterRollEngine`, add the situational modifier to the expression modifier for both standard Rolemaster rolls and open-ended percentile attempts. The first attempt total that feeds `RolemasterRetryPolicy.ResolveAutoRetryBonus(...)` must already include both the stored skill modifier and the situational modifier. The retry attempt must use the same situational modifier again. Update `RollBreakdownFormatter` so Rolemaster text remains explicit, for example `08+50+20=78` for a normal positive path and `(05) -97 +50 +20 = -22` for a low-end path. The retry breakdown must also preserve this explicit style, for example `08+50+20=78; retry(+5): 42+50+20=112; final=117`.
Then add the pre-roll modal to the workspace play flow. Keep the state and orchestration in the existing play coordinator rather than letting `CharacterPanel` call the API itself. Change the roll callback path from `Guid` to `CharacterSheetSkill` so the coordinator has the skill name and expression immediately. Update `RpgRoller/Components/Pages/HomeControls/SkillGroupBlock.razor(.cs)` and `CharacterPanel.razor(.cs)` to pass the full skill object upward. In `RpgRoller/Components/Pages/WorkspaceState.cs`, add modal state for whether the prompt is open, which skill is pending, the pending raw modifier text, whether the modal is submitting, and the current validation message. In `RpgRoller/Components/Pages/WorkspacePlayCoordinator.cs`, replace the direct-roll entry point with a Rolemaster-aware method that either opens the modal or immediately rolls with modifier `0` for other rulesets. Add companion methods to confirm and cancel the pending Rolemaster roll.
Render the modal near the bottom of `RpgRoller/Components/Pages/Workspace.razor`, alongside the existing character modals, so it shares the same page-level ownership as other overlays. Create a dedicated component pair at `RpgRoller/Components/Pages/HomeControls/RolemasterSkillRollModal.razor` and `RolemasterSkillRollModal.razor.cs` instead of expanding `CharacterPanel` further. The modal should show the skill name, the stored expression, a short help line explaining that blank means zero and negative numbers are allowed, and a single signed-number input. The input should autofocus via `ElementReference.FocusAsync()`. Escape should cancel. Enter should submit through the form. Clicking the overlay outside the dialog card should cancel, while clicking inside the card must not bubble out. Use the existing `.modal-overlay` and `.modal-card` styling patterns first; only add CSS in `RpgRoller/wwwroot/styles.css` if the modal needs a small amount of spacing or width tuning.
Validation belongs in both UI and server code. The modal should trim whitespace and treat an empty field as `0`. If parsing fails, keep the modal open and show an inline error such as “Enter a whole number like 20, -15, or leave blank for 0.” On the server, reject values outside the same Rolemaster modifier limits already enforced by `DiceRules`, namely `-1000` through `1000`. Reuse the existing API error path so invalid requests still surface as user-facing errors without breaking the page.
Finally, update documentation and tests. `README.md` must describe the new Rolemaster roll flow in current-state language, not as a changelog note. Extend service tests to prove that a situational modifier changes Rolemaster totals, triggers retry evaluation from the combined total, and applies again on the retry attempt. Extend API tests to prove the new request payload, server-side Rolemaster-only validation, and breakdown text. Extend Playwright smoke coverage so the browser proves the modal opens only on Rolemaster skill rolls, autofocus works, Enter submits, Escape and outside click dismiss, invalid text stays inline, and a positive situational bonus can be used to cause a retry-enabled result.
## Concrete Steps
All commands below run from `D:\Code\RpgRoller`.
First, implement the request and engine changes, then run targeted tests while the work is still small.
dotnet test RpgRoller.Tests/RpgRoller.Tests.csproj --filter "FullyQualifiedName~ServiceRolemasterRollTests|FullyQualifiedName~RolemasterApiTests"
After the UI modal is in place, run the browser smoke suite directly.
pwsh ./scripts/run-playwright.ps1
Before closing the iteration, format every touched file using the repository rule. Replace the placeholder list with the exact touched file paths separated by semicolons.
jb cleanupcode --build=False RpgRoller/Contracts/ApiContracts.cs;RpgRoller/Api/SkillEndpoints.cs;RpgRoller/Services/IGameService.cs;RpgRoller/Services/GameService.cs;RpgRoller/Services/GameRollService.cs;RpgRoller/Services/RollEngine.cs;RpgRoller/Services/RolemasterRollEngine.cs;RpgRoller/Services/RollBreakdownFormatter.cs;RpgRoller/Components/Pages/Workspace.razor;RpgRoller/Components/Pages/WorkspaceState.cs;RpgRoller/Components/Pages/WorkspacePlayCoordinator.cs;RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor;RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor.cs;RpgRoller/Components/Pages/HomeControls/SkillGroupBlock.razor;RpgRoller/Components/Pages/HomeControls/SkillGroupBlock.razor.cs;RpgRoller/Components/Pages/HomeControls/RolemasterSkillRollModal.razor;RpgRoller/Components/Pages/HomeControls/RolemasterSkillRollModal.razor.cs;RpgRoller/wwwroot/styles.css;RpgRoller.Tests/Services/ServiceRolemasterRollTests.cs;RpgRoller.Tests/Api/RolemasterApiTests.cs;tests/e2e/smoke.spec.js;README.md
Run the repository-wide local CI script as the final proof.
pwsh ./scripts/ci-local.ps1
Then create one brief commit for the iteration.
git add TASKS.md README.md RpgRoller/Contracts/ApiContracts.cs RpgRoller/Api/SkillEndpoints.cs RpgRoller/Services/IGameService.cs RpgRoller/Services/GameService.cs RpgRoller/Services/GameRollService.cs RpgRoller/Services/RollEngine.cs RpgRoller/Services/RolemasterRollEngine.cs RpgRoller/Services/RollBreakdownFormatter.cs RpgRoller/Components/Pages/Workspace.razor RpgRoller/Components/Pages/WorkspaceState.cs RpgRoller/Components/Pages/WorkspacePlayCoordinator.cs RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor.cs RpgRoller/Components/Pages/HomeControls/SkillGroupBlock.razor RpgRoller/Components/Pages/HomeControls/SkillGroupBlock.razor.cs RpgRoller/Components/Pages/HomeControls/RolemasterSkillRollModal.razor RpgRoller/Components/Pages/HomeControls/RolemasterSkillRollModal.razor.cs RpgRoller/wwwroot/styles.css RpgRoller.Tests/Services/ServiceRolemasterRollTests.cs RpgRoller.Tests/Api/RolemasterApiTests.cs tests/e2e/smoke.spec.js
git commit -m "Add Rolemaster situational roll modifier prompt"
Expected proof points during implementation are:
A Rolemaster skill such as d100!+50 opens the modal instead of rolling immediately.
Leaving the field blank and pressing Enter records a normal roll.
Typing 20 and pressing Enter records a breakdown that visibly contains +20.
If the first attempt becomes 76 through 110 after adding the situational modifier, the existing retry flow still fires.
A D6 or D&D 5e skill still rolls immediately with no popup.
## Validation and Acceptance
Acceptance is behavioral, not just “the code compiles.”
Start the browser smoke environment with `pwsh ./scripts/run-playwright.ps1` or run the application locally and navigate to the play screen. Create or seed a Rolemaster campaign, a character, and an open-ended skill such as `Observation` with `d100!+50`, `fumbleRange: 5`, and `rolemasterAutoRetry: true`.
On the play screen, clicking `Roll Observation` must open a modal dialog. The modifier input must receive focus immediately. Pressing Escape must close the dialog with no roll recorded. Clicking the backdrop outside the dialog must also close it with no roll recorded. Reopening the dialog and pressing Enter with the field blank must submit a normal zero-modifier roll.
Reopen the dialog and enter `20`. After confirming, the recorded roll detail must show the temporary modifier as a separate term in the breakdown. If the first computed attempt lands in the retry window, the breakdown must show the same `+20` in both attempts and the final total must reflect the retry bonus on top of the retry attempt. The compact log entry should still show the existing retry badge behavior, and expanding the detail should show the existing attempt markers on the dice chips.
Run `dotnet test RpgRoller.Tests/RpgRoller.Tests.csproj` and expect the suite to pass. The new or updated tests should fail before the feature is implemented and pass after it is complete. Run `pwsh ./scripts/ci-local.ps1` and expect the build, tests, coverage gate, and Playwright smoke test to pass end-to-end.
## Idempotence and Recovery
This feature should be implemented additively and is safe to retry. Re-running the same code-edit steps should only replace the intended current-state logic. No migration is expected for this feature because the modifier is transient. If a draft implementation accidentally introduces persistence for the modifier, remove that persistence before considering the work complete.
If the UI modal gets into a bad state during development, the safe recovery path is to clear only the workspace prompt state in `WorkspaceState` and retry the interaction. If Playwright fails because the temporary application process is still running, stop the lingering `dotnet` process once, rerun the script, and confirm the health endpoint responds before rechecking the smoke suite.
## Artifacts and Notes
The most important visible transcript to preserve during implementation is the breakdown text for a retry-causing situational bonus. A representative successful result should look like this shape, with different random numbers allowed:
08+50+20=78; retry(+5): 42+50+20=112; final=117
The corresponding browser-level proof should include a log row that shows the final result, retains the existing retry badge, and expands into detail whose dice titles still include attempt markers such as:
Roll 8, step 1, attempt 1, Rolemaster open-ended initial
Roll 42, step 1, retry attempt 2, Rolemaster open-ended initial
## Interfaces and Dependencies
At the end of the implementation, these repository interfaces should exist in the following shapes.
In `RpgRoller/Contracts/ApiContracts.cs`, define:
public sealed record RollSkillRequest(string Visibility, int SituationalModifier = 0);
In `RpgRoller/Services/IGameService.cs`, define:
ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility, int situationalModifier = 0);
In `RpgRoller/Services/GameRollService.cs`, define a matching method:
public ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility, int situationalModifier)
In `RpgRoller/Services/RollEngine.cs`, extend the Rolemaster path with:
public (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) Roll(
RulesetKind ruleset,
DiceExpression expression,
int wildDice,
bool allowFumble,
int? fumbleRange,
bool rolemasterAutoRetry = false,
int situationalModifier = 0)
In `RpgRoller/Components/Pages/HomeControls/SkillGroupBlock.razor.cs` and `CharacterPanel.razor.cs`, change the roll callback shape to carry the full skill:
[Parameter]
public EventCallback<CharacterSheetSkill> RollSkillRequested { get; set; }
[Parameter]
public EventCallback<CharacterSheetSkill> RollRequested { get; set; }
Create a new modal component at `RpgRoller/Components/Pages/HomeControls/RolemasterSkillRollModal.razor` and `.razor.cs` that accepts at least the visibility flag, skill label, expression label, raw modifier text, submit state, and confirm/cancel callbacks. The component should use only existing Blazor and repository dependencies; no third-party modal library is required or desired.
Plan revision note (2026-04-14 / Codex): created the initial ExecPlan for the new Rolemaster one-shot situational modifier modal because `TASKS.md` was empty and the feature needs a self-contained implementation guide before coding begins.
Plan revision note (2026-04-14 / Codex): updated the living plan after the first backend slice landed so progress, discoveries, and retrospective match the new transient request path, explicit breakdown formatting, and added service/API coverage.
Plan revision note (2026-04-14 / Codex): updated the living plan again after the UI slice completed so the document now reflects the shipped modal behavior, the Playwright coverage, and the final end-to-end outcome.

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",
"info": {
"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": {
"get": {
"operationId": "getCampaigns",
@@ -701,12 +741,27 @@
},
"displayName": {
"type": "string"
},
"roles": {
"type": "array",
"items": {
"type": "string"
}
},
"themePreference": {
"type": "string",
"nullable": true,
"enum": [
"light",
"dark"
]
}
},
"required": [
"id",
"username",
"displayName"
"displayName",
"roles"
]
},
"MeResponse": {
@@ -730,6 +785,21 @@
"user"
]
},
"UpdateThemePreferenceRequest": {
"type": "object",
"properties": {
"themePreference": {
"type": "string",
"enum": [
"light",
"dark"
]
}
},
"required": [
"themePreference"
]
},
"RulesetDefinition": {
"type": "object",
"properties": {

195
package-lock.json generated
View File

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

View File

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

View File

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

View File

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

View File

@@ -1,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'

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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