Compare commits
156 Commits
feature/bl
...
43bd68e707
| Author | SHA1 | Date | |
|---|---|---|---|
| 43bd68e707 | |||
| e574b4a37b | |||
| b8bd92e3dc | |||
| 2be1fc599a | |||
| ba9536de12 | |||
| 777befdbf0 | |||
| 6b18051073 | |||
| f8b09be399 | |||
| f01d100740 | |||
| c427e717d5 | |||
| c628957163 | |||
| 56e0ec1e79 | |||
| f86ac43153 | |||
| e60b4b5867 | |||
| a69c6284d7 | |||
| 12612e05fa | |||
| 73dc4a9cd4 | |||
| 9c3f7c039e | |||
| def2a3f680 | |||
| c13a2ce7c7 | |||
| b97437fda3 | |||
| b9fba1bbbc | |||
| a7f6163c4b | |||
| 8d08b857ab | |||
| e0b7d27ba7 | |||
| da813583bd | |||
| 231b0ac9a0 | |||
| 1f19bf7bfd | |||
| 2d2ed561cc | |||
| d3ba75ce42 | |||
| 2d5f893963 | |||
| 3e1d3746dd | |||
| 368a9a4960 | |||
| 9e91fb2719 | |||
| 8d59868392 | |||
| d4e72fe5bb | |||
| 2997247eeb | |||
| 0c638e8ebe | |||
| 0dff8a275c | |||
| d38003a77c | |||
| f63c3f8f28 | |||
| 5c62fb5bbb | |||
| 9278e59825 | |||
| 42a9164ddd | |||
| 600ea6770d | |||
| b135203318 | |||
| a290ff87dd | |||
| 938d8a5eba | |||
| 12b8aaee26 | |||
| 46a63f9e06 | |||
| 305999e4b7 | |||
| b291d0531f | |||
| 6cdd29ed93 | |||
| 6f9acdc165 | |||
| a2e130abb1 | |||
| 97ddb4b136 | |||
| 4690c8b5e1 | |||
| 25040d7824 | |||
| 93c19f0705 | |||
| 4d5d112168 | |||
| ec40baa107 | |||
| abee1729c5 | |||
| b3cde614e7 | |||
| 4af1c87639 | |||
| 0124325c20 | |||
| 4f77d4a702 | |||
| 17b049d2ca | |||
| 3d7f3d1ee4 | |||
| ad4241aaaf | |||
| 9479b2e2f3 | |||
| f6046e65f8 | |||
| 951ce9f1fe | |||
| a9558a16fc | |||
| 8961c75305 | |||
| fa5bad23a7 | |||
| 7ec2887df2 | |||
| 8c5a88afc5 | |||
| a5f8421aa8 | |||
| 8c413a8ded | |||
| 2e6951e695 | |||
| 22ee512cb7 | |||
| 9e6e6fe8c7 | |||
| 7248b60395 | |||
| b26d58cea4 | |||
| 9581442cab | |||
| 7d91e7c900 | |||
| 923c6ae26d | |||
| f0dd79e589 | |||
| e5f00fa693 | |||
| 61ea310179 | |||
| 960197354a | |||
| 0059fde74f | |||
| 9b9927084b | |||
| 48439fd21d | |||
| 90afe3b06b | |||
| 13c6215c89 | |||
| da9dc24d8e | |||
| f750f5adc4 | |||
| 31dcb0c4a9 | |||
| ac586b0e55 | |||
| fadb7efd64 | |||
| bf6113f790 | |||
| 0ac1bda10b | |||
| f04f4aa08a | |||
| 0fd7cfd3ca | |||
| 5c268070bd | |||
| e028ad472d | |||
| ddb57cde8f | |||
| e42c0fb9ba | |||
| 6ea91ee565 | |||
| 107b8b8552 | |||
| 001f775714 | |||
| c935578cf6 | |||
| 8561c6643a | |||
| 1c8cb71cb4 | |||
| c1236eec63 | |||
| b062ad1adf | |||
| 637a2ef7ac | |||
| fa7f88e209 | |||
| 13113f9d40 | |||
| 54aabc6d8c | |||
| 52e3ae8b0f | |||
| c41aabc0a8 | |||
| a56b3fc451 | |||
| 51d04fcdc5 | |||
| ac5acd77f0 | |||
| 6f94b1ba95 | |||
| 0b30b04283 | |||
| 2e2f364c5e | |||
| 3026221cd6 | |||
| ba8141b336 | |||
| 59fe453297 | |||
| 0cb41dd004 | |||
| 83151d81fd | |||
| 76c83a5784 | |||
| 3b1a314a75 | |||
| 04bc8095e6 | |||
| bf3a6fa645 | |||
| 15c046bcac | |||
| 017fc37b1d | |||
| f9879c1541 | |||
| c3aa0d4e88 | |||
| e7114d8798 | |||
| 9036a3a157 | |||
| d0da35a68c | |||
| 3bfeb39883 | |||
| df98f39c54 | |||
| 9c31e81977 | |||
| 54286f80d5 | |||
| 4d728f91cf | |||
| b17490e5ac | |||
| 2d1bf9b9b7 | |||
| 96238a9341 | |||
| 2d0df7948c | |||
| 5763c67f34 | |||
| 0ec19bf682 |
7
.gitignore
vendored
7
.gitignore
vendored
@@ -6,8 +6,13 @@ artifacts/
|
||||
|
||||
# IDE
|
||||
.vs/
|
||||
.vscode/
|
||||
.idea/
|
||||
.vscode/*
|
||||
!.vscode/launch.json
|
||||
!.vscode/tasks.json
|
||||
node_modules/
|
||||
playwright-report/
|
||||
test-results/
|
||||
|
||||
# User secrets / configs
|
||||
appsettings.Development.json
|
||||
|
||||
65
.vscode/launch.json
vendored
Normal file
65
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "RpgRoller: Server",
|
||||
"type": "coreclr",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "build",
|
||||
"program": "${workspaceFolder}/RpgRoller/bin/Debug/net10.0/RpgRoller.dll",
|
||||
"args": [],
|
||||
"cwd": "${workspaceFolder}/RpgRoller",
|
||||
"stopAtEntry": false,
|
||||
"console": "internalConsole",
|
||||
"env": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||
"ASPNETCORE_URLS": "https://localhost:7271;http://localhost:5175"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "RpgRoller: Server + Edge (F5)",
|
||||
"type": "coreclr",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "build",
|
||||
"program": "${workspaceFolder}/RpgRoller/bin/Debug/net10.0/RpgRoller.dll",
|
||||
"args": [],
|
||||
"cwd": "${workspaceFolder}/RpgRoller",
|
||||
"stopAtEntry": false,
|
||||
"console": "internalConsole",
|
||||
"env": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||
"ASPNETCORE_URLS": "https://localhost:7271;http://localhost:5175"
|
||||
},
|
||||
"serverReadyAction": {
|
||||
"action": "debugWithEdge",
|
||||
"pattern": "\\bNow listening on:\\s+(https?://\\S+)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "RpgRoller: Server + Firefox (F5)",
|
||||
"type": "coreclr",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "build",
|
||||
"program": "${workspaceFolder}/RpgRoller/bin/Debug/net10.0/RpgRoller.dll",
|
||||
"args": [],
|
||||
"cwd": "${workspaceFolder}/RpgRoller",
|
||||
"stopAtEntry": false,
|
||||
"console": "internalConsole",
|
||||
"env": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||
"ASPNETCORE_URLS": "https://localhost:7271;http://localhost:5175"
|
||||
},
|
||||
"serverReadyAction": {
|
||||
"action": "startDebugging",
|
||||
"pattern": "\\bNow listening on:\\s+(https?://\\S+)",
|
||||
"name": "RpgRoller: Open Firefox"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "RpgRoller: Open Firefox",
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"command": "$firefox = Join-Path $env:ProgramFiles 'Mozilla Firefox\\firefox.exe'; if (-not (Test-Path $firefox)) { $firefox = Join-Path ${env:ProgramFiles(x86)} 'Mozilla Firefox\\firefox.exe' }; Start-Process -FilePath $firefox -ArgumentList 'https://localhost:7271'"
|
||||
}
|
||||
]
|
||||
}
|
||||
21
.vscode/tasks.json
vendored
Normal file
21
.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "build",
|
||||
"command": "dotnet",
|
||||
"type": "process",
|
||||
"args": [
|
||||
"build",
|
||||
"${workspaceFolder}/RpgRoller.sln",
|
||||
"/property:GenerateFullPaths=true",
|
||||
"/consoleloggerparameters:NoSummary"
|
||||
],
|
||||
"problemMatcher": "$msCompile",
|
||||
"group": {
|
||||
"kind": "build",
|
||||
"isDefault": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
34
AGENTS.linux.md
Normal file
34
AGENTS.linux.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Agent Guide
|
||||
|
||||
Also see the other related technical documentation in the docs folder.
|
||||
|
||||
## Tools
|
||||
|
||||
These tools are installed and available: Python3, geckodriver, Selenium
|
||||
|
||||
## Rules
|
||||
|
||||
- Prefer extracting code to a shared helper to be reused instead of duplicating code. Always keep high maintainability standards.
|
||||
- Always place each newly created class into its own file. The file name must match the class name.
|
||||
- When asked to begin working on a task, create a detailed implementation plan first, present the plan to the user, and ask for approval before beginning with the actual implementation.
|
||||
- Don't make assumptions in the plan. If necessary, ask all clarifying questions before presenting the final plan.
|
||||
- When an task is finished, perform a code review to evaluate if the change is clean and maintainable with high software engineering standards. Iterate on the code and repeat the review process until satisfied.
|
||||
- If there's documnentation present, always keep it updated.
|
||||
- After every iteration, evaluate if the test coverage would fall below 100%, and write tests if necessary.
|
||||
- After every iteration, run `dotnet jb cleanupcode --build=False '$file1' '$file2' ...` for every C# file you touched.
|
||||
- After every frontend change, verify the results using a geckodriver+Selenium run.
|
||||
- When browser verification needs the app running, launch the app against a temporary copy of `src\RolemasterDb.App\rolemaster.db` so verification does not mutate the canonical DB.
|
||||
|
||||
### Git
|
||||
|
||||
- Never change the .gitignore file without consent.
|
||||
- Keep changes small with minimal churn and commit often. If one iteration encompasses many smaller tasks with more than one commit, create a git branch and do the commits there. Let me review the branch before merging it back to master.
|
||||
- When multiple commits are necessary, pause after every commit and ask the user to give a command to proceed.
|
||||
- After every iteration, do a git commit with a brief summary of the changes as a commit message.
|
||||
- If you find unexpected changes in the code (deletions, changes, diff results that were not communicated), never revert them and never restore the old state. Assume that those changes happened with intent.
|
||||
- Never use `git restore`, `git checkout --`, reset commands, or equivalent rollback actions to discard local changes unless the user explicitly asks for that exact rollback.
|
||||
|
||||
### Dotnet CLI
|
||||
|
||||
- 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`.
|
||||
17
AGENTS.md
17
AGENTS.md
@@ -1,16 +1 @@
|
||||
# Agent Guide
|
||||
|
||||
Also see the other related technical documentation: TECH.md, REQUIREMENTS.md and possibly other markdown files.
|
||||
|
||||
## Rules
|
||||
|
||||
- This is a Windows environment, WSL is not installed (i.e. sed is not available). You're running under PowerShell 7.5.4. 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.
|
||||
- After every iteration, evaluate if the test coverage would fall below 100%, and write tests if necessary.
|
||||
- After every iteration, run "scripts/ci-local.ps1" and ensure that nothing broke.
|
||||
- After every iteration, update all related documentation according to the change, and evaluate if a FAQ entry would help the users, serving as public documentation for this project.
|
||||
- After every iteration, do a git commit with a brief summary of the changes as a commit message.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
This is a linux environment, read `AGENTS.linux.md`.
|
||||
55
AGENTS.windows.md
Normal file
55
AGENTS.windows.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# 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
|
||||
|
||||
- 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 working on a task, create a detailed implementation plan first, present the plan to the user, and ask for approval before beginning with the actual implementation.
|
||||
- Don't make assumptions in the plan. If necessary, ask all clarifying questions before presenting the final plan.
|
||||
- When an task is finished, perform a code review to evaluate if the change is clean and maintainable with high software engineering standards. Iterate on the code and repeat the review process until satisfied.
|
||||
- After the implementation is finished, verify all changed files, and run `python D:\Code\crlf.py $file1 $file2 ...` only for files you recognize, in order to normalize all line endings of all touched files to CRLF.
|
||||
- If there's documnentation present, always keep it updated.
|
||||
- After every iteration, evaluate if the test coverage would fall below 100%, and write tests if necessary.
|
||||
- After every iteration, run `jb cleanupcode --build=False '$file1' '$file2' ...` for every C# file you touched.
|
||||
- After every frontend change, verify the results using an ephemeral Playwright run.
|
||||
- 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.
|
||||
### Git
|
||||
|
||||
- Never change the .gitignore file without consent.
|
||||
- Keep changes small and commit often. If one iteration encompasses many smaller tasks with more than one commit, create a git branch and do the commits there. Let me review the branch before merging it back to master.
|
||||
- When multiple commits are necessary, pause after every commit and ask the user to give a command to proceed.
|
||||
- After every iteration, do a git commit with a brief summary of the changes as a commit message.
|
||||
- If you find unexpected changes in the code (deletions, changes, diff results that were not communicated), never revert them and never restore the old state. Assume that those changes happened with intent.
|
||||
- Never use `git restore`, `git checkout --`, reset commands, or equivalent rollback actions to discard local changes unless the user explicitly asks for that exact rollback.
|
||||
|
||||
### PowerShell
|
||||
|
||||
- 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`.
|
||||
52
FAQ.md
52
FAQ.md
@@ -1,52 +0,0 @@
|
||||
# FAQ
|
||||
|
||||
## Does this project still require npm/frontend TypeScript tooling?
|
||||
|
||||
No. The legacy TypeScript frontend pipeline was removed after the Blazor rewrite.
|
||||
`scripts/ci-local.ps1` is now a .NET-only flow (restore, build, tests, coverage checks).
|
||||
|
||||
## Is frontend JavaScript handwritten?
|
||||
|
||||
The runtime UI is now built with Blazor components (`RpgRoller/Components/*`).
|
||||
|
||||
There is still small handwritten JavaScript in `RpgRoller/wwwroot/js/rpgroller-api.js` for:
|
||||
|
||||
- browser `fetch` calls with cookie auth
|
||||
- SSE connection/reconnect handling
|
||||
- per-tab session storage helpers used by the Blazor UI
|
||||
|
||||
There is no TypeScript runtime frontend in the current codebase.
|
||||
|
||||
## Where is backend state stored locally?
|
||||
|
||||
Backend state is persisted via EF Core + SQLite.
|
||||
|
||||
- Development default: `RpgRoller/App_Data/rpgroller.development.db`
|
||||
- Non-development default: `RpgRoller/App_Data/rpgroller.db`
|
||||
|
||||
To start with a clean backend state, stop the app and remove the corresponding SQLite file.
|
||||
|
||||
## Does the backend read SQLite on every API call?
|
||||
|
||||
No. The backend loads state from SQLite once during startup into in-memory state and serves requests from memory. Successful state mutations are then written back to SQLite.
|
||||
|
||||
## Where is the frontend UX plan documented?
|
||||
|
||||
The canonical frontend UX design lives in `UX.md` at the repository root. It defines roles, flows, screen behavior, validation/error handling, responsive behavior, and real-time update expectations to guide implementation.
|
||||
|
||||
## What does test coverage include?
|
||||
|
||||
Coverage now includes the entire backend project (`RpgRoller`), including API/hosting/bootstrap code and services. It is no longer restricted to `RpgRoller.Services.*`.
|
||||
|
||||
## Why do backend services avoid API request DTO dependencies?
|
||||
|
||||
Service workflows accept explicit parameters (for example, `CreateCampaign(sessionToken, name, rulesetId)`) instead of API request DTOs. This keeps the service layer independent from HTTP transport contracts while avoiding extra service-only wrapper command types.
|
||||
|
||||
## How do d6 wild dice and fumbles work now?
|
||||
|
||||
d6 skills now store two explicit options:
|
||||
|
||||
- `wildDice`: number of wild dice for the skill
|
||||
- `allowFumble`: whether wild dice rolling `1` can trigger fumble removal
|
||||
|
||||
Roll responses also include per-die state flags (`crit`, `fumble`, `wild`, `removed`, `added`) so the frontend can render the full die-by-die outcome, not just the final total.
|
||||
@@ -1,36 +0,0 @@
|
||||
# Frontend Rebuild Progress (Blazor)
|
||||
|
||||
Tracking against `UX.md` tasks and decisions.
|
||||
|
||||
## Status Snapshot
|
||||
|
||||
- Branch: `feature/blazor-frontend-rebuild-ux`
|
||||
- Runtime frontend stack: Blazor (`RpgRoller/Components/*`) + browser JS interop (`wwwroot/js/rpgroller-api.js`)
|
||||
- Legacy TypeScript frontend/runtime artifacts: removed
|
||||
|
||||
## UX Checklist
|
||||
|
||||
| UX area | Status | Notes |
|
||||
|---|---|---|
|
||||
| 9.1 App load + session restore | Implemented | Health check on load, rulesets/session load, unauthorized session reset, API unhealthy retry banner. |
|
||||
| 9.2 Authentication view | Implemented | Register/login cards, required validation, register password length check, server-error display. |
|
||||
| 9.3 Shared authenticated header | Implemented | User chip, campaign/active context, connection state, screen switch, refresh, logout. |
|
||||
| 9.4 Play screen character column | Implemented | Character icon tabs, sheet, modal edit/create flows, activate action, skill list, d6 skill options (wild/fumble), roll controls, and die-state visualized last roll card. |
|
||||
| 9.5 Play screen log column | Implemented | Chronological feed, private/public badges, private perspective styles (roller vs GM), local time + ISO tooltip. |
|
||||
| 9.6 Campaign management screen | Implemented | Campaign selector/summary, create form, details card, character management actions with modal edit pattern. |
|
||||
| 9.7 Tablet/mobile bottom bar | Implemented | `Character` / `Log` panel switch in play screen and per-tab session persistence. |
|
||||
| 10 Validation and error UX | Partially implemented | Required-field and common API errors are mapped; message/code-specific mapping is limited by current API exposing only text messages. |
|
||||
| 11 Empty/loading/disabled states | Implemented | Empty states, skeleton placeholders, mutation button disabling. |
|
||||
| 12 Real-time and sync rules | Implemented | Campaign-scoped SSE subscribe/unsubscribe, reconnect with exponential backoff, manual refresh fallback. |
|
||||
| 13 Accessibility requirements | Partially implemented | Keyboard-friendly controls, labels, focus styling, `aria-live` announcements; screen-reader validation for all flows still needs dedicated accessibility QA. |
|
||||
| 14 Content and copy guidance | Implemented | Direct action labels and corrective error copy used throughout. |
|
||||
| 15 Visual direction | Implemented | Tabletop utility styling, tokenized colors, responsive layout, private/public visual differentiation. |
|
||||
| 17 Next iteration targets: wireframes | Not yet implemented | No separate low-fidelity wireframe artifact added in repo. |
|
||||
| 17 Next iteration targets: component contracts doc | Not yet implemented | Component contract document not yet extracted from implementation. |
|
||||
| 17 Next iteration targets: visual token doc | Not yet implemented | Tokens are implemented in CSS but not yet documented in a dedicated spec file. |
|
||||
|
||||
## Follow-up Candidates
|
||||
|
||||
1. Add explicit machine-readable API error codes to HTTP responses for richer field-level mapping.
|
||||
2. Add automated accessibility checks (focus order, contrast, and screen-reader behavior assertions).
|
||||
3. Document component contracts and visual token references as separate markdown artifacts.
|
||||
150
PLANS.md
Normal file
150
PLANS.md
Normal file
@@ -0,0 +1,150 @@
|
||||
# Codex Execution Plans (ExecPlans):
|
||||
|
||||
This document describes the requirements for an execution plan ("ExecPlan"), a design document that a coding agent can follow to deliver a working feature or system change. Treat the reader as a complete beginner to this repository: they have only the current working tree and the single ExecPlan file you provide. There is no memory of prior plans and no external context.
|
||||
|
||||
## How to use ExecPlans and PLANS.md
|
||||
|
||||
When authoring an executable specification (ExecPlan), follow PLANS.md _to the letter_. If it is not in your context, refresh your memory by reading the entire PLANS.md file. Be thorough in reading (and re-reading) source material to produce an accurate specification. When creating a spec, start from the skeleton and flesh it out as you do your research.
|
||||
|
||||
When implementing an executable specification (ExecPlan), do not prompt the user for "next steps"; simply proceed to the next milestone. Keep all sections up to date, add or split entries in the list at every stopping point to affirmatively state the progress made and next steps. Resolve ambiguities autonomously, and commit frequently.
|
||||
|
||||
When discussing an executable specification (ExecPlan), record decisions in a log in the spec for posterity; it should be unambiguously clear why any change to the specification was made. ExecPlans are living documents, and it should always be possible to restart from _only_ the ExecPlan and no other work.
|
||||
|
||||
When researching a design with challenging requirements or significant unknowns, use milestones to implement proof of concepts, "toy implementations", etc., that allow validating whether the user's proposal is feasible. Read the source code of libraries by finding or acquiring them, research deeply, and include prototypes to guide a fuller implementation.
|
||||
|
||||
## Requirements
|
||||
|
||||
NON-NEGOTIABLE REQUIREMENTS:
|
||||
|
||||
* Every ExecPlan must be fully self-contained. Self-contained means that in its current form it contains all knowledge and instructions needed for a novice to succeed.
|
||||
* Every ExecPlan is a living document. Contributors are required to revise it as progress is made, as discoveries occur, and as design decisions are finalized. Each revision must remain fully self-contained.
|
||||
* Every ExecPlan must enable a complete novice to implement the feature end-to-end without prior knowledge of this repo.
|
||||
* Every ExecPlan must produce a demonstrably working behavior, not merely code changes to "meet a definition".
|
||||
* Every ExecPlan must define every term of art in plain language or do not use it.
|
||||
|
||||
Purpose and intent come first. Begin by explaining, in a few sentences, why the work matters from a user's perspective: what someone can do after this change that they could not do before, and how to see it working. Then guide the reader through the exact steps to achieve that outcome, including what to edit, what to run, and what they should observe.
|
||||
|
||||
The agent executing your plan can list files, read files, search, run the project, and run tests. It does not know any prior context and cannot infer what you meant from earlier milestones. Repeat any assumption you rely on. Do not point to external blogs or docs; if knowledge is required, embed it in the plan itself in your own words. If an ExecPlan builds upon a prior ExecPlan and that file is checked in, incorporate it by reference. If it is not, you must include all relevant context from that plan.
|
||||
|
||||
## Formatting
|
||||
|
||||
Format and envelope are simple and strict. Each ExecPlan must be one single fenced code block labeled as `md` that begins and ends with triple backticks. Do not nest additional triple-backtick code fences inside; when you need to show commands, transcripts, diffs, or code, present them as indented blocks within that single fence. Use indentation for clarity rather than code fences inside an ExecPlan to avoid prematurely closing the ExecPlan's code fence. Use two newlines after every heading, use # and ## and so on, and correct syntax for ordered and unordered lists.
|
||||
|
||||
When writing an ExecPlan to a Markdown (.md) file where the content of the file *is only* the single ExecPlan, you should omit the triple backticks.
|
||||
|
||||
Write in plain prose. Prefer sentences over lists. Avoid checklists, tables, and long enumerations unless brevity would obscure meaning. Checklists are permitted only in the `Progress` section, where they are mandatory. Narrative sections must remain prose-first.
|
||||
|
||||
## Guidelines
|
||||
|
||||
Self-containment and plain language are paramount. If you introduce a phrase that is not ordinary English ("daemon", "middleware", "RPC gateway", "filter graph"), define it immediately and remind the reader how it manifests in this repository (for example, by naming the files or commands where it appears). Do not say "as defined previously" or "according to the architecture doc." Include the needed explanation here, even if you repeat yourself.
|
||||
|
||||
Avoid common failure modes. Do not rely on undefined jargon. Do not describe "the letter of a feature" so narrowly that the resulting code compiles but does nothing meaningful. Do not outsource key decisions to the reader. When ambiguity exists, resolve it in the plan itself and explain why you chose that path. Err on the side of over-explaining user-visible effects and under-specifying incidental implementation details.
|
||||
|
||||
Anchor the plan with observable outcomes. State what the user can do after implementation, the commands to run, and the outputs they should see. Acceptance should be phrased as behavior a human can verify ("after starting the server, navigating to [http://localhost:8080/health](http://localhost:8080/health) returns HTTP 200 with body OK") rather than internal attributes ("added a HealthCheck struct"). If a change is internal, explain how its impact can still be demonstrated (for example, by running tests that fail before and pass after, and by showing a scenario that uses the new behavior).
|
||||
|
||||
Specify repository context explicitly. Name files with full repository-relative paths, name functions and modules precisely, and describe where new files should be created. If touching multiple areas, include a short orientation paragraph that explains how those parts fit together so a novice can navigate confidently. When running commands, show the working directory and exact command line. When outcomes depend on environment, state the assumptions and provide alternatives when reasonable.
|
||||
|
||||
Be idempotent and safe. Write the steps so they can be run multiple times without causing damage or drift. If a step can fail halfway, include how to retry or adapt. If a migration or destructive operation is necessary, spell out backups or safe fallbacks. Prefer additive, testable changes that can be validated as you go.
|
||||
|
||||
Validation is not optional. Include instructions to run tests, to start the system if applicable, and to observe it doing something useful. Describe comprehensive testing for any new features or capabilities. Include expected outputs and error messages so a novice can tell success from failure. Where possible, show how to prove that the change is effective beyond compilation (for example, through a small end-to-end scenario, a CLI invocation, or an HTTP request/response transcript). State the exact test commands appropriate to the project’s toolchain and how to interpret their results.
|
||||
|
||||
Capture evidence. When your steps produce terminal output, short diffs, or logs, include them inside the single fenced block as indented examples. Keep them concise and focused on what proves success. If you need to include a patch, prefer file-scoped diffs or small excerpts that a reader can recreate by following your instructions rather than pasting large blobs.
|
||||
|
||||
## Milestones
|
||||
|
||||
Milestones are narrative, not bureaucracy. If you break the work into milestones, introduce each with a brief paragraph that describes the scope, what will exist at the end of the milestone that did not exist before, the commands to run, and the acceptance you expect to observe. Keep it readable as a story: goal, work, result, proof. Progress and milestones are distinct: milestones tell the story, progress tracks granular work. Both must exist. Never abbreviate a milestone merely for the sake of brevity, do not leave out details that could be crucial to a future implementation.
|
||||
|
||||
Each milestone must be independently verifiable and incrementally implement the overall goal of the execution plan.
|
||||
|
||||
## Living plans and design decisions
|
||||
|
||||
* ExecPlans are living documents. As you make key design decisions, update the plan to record both the decision and the thinking behind it. Record all decisions in the `Decision Log` section.
|
||||
* ExecPlans must contain and maintain a `Progress` section, a `Surprises & Discoveries` section, a `Decision Log`, and an `Outcomes & Retrospective` section. These are not optional.
|
||||
* When you discover optimizer behavior, performance tradeoffs, unexpected bugs, or inverse/unapply semantics that shaped your approach, capture those observations in the `Surprises & Discoveries` section with short evidence snippets (test output is ideal).
|
||||
* If you change course mid-implementation, document why in the `Decision Log` and reflect the implications in `Progress`. Plans are guides for the next contributor as much as checklists for you.
|
||||
* At completion of a major task or the full plan, write an `Outcomes & Retrospective` entry summarizing what was achieved, what remains, and lessons learned.
|
||||
|
||||
# Prototyping milestones and parallel implementations
|
||||
|
||||
It is acceptable—-and often encouraged—-to include explicit prototyping milestones when they de-risk a larger change. Examples: adding a low-level operator to a dependency to validate feasibility, or exploring two composition orders while measuring optimizer effects. Keep prototypes additive and testable. Clearly label the scope as “prototyping”; describe how to run and observe results; and state the criteria for promoting or discarding the prototype.
|
||||
|
||||
Prefer additive code changes followed by subtractions that keep tests passing. Parallel implementations (e.g., keeping an adapter alongside an older path during migration) are fine when they reduce risk or enable tests to continue passing during a large migration. Describe how to validate both paths and how to retire one safely with tests. When working with multiple new libraries or feature areas, consider creating spikes that evaluate the feasibility of these features _independently_ of one another, proving that the external library performs as expected and implements the features we need in isolation.
|
||||
|
||||
## Skeleton of a Good ExecPlan
|
||||
|
||||
# <Short, action-oriented description>
|
||||
|
||||
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.
|
||||
|
||||
If PLANS.md file is checked into the repo, reference the path to that file here from the repository root and note that this document must be maintained in accordance with PLANS.md.
|
||||
|
||||
## Purpose / Big Picture
|
||||
|
||||
Explain in a few sentences what someone gains after this change and how they can see it working. State the user-visible behavior you will enable.
|
||||
|
||||
## Progress
|
||||
|
||||
Use a list with checkboxes to summarize granular steps. Every stopping point must be documented here, even if it requires splitting a partially completed task into two (“done” vs. “remaining”). This section must always reflect the actual current state of the work.
|
||||
|
||||
- [x] (2025-10-01 13:00Z) Example completed step.
|
||||
- [ ] Example incomplete step.
|
||||
- [ ] Example partially completed step (completed: X; remaining: Y).
|
||||
|
||||
Use timestamps to measure rates of progress.
|
||||
|
||||
## Surprises & Discoveries
|
||||
|
||||
Document unexpected behaviors, bugs, optimizations, or insights discovered during implementation. Provide concise evidence.
|
||||
|
||||
- Observation: …
|
||||
Evidence: …
|
||||
|
||||
## Decision Log
|
||||
|
||||
Record every decision made while working on the plan in the format:
|
||||
|
||||
- Decision: …
|
||||
Rationale: …
|
||||
Date/Author: …
|
||||
|
||||
## Outcomes & Retrospective
|
||||
|
||||
Summarize outcomes, gaps, and lessons learned at major milestones or at completion. Compare the result against the original purpose.
|
||||
|
||||
## Context and Orientation
|
||||
|
||||
Describe the current state relevant to this task as if the reader knows nothing. Name the key files and modules by full path. Define any non-obvious term you will use. Do not refer to prior plans.
|
||||
|
||||
## Plan of Work
|
||||
|
||||
Describe, in prose, the sequence of edits and additions. For each edit, name the file and location (function, module) and what to insert or change. Keep it concrete and minimal.
|
||||
|
||||
## Concrete Steps
|
||||
|
||||
State the exact commands to run and where to run them (working directory). When a command generates output, show a short expected transcript so the reader can compare. This section must be updated as work proceeds.
|
||||
|
||||
## Validation and Acceptance
|
||||
|
||||
Describe how to start or exercise the system and what to observe. Phrase acceptance as behavior, with specific inputs and outputs. If tests are involved, say "run <project’s test command> and expect <N> passed; the new test <name> fails before the change and passes after>".
|
||||
|
||||
## Idempotence and Recovery
|
||||
|
||||
If steps can be repeated safely, say so. If a step is risky, provide a safe retry or rollback path. Keep the environment clean after completion.
|
||||
|
||||
## Artifacts and Notes
|
||||
|
||||
Include the most important transcripts, diffs, or snippets as indented examples. Keep them concise and focused on what proves success.
|
||||
|
||||
## Interfaces and Dependencies
|
||||
|
||||
Be prescriptive. Name the libraries, modules, and services to use and why. Specify the types, traits/interfaces, and function signatures that must exist at the end of the milestone. Prefer stable names and paths such as `crate::module::function` or `package.submodule.Interface`. E.g.:
|
||||
|
||||
In crates/foo/planner.rs, define:
|
||||
|
||||
pub trait Planner {
|
||||
fn plan(&self, observed: &Observed) -> Vec<Action>;
|
||||
}
|
||||
|
||||
If you follow the guidance above, a single, stateless agent -- or a human novice -- can read your ExecPlan from top to bottom and produce a working, observable result. That is the bar: SELF-CONTAINED, SELF-SUFFICIENT, NOVICE-GUIDING, OUTCOME-FOCUSED.
|
||||
|
||||
When you revise a plan, you must ensure your changes are comprehensively reflected across all sections, including the living document sections, and you must write a note at the bottom of the plan describing the change and the reason why. ExecPlans must describe not just the what but the why for almost everything.
|
||||
419
POSTMORTEM.md
Normal file
419
POSTMORTEM.md
Normal file
@@ -0,0 +1,419 @@
|
||||
# POSTMORTEM
|
||||
|
||||
## Executive Summary
|
||||
|
||||
RpgRoller failed in Firefox with RoboForm enabled because the authenticated workspace was built as a highly reactive Blazor Server surface that performs several structural rerenders immediately after login while assuming stable ownership of the rendered DOM.
|
||||
|
||||
RoboForm was the trigger, not the root cause.
|
||||
|
||||
The root cause was architectural:
|
||||
|
||||
- the app mixed static HTML auth, interactive Blazor Server UI, browser-managed session state, JavaScript fetch calls, and SSE live updates into one startup path
|
||||
- the authenticated workspace bootstrap was driven from `OnAfterRenderAsync` and intentionally caused follow-up renders
|
||||
- the root shell in [App.razor](/home/frank/Code/RpgRoller/RpgRoller/Components/App.razor) still branches on request-time `HttpContext` and session-cookie state
|
||||
- the workspace render tree is large, form-heavy, and sensitive to DOM mutation during early batches
|
||||
|
||||
That combination made the app fragile under browser extensions that legitimately modify login and form-related DOM.
|
||||
|
||||
## Incident Summary
|
||||
|
||||
### User-visible symptoms
|
||||
|
||||
- Firefox in a normal profile crashed the Blazor circuit immediately after or around login
|
||||
- the browser console reported:
|
||||
- `Error: There was an error applying batch ...`
|
||||
- `TypeError: can't access property "insertBefore", n.parentNode is null`
|
||||
- the server logged `RemoteRenderer[100]` and terminated the circuit
|
||||
- the UI often degraded into `Loading user...`, `Offline fallback`, or a partially rendered play screen before failing
|
||||
|
||||
### Scope
|
||||
|
||||
- The failure reproduced only in a normal Firefox profile with RoboForm enabled
|
||||
- The failure did not reproduce in a private Firefox window
|
||||
- The failure did not reproduce after disabling RoboForm
|
||||
- The failure was not tied to a specific username or database row
|
||||
|
||||
### Trigger vs. Root Cause
|
||||
|
||||
Trigger:
|
||||
|
||||
- RoboForm mutated form-related DOM in the page
|
||||
|
||||
Root cause:
|
||||
|
||||
- the app architecture depended on Blazor Server retaining stable ownership of a DOM subtree that was undergoing immediate, multi-batch structural changes during startup
|
||||
|
||||
## Current Architecture
|
||||
|
||||
### Root shell
|
||||
|
||||
The application entry point is [Program.cs](/home/frank/Code/RpgRoller/RpgRoller/Program.cs):
|
||||
|
||||
- `AddRazorComponents().AddInteractiveServerComponents()`
|
||||
- `MapRazorComponents<App>().AddInteractiveServerRenderMode()`
|
||||
- `AddScoped<RpgRollerApiClient>()`
|
||||
- `AddScoped<WorkspaceQueryService>()`
|
||||
|
||||
The root component is [App.razor](/home/frank/Code/RpgRoller/RpgRoller/Components/App.razor). It does two different things at `/`:
|
||||
|
||||
- if the incoming request has no valid session cookie, it renders [StaticAuthPage.razor](/home/frank/Code/RpgRoller/RpgRoller/Components/Pages/HomeControls/StaticAuthPage.razor) as plain HTML
|
||||
- otherwise it boots the Blazor app with `<Routes @rendermode="InteractiveServer(prerender: false)" />`
|
||||
|
||||
This decision is made from request-time state:
|
||||
|
||||
- `HttpContext`
|
||||
- request path
|
||||
- session cookie
|
||||
- `IGameService.GetUserBySession`
|
||||
|
||||
### Auth flow
|
||||
|
||||
The auth page is no longer a Blazor form.
|
||||
|
||||
[StaticAuthPage.razor](/home/frank/Code/RpgRoller/RpgRoller/Components/Pages/HomeControls/StaticAuthPage.razor) renders plain HTML forms, and [rpgroller-api.js](/home/frank/Code/RpgRoller/RpgRoller/wwwroot/js/rpgroller-api.js) binds submit handlers that:
|
||||
|
||||
- validate in JS
|
||||
- call `/api/auth/register` or `/api/auth/login` via `fetch`
|
||||
- on login, force a full `window.location.assign("/")`
|
||||
|
||||
This means the app has one browser experience before login and a different ownership model after login.
|
||||
|
||||
### Authenticated workspace
|
||||
|
||||
The authenticated `/` route is [Home.razor](/home/frank/Code/RpgRoller/RpgRoller/Components/Pages/Home.razor), which only renders `<Workspace LoggedOut="OnLoggedOutAsync" />`.
|
||||
|
||||
The real composition root is [Workspace.razor.cs](/home/frank/Code/RpgRoller/RpgRoller/Components/Pages/Workspace.razor.cs). It wires:
|
||||
|
||||
- `WorkspaceSessionCoordinator`
|
||||
- `WorkspaceCampaignScopeCoordinator`
|
||||
- `WorkspaceCampaignCoordinator`
|
||||
- `WorkspacePlayCoordinator`
|
||||
- `WorkspaceAdminCoordinator`
|
||||
- `WorkspaceLiveStateController`
|
||||
- `WorkspaceFeedbackService`
|
||||
|
||||
The current startup path is driven by `OnAfterRenderAsync`:
|
||||
|
||||
1. first interactive render occurs
|
||||
2. `Session.InitializeAsync()` runs
|
||||
3. `StateHasChanged()` is invoked
|
||||
4. later renders enable more controls such as the character controls and custom roll composer
|
||||
|
||||
### State channels
|
||||
|
||||
The workspace state is not owned by one subsystem. It is spread across four channels:
|
||||
|
||||
1. Blazor component state in [WorkspaceState.cs](/home/frank/Code/RpgRoller/RpgRoller/Components/Pages/WorkspaceState.cs)
|
||||
2. browser `sessionStorage` via `rpgroller-api.js`
|
||||
3. browser `fetch` requests through [RpgRollerApiClient.cs](/home/frank/Code/RpgRoller/RpgRoller/Components/RpgRollerApiClient.cs) and [WorkspaceQueryService.cs](/home/frank/Code/RpgRoller/RpgRoller/Components/WorkspaceQueryService.cs)
|
||||
4. SSE live updates from `/api/events/state` via [StateEventEndpoints.cs](/home/frank/Code/RpgRoller/RpgRoller/Api/StateEventEndpoints.cs)
|
||||
|
||||
### Backend state model
|
||||
|
||||
The backend is not stateless HTTP over a database. [GameService.cs](/home/frank/Code/RpgRoller/RpgRoller/Services/GameService.cs) builds an in-memory runtime state from SQLite at startup using [GameStateStore.cs](/home/frank/Code/RpgRoller/RpgRoller/Services/GameStateStore.cs), then serves reads and writes against that state.
|
||||
|
||||
This matters because the frontend already has multiple live state concepts:
|
||||
|
||||
- the Blazor circuit
|
||||
- the JS app state
|
||||
- the SSE stream
|
||||
- the server-side runtime store
|
||||
|
||||
That complexity is manageable only if the UI ownership boundaries stay clean. They did not.
|
||||
|
||||
## Architectural Timeline
|
||||
|
||||
### February 25, 2026
|
||||
|
||||
Blazor was introduced as the frontend host:
|
||||
|
||||
- `a8ee637` `Scaffold Blazor frontend host and root components`
|
||||
- `35c60c4` `Replace frontend with Blazor UX implementation`
|
||||
|
||||
This established Blazor Server as the UI owner.
|
||||
|
||||
### February 26 to April 5, 2026
|
||||
|
||||
The workspace became denser and more interactive:
|
||||
|
||||
- `c3aa0d4` `Overhaul workspace UX for denser play workflow`
|
||||
- `bf3a6fa` `Persist roll visibility preference across workspace reloads`
|
||||
- `54aabc6` `Unify play management and admin screens in workspace`
|
||||
- `6ea91ee` `Add targeted workspace live refresh`
|
||||
- `e42c0fb` `Load campaign logs incrementally`
|
||||
- `9e6e6fe` `Add custom campaign roll composer`
|
||||
- `4af1c87` through `b291d05` extracted coordinators and simplified the composition root
|
||||
|
||||
This refactor improved code organization, but it also increased the number of reactive moving parts:
|
||||
|
||||
- more persistent UI state in `sessionStorage`
|
||||
- more conditional screen branching inside one workspace root
|
||||
- more input-heavy controls on the default play screen
|
||||
- a live SSE side channel that can trigger refreshes after startup
|
||||
|
||||
### May 2 to May 4, 2026
|
||||
|
||||
This period contains direct evidence of mitigation attempts after the Firefox failure surfaced:
|
||||
|
||||
- `2d2ed56` `Isolate anonymous auth page from Blazor`
|
||||
- `1f19bf7` `Restore workspace prerender and auth errors`
|
||||
- `231b0ac` `Remove workspace session-token coupling`
|
||||
- `da81358` `Delay workspace render until session init completes`
|
||||
- `e0b7d27` `Stage workspace controls after bootstrap`
|
||||
|
||||
These commits are valuable evidence because they show the app was being repaired at the symptom boundary:
|
||||
|
||||
- first by removing auth from Blazor ownership
|
||||
- then by changing prerender behavior
|
||||
- then by removing `HttpContext`-captured session coupling from workspace queries
|
||||
- then by staging workspace startup
|
||||
|
||||
None of those changes removed the underlying architectural fragility: a Blazor Server workspace that still reshapes a large, extension-visible DOM over several early render batches.
|
||||
|
||||
## Root Causes
|
||||
|
||||
### 1. DOM ownership was not treated as a hard architectural boundary
|
||||
|
||||
The app used Blazor Server for a form-heavy authenticated workspace, while also operating in a browser environment where password managers are expected to inject or wrap form controls.
|
||||
|
||||
In principle that can work, but only when the rendered DOM is stable enough that third-party mutation does not race against structural rerenders.
|
||||
|
||||
RpgRoller violated that assumption:
|
||||
|
||||
- immediate post-login renders reshaped the workspace
|
||||
- input-bearing controls were mounted during startup
|
||||
- later state syncs continued changing the same subtree
|
||||
|
||||
That made the DOM ownership contract weak.
|
||||
|
||||
### 2. Startup was centered on `OnAfterRenderAsync` instead of a stable initial model
|
||||
|
||||
[Workspace.razor.cs](/home/frank/Code/RpgRoller/RpgRoller/Components/Pages/Workspace.razor.cs) drives initialization from `OnAfterRenderAsync`, then explicitly schedules more rerenders.
|
||||
|
||||
That has two consequences:
|
||||
|
||||
- the first visible authenticated frame is not the final intended frame
|
||||
- the renderer must apply several batches while the browser is already free to run extensions against the DOM
|
||||
|
||||
This is a poor fit for DOM-mutating extensions.
|
||||
|
||||
### 3. The root shell still depends on request-time `HttpContext`
|
||||
|
||||
[App.razor](/home/frank/Code/RpgRoller/RpgRoller/Components/App.razor) still uses `HttpContext` and the session cookie to decide whether to render:
|
||||
|
||||
- static auth HTML
|
||||
- or the interactive app
|
||||
|
||||
Even after removing the old workspace session-token accessor, the root shell still relies on request-only state to choose the subtree for `/`.
|
||||
|
||||
This is a fragile architectural seam because the app is half request-rendered and half interactive, with the split encoded in the component tree itself.
|
||||
|
||||
### 4. Too many reactive state channels were active during the same startup window
|
||||
|
||||
During or shortly after login, the workspace may react to:
|
||||
|
||||
- `sessionStorage` reads
|
||||
- API reads through `fetch`
|
||||
- Blazor rerenders from `StateHasChanged`
|
||||
- SSE connection state transitions
|
||||
- SSE state events
|
||||
|
||||
That is too much coordination for a large render tree if DOM stability is required.
|
||||
|
||||
### 5. The workspace root remained too large and too structurally dynamic
|
||||
|
||||
[Workspace.razor](/home/frank/Code/RpgRoller/RpgRoller/Components/Pages/Workspace.razor) controls:
|
||||
|
||||
- header
|
||||
- play screen
|
||||
- campaign management
|
||||
- admin screen
|
||||
- toasts
|
||||
- character modals
|
||||
- Rolemaster modal
|
||||
|
||||
The play screen itself contains multiple conditional branches and input surfaces.
|
||||
|
||||
Even though code-behind files and coordinators improved organization, the rendered root still has a large rerender blast radius.
|
||||
|
||||
### 6. Documentation drift hid the architecture change
|
||||
|
||||
The current [README.md](/home/frank/Code/RpgRoller/README.md) still describes:
|
||||
|
||||
- `Home.razor` as a gateway that switches between loading, auth, and authenticated workspace views
|
||||
- `WorkspaceQueryService` as “server-side read model access”
|
||||
|
||||
Neither description matches the current code:
|
||||
|
||||
- `Home.razor` now just renders `Workspace`
|
||||
- `App.razor` became the real gateway
|
||||
- `WorkspaceQueryService` now calls browser `fetch` through `RpgRollerApiClient`
|
||||
|
||||
This kind of drift usually means the architecture has moved faster than the design was reevaluated.
|
||||
|
||||
## Why the Failure Was Hard to Fix Incrementally
|
||||
|
||||
The failure was not caused by one broken line. It emerged from several acceptable local decisions that interacted badly:
|
||||
|
||||
- static auth was added to avoid Blazor auth-page failures
|
||||
- workspace prerender behavior was changed to satisfy session bootstrap
|
||||
- direct server-side workspace reads were removed to avoid `HttpContext` coupling
|
||||
- render staging was added to reduce early DOM churn
|
||||
|
||||
Each change improved one seam while leaving the overall architecture intact.
|
||||
|
||||
That is why the issue kept moving:
|
||||
|
||||
- first the auth page crashed
|
||||
- then login worked but workspace bootstrap stalled
|
||||
- then the play view partially rendered but later crashed
|
||||
|
||||
The architecture allowed the failure to migrate between phases instead of disappearing.
|
||||
|
||||
## Evidence From Recent Fix Attempts
|
||||
|
||||
### `2d2ed56` on May 2, 2026
|
||||
|
||||
`Isolate anonymous auth page from Blazor`
|
||||
|
||||
What it changed:
|
||||
|
||||
- added `StaticAuthPage.razor`
|
||||
- moved login/register handling into `rpgroller-api.js`
|
||||
- changed `App.razor` to serve static auth HTML when unauthenticated
|
||||
|
||||
What it revealed:
|
||||
|
||||
- removing Blazor from the auth page improved the anonymous path
|
||||
- the underlying crash still existed in the authenticated workspace path
|
||||
|
||||
### `1f19bf7` on May 2, 2026
|
||||
|
||||
`Restore workspace prerender and auth errors`
|
||||
|
||||
What it changed:
|
||||
|
||||
- adjusted render mode behavior again
|
||||
- kept better auth error reporting
|
||||
|
||||
What it revealed:
|
||||
|
||||
- workspace startup was still entangled with earlier render-mode decisions
|
||||
- the app was using render-mode changes as a corrective mechanism rather than as a stable architecture choice
|
||||
|
||||
### `231b0ac` on May 3, 2026
|
||||
|
||||
`Remove workspace session-token coupling`
|
||||
|
||||
What it changed:
|
||||
|
||||
- deleted `WorkspaceSessionTokenAccessor`
|
||||
- changed `WorkspaceQueryService` to use API calls instead of direct service access
|
||||
|
||||
What it revealed:
|
||||
|
||||
- the previous architecture had leaked request-time session access into interactive workspace startup
|
||||
- removing that coupling was necessary, but not sufficient, because the DOM ownership problem remained
|
||||
|
||||
### `da81358` on May 4, 2026
|
||||
|
||||
`Delay workspace render until session init completes`
|
||||
|
||||
What it changed:
|
||||
|
||||
- replaced the early workspace UI with a loading shell
|
||||
|
||||
What it revealed:
|
||||
|
||||
- broad render suppression was too blunt
|
||||
- it masked, rather than removed, the actual failing rerender path
|
||||
|
||||
### `e0b7d27` on May 4, 2026
|
||||
|
||||
`Stage workspace controls after bootstrap`
|
||||
|
||||
What it changed:
|
||||
|
||||
- restored the base workspace
|
||||
- deferred some input-heavy controls to later batches
|
||||
|
||||
What it revealed:
|
||||
|
||||
- the crash moved later in startup
|
||||
- the base play view could survive, but later structural updates still failed
|
||||
|
||||
Taken together, these commits are evidence that the app was being pushed toward compatibility through localized mitigations, while the larger architecture still tolerated unstable startup ownership.
|
||||
|
||||
## What Actually Failed
|
||||
|
||||
The practical failure mode was:
|
||||
|
||||
1. login succeeded
|
||||
2. the authenticated workspace circuit started
|
||||
3. early render batches built or reshaped a large DOM subtree
|
||||
4. RoboForm touched form-related DOM inside that subtree
|
||||
5. Blazor attempted to apply a later batch using DOM assumptions that were no longer true
|
||||
6. the browser-side batch apply failed with `insertBefore ... parentNode is null`
|
||||
7. the server terminated the circuit
|
||||
|
||||
The `Offline fallback` label was mostly a consequence:
|
||||
|
||||
- once the circuit failed, live-state coordination could not complete cleanly
|
||||
- the connection-state UI then reflected that degraded state
|
||||
|
||||
## Findings
|
||||
|
||||
### Primary finding
|
||||
|
||||
The authenticated workspace should not have been architected as a multi-batch, structurally dynamic, form-heavy startup surface if compatibility with password managers and other DOM-mutating extensions is a requirement.
|
||||
|
||||
### Secondary findings
|
||||
|
||||
- `App.razor` became a hidden architecture boundary without being treated as one
|
||||
- the workspace composition root is still too structurally broad
|
||||
- frontend ownership is split between Blazor and handwritten JS in a way that complicates startup reasoning
|
||||
- live updates were added as another reactivity source before the UI ownership model was made robust
|
||||
- documentation no longer described the actual architecture, making corrective design work harder
|
||||
|
||||
## Remediation Directions
|
||||
|
||||
These are architectural directions, not the implementation plan.
|
||||
|
||||
### 1. Choose a single ownership model for the authenticated shell
|
||||
|
||||
The authenticated shell should not be partly “request-decided” and partly “interactive-decided” in a component tree that still relies on request-time state.
|
||||
|
||||
### 2. Stop using `OnAfterRenderAsync` as the main workspace bootstrap orchestrator
|
||||
|
||||
The authenticated workspace needs a stable initial render contract with fewer structural follow-up diffs.
|
||||
|
||||
### 3. Reduce startup-state multiplicity
|
||||
|
||||
The startup path should not require simultaneous coordination across:
|
||||
|
||||
- Blazor state
|
||||
- `sessionStorage`
|
||||
- `fetch`
|
||||
- SSE
|
||||
|
||||
at least not before the UI is stable.
|
||||
|
||||
### 4. Shrink the render blast radius
|
||||
|
||||
The workspace root should own less structural branching. The more isolated the screen and control subtrees are, the less likely a third-party DOM mutation is to invalidate a broad diff.
|
||||
|
||||
### 5. Treat extension compatibility as a design requirement
|
||||
|
||||
Password managers are not an edge case for login and form-driven applications. The UI architecture must assume that form controls can be wrapped, annotated, or moved by browser software.
|
||||
|
||||
### 6. Realign documentation with the code
|
||||
|
||||
The design notes and README need to describe the actual architecture before a stable fix plan is made. Otherwise future changes will continue to optimize around an outdated mental model.
|
||||
|
||||
## Conclusion
|
||||
|
||||
RpgRoller did not fail because RoboForm existed. It failed because the app’s frontend architecture evolved into a shape where the authenticated workspace depended on fragile early render batches, mixed ownership boundaries, and multiple overlapping state channels.
|
||||
|
||||
RoboForm exposed that weakness reliably.
|
||||
|
||||
The correct next step is not another isolated workaround. The correct next step is to redesign the authenticated shell and workspace startup path around stable DOM ownership and simpler state flow.
|
||||
329
README.md
329
README.md
@@ -1,71 +1,289 @@
|
||||
# RpgRoller
|
||||
|
||||
Fresh full-stack starter scaffold:
|
||||
RpgRoller is an ASP.NET Core and Blazor Server app for lightweight tabletop campaign play, character sheets, and dice workflows.
|
||||
|
||||
- `RpgRoller/`: ASP.NET Core backend + Blazor frontend host (`Components` + `wwwroot`)
|
||||
- `RpgRoller.Tests/`: xUnit integration-heavy test project
|
||||
- `RpgRoller.sln`: solution used by local CI script
|
||||
- `UX.md`: frontend UX and interaction design specification (pre-implementation baseline)
|
||||
- `FRONTEND_PROGRESS.md`: implementation tracking (`Implemented` / `Partially implemented` / `Not yet implemented`)
|
||||
- `RpgRoller/`: web app, API endpoints, domain model, EF Core persistence, Blazor components, and static assets
|
||||
- `RpgRoller.Tests/`: xUnit coverage for API behavior, services, hosting, payload budgets, and persistence and migration paths
|
||||
- `RpgRoller.sln`: solution used by local development and repo scripts
|
||||
- `POSTMORTEM.md`: architecture analysis of the May 2026 Firefox and RoboForm failure in the authenticated workspace
|
||||
- `TASKS.md`: the completed execution log for the route-first authenticated shell rewrite
|
||||
|
||||
Test layout:
|
||||
|
||||
- `RpgRoller.Tests/Api/`: API integration tests grouped by feature concern
|
||||
- `RpgRoller.Tests/Services/`: service-level tests grouped by domain concern
|
||||
- `RpgRoller.Tests/Support/`: shared test harnesses/builders/helpers
|
||||
- `RpgRoller.Tests/Api/`: endpoint and host-facing integration tests
|
||||
- `RpgRoller.Tests/Services/`: service and rules-engine tests
|
||||
- `RpgRoller.Tests/Support/`: shared harnesses, builders, and test host helpers
|
||||
|
||||
## Code Organization
|
||||
|
||||
Backend:
|
||||
|
||||
- `RpgRoller/Program.cs`: thin app bootstrap only
|
||||
- `RpgRoller/Hosting/`: service registration + startup initialization
|
||||
- `RpgRoller/Api/`: endpoint mapping modules and auth/session filter helpers
|
||||
- `RpgRoller/Services/`: game workflows with explicit method parameters (no API DTO dependencies)
|
||||
- `RpgRoller/Program.cs`: app bootstrap, JSON options, compression, API and component mapping, and optional `PathBase`
|
||||
- `RpgRoller/Hosting/`: service registration, startup initialization, SQLite path resolution, and schema upgrades
|
||||
- `RpgRoller/Api/`: minimal API endpoint groups, request mappings, cookie and session helpers, and result mapping
|
||||
- `RpgRoller/Services/`: gameplay and account workflows behind `IGameService`
|
||||
- `RpgRoller/Services/GameService.cs`: facade over composed domain services
|
||||
- `RpgRoller/Services/GameAuthService.cs`: registration, login, logout, session lookup, and `GetMe`
|
||||
- `RpgRoller/Services/GameCampaignService.cs`: campaign creation, listing, roster reads, campaign options, and deletion
|
||||
- `RpgRoller/Services/GameCharacterService.cs`: character creation, updates, activation, deletion, transfer, and owner-scoped reads
|
||||
- `RpgRoller/Services/GameSkillService.cs`: skill-group CRUD, skill CRUD, sheet shaping, and ruleset validation
|
||||
- `RpgRoller/Services/GameRollService.cs`: skill and custom rolls, compact log pages, roll detail, and campaign state snapshots
|
||||
- `RpgRoller/Services/GameUserAdministrationService.cs`: username reads, admin user listing, role updates, and account deletion
|
||||
- `RpgRoller/Services/GameStateStore.cs`, `GameStateCloneFactory.cs`, and `GamePersistenceService.cs`: in-memory runtime state, campaign-state version tracking, and SQLite load and save boundaries
|
||||
- `RpgRoller/Services/GameAuthorization.cs`, `GameContextResolver.cs`, and `GameDtoMapper.cs`: shared authorization, session and campaign resolution, and backend read-model mapping
|
||||
- `RpgRoller/Services/RollEngine.cs`, `StandardRollEngine.cs`, `D6RollEngine.cs`, `RolemasterRollEngine.cs`, `RollBreakdownFormatter.cs`, and `CampaignLogSummaryBuilder.cs`: ruleset-specific dice execution, breakdown formatting, and compact campaign-log summaries
|
||||
- `RpgRoller/Services/SkillDefinitionValidator.cs`, `RoleSerializer.cs`, `RollVisibilityParser.cs`, and `CustomRollOptionsResolver.cs`: shared rules and parsing helpers
|
||||
|
||||
Frontend:
|
||||
|
||||
- `RpgRoller/Components/`: Blazor root app, routes, layout and page components
|
||||
- `RpgRoller/Components/Pages/Home.razor(.cs)`: main UX implementation for auth/play/management screens
|
||||
- `RpgRoller/wwwroot/js/rpgroller-api.js`: browser-side API + SSE + session storage interop for Blazor
|
||||
- `RpgRoller/wwwroot/styles.css`: responsive UX styling and theme tokens
|
||||
- `RpgRoller/Components/App.razor`: HTML shell that serves the static `/login` auth document or the per-page interactive authenticated route set based on request path
|
||||
- `RpgRoller/Components/Routes.razor`: Blazor router and layout hookup
|
||||
- `RpgRoller/Components/Layout/MainLayout.razor`: default layout
|
||||
- `RpgRoller/Components/Pages/LoginPage.razor`: route marker for the static `/login` auth document
|
||||
- `RpgRoller/Components/Pages/PlayPage.razor`, `CampaignsPage.razor`, and `AdminPage.razor`: authenticated route entry points for the interactive workspace
|
||||
- `RpgRoller/Components/Pages/AuthenticatedPageBase.cs`: shared logout-to-`/login` redirect helper for authenticated route pages
|
||||
- `RpgRoller/Components/Pages/Workspace.razor`: authenticated shell with shared header, health banner, toast stack, and route-owned body slot
|
||||
- `RpgRoller/Components/Pages/Workspace.razor.cs`: shell composition root, coordinator wiring, route initialization entry point, JS-invokable state-event hooks, and menu item construction
|
||||
- `RpgRoller/Components/Pages/WorkspaceRouteView.razor`: route-local first-render bootstrapper that initializes the interactive workspace after the page mounts
|
||||
- `RpgRoller/Components/Pages/PlayWorkspaceContent.razor`, `CampaignsWorkspaceContent.razor`, and `AdminWorkspaceContent.razor`: route-owned authenticated page subtrees
|
||||
- `RpgRoller/Components/Pages/CharacterManagementModals.razor`: shared create and edit character modals used by play and campaign-management routes
|
||||
- `RpgRoller/Components/Pages/WorkspaceState.cs`: workspace UI state plus pure computed and formatting projections used directly by the Razor view
|
||||
- `RpgRoller/Components/Pages/WorkspaceSessionCoordinator.cs`, `WorkspaceCampaignCoordinator.cs`, `WorkspaceCampaignScopeCoordinator.cs`, `WorkspacePlayCoordinator.cs`, `WorkspaceAdminCoordinator.cs`, `WorkspaceLiveStateController.cs`, `WorkspaceFeedbackService.cs`, and `WorkspaceToast.cs`: session bootstrap, campaign scope, play and log, admin, live update, and toast concerns used by `Workspace`
|
||||
- `RpgRoller/Components/Pages/HomeControls/StaticAuthPage.razor`: plain HTML login and registration page used at `/login`
|
||||
- `RpgRoller/Components/Pages/HomeControls/`: workspace child components, forms, header, panels, and modal controls
|
||||
- `RpgRoller/Components/RpgRollerApiClient.cs`: browser API client for write actions
|
||||
- `RpgRoller/Components/WorkspaceQueryService.cs`: browser-facing read client for workspace data
|
||||
- `RpgRoller/wwwroot/js/rpgroller-api.js`: browser interop for auth forms, session storage, SSE wiring, and DOM helpers
|
||||
- `RpgRoller/wwwroot/styles.css`: app styling and responsive layout
|
||||
|
||||
Backend state persistence:
|
||||
Current repo note:
|
||||
|
||||
- EF Core with SQLite (`Microsoft.EntityFrameworkCore.Sqlite`)
|
||||
- Development DB: `RpgRoller/App_Data/rpgroller.development.db`
|
||||
- Default DB: `RpgRoller/App_Data/rpgroller.db`
|
||||
- Database schema is created automatically on startup (`EnsureCreated`)
|
||||
- Runtime state is loaded once at startup into memory and written back to SQLite on successful state changes
|
||||
- `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.
|
||||
|
||||
## Prerequisites
|
||||
## Runtime and Persistence
|
||||
|
||||
- .NET SDK 10.0+
|
||||
- PowerShell 7+
|
||||
- Persistence uses EF Core with SQLite (`Microsoft.EntityFrameworkCore.Sqlite`).
|
||||
- The default database file is `RpgRoller/App_Data/rpgroller.db`.
|
||||
- `ConnectionStrings__RpgRoller` overrides the SQLite path for local runs, tests, or temporary environments.
|
||||
- Startup applies pending EF Core migrations through `Database.Migrate()`.
|
||||
- The app loads runtime state into memory during startup and persists successful state changes back to SQLite.
|
||||
- `RpgRoller/App_Data/rpgroller.development.db` is a checked-in migration coverage fixture used by hosting tests that copy it to a temporary file before validation.
|
||||
|
||||
## Product Capabilities
|
||||
|
||||
- Supported campaign rulesets: D6 System, D&D 5e, and Rolemaster
|
||||
- Account registration, login, session-based auth, and role-aware authorization
|
||||
- Admin tools for user listing, role updates, account deletion, and direct SQLite database download
|
||||
- Campaign creation, roster reads, participant-scoped visibility, and owner and admin deletion
|
||||
- Character creation, activation, owner transfer, campaign reassignment or unlinking, and owner and admin deletion
|
||||
- Skill groups with reusable defaults plus skill and skill-group create, edit, reassign, and delete flows
|
||||
- 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
|
||||
- Campaign management owner labels based on display names
|
||||
|
||||
Rolemaster support:
|
||||
|
||||
- Standard expressions such as `d10`, `15d10`, `2d10+48`, and `d100-15`
|
||||
- Open-ended percentile expressions such as `d100!+85`
|
||||
- Conditional `FumbleRange` handling for open-ended percentile skills and skill-group defaults
|
||||
- Persisted and validated automatic retry toggle for open-ended percentile skills; only eligible Rolemaster skills can enable it
|
||||
- Rolemaster skill rolls open a modal prompt before rolling so the player can apply a one-shot situational modifier; the prompt autofocuses, supports Enter and Escape, and closes when clicking outside it
|
||||
- One-shot situational modifiers are transient Rolemaster-only roll inputs; the temporary modifier is applied to both the first attempt and any automatic retry attempt
|
||||
- Automatic retry windows for eligible open-ended skills: results `76-90` retry once with `+5`, and results `91-110` retry once with `+10`
|
||||
- Open-ended high chaining and low-end subtraction with ordered die metadata in roll detail
|
||||
- Compact log badges and summaries for open-ended, retry, and fumble-related events, including `Retry +5` and `Retry +10`
|
||||
|
||||
## Current Frontend Architecture
|
||||
|
||||
The frontend now uses a route-first authenticated shell that keeps the anonymous auth document outside the interactive Blazor subtree.
|
||||
|
||||
`/` is an auth-aware entry redirect:
|
||||
|
||||
- anonymous `GET /` redirects to `/login`
|
||||
- authenticated `GET /` redirects to `/play`
|
||||
- `RpgRoller/Components/App.razor` serves the static `/login` document or the interactive route set based on the request path, not auth state
|
||||
|
||||
Inside the authenticated app, `/play`, `/campaigns`, and `/admin` are real Blazor routes, and the hamburger menu navigates between those URLs. `Workspace.razor` is now a shared shell only. Each authenticated route owns its own main content subtree through a route-specific component.
|
||||
|
||||
Authenticated interactivity is route-local instead of global:
|
||||
|
||||
- `App.razor` no longer applies `@rendermode` to `Routes` or `HeadOutlet`
|
||||
- `PlayPage.razor`, `CampaignsPage.razor`, and `AdminPage.razor` each opt into `InteractiveServerRenderMode(prerender: false)` directly
|
||||
- Blazor startup is manual with `Blazor.start({ ssr: { disableDomPreservation: true } })` so the app can disable enhanced SSR DOM preservation during interactive attach
|
||||
- Header route changes now use full document navigation so moving between authenticated routes remounts the target per-page interactive root instead of trying to reuse the previous page circuit
|
||||
|
||||
Firefox plus RoboForm resolution:
|
||||
|
||||
- the route-first rewrite reduced the authenticated surface area, but it was not the final fix
|
||||
- the crash stopped only after the app stopped using global Blazor interactivity
|
||||
- the working combination is:
|
||||
- per-page `InteractiveServerRenderMode(prerender: false)` on `/play`, `/campaigns`, and `/admin`
|
||||
- manual `Blazor.start({ ssr: { disableDomPreservation: true } })`
|
||||
- full document navigation between authenticated routes with `forceLoad: true`
|
||||
- earlier phased first-render shells and heavy diagnostics were investigative steps and have been removed
|
||||
|
||||
Interactive bootstrap is now route-local:
|
||||
|
||||
- `WorkspaceRouteView.razor` performs the first-render JS-dependent session initialization for the authenticated route that mounted
|
||||
- `Workspace.razor.cs` no longer uses `OnAfterRenderAsync` as the shell bootstrap orchestrator
|
||||
- play-specific post-render behavior is limited to page-local controls such as log auto-scroll and modal autofocus inside child components
|
||||
|
||||
Remaining architectural constraints are deliberate:
|
||||
|
||||
- `/login` stays plain HTML plus JavaScript so the anonymous auth path avoids Blazor form ownership entirely
|
||||
- authenticated reads and writes still depend on JS interop-backed `fetch`, so first interactive initialization must still happen after mount
|
||||
- live updates still use SSE and route-aware synchronization, with `/play` as the only route that keeps the play log and selected character sheet live
|
||||
|
||||
## Route-First Authenticated Shell
|
||||
|
||||
- `/` becomes an auth-aware entry point that redirects to `/login` or `/play`
|
||||
- `/login` hosts the anonymous auth experience
|
||||
- `/play`, `/campaigns`, and `/admin` become real authenticated routes
|
||||
- the hamburger menu becomes route navigation instead of in-memory screen switching
|
||||
- SSE and heavy play bootstrap stay scoped to `/play`
|
||||
- the large `Workspace` component is split so each route owns a smaller, more stable subtree
|
||||
|
||||
This rewrite is complete. See `TASKS.md` for the execution history and milestone notes.
|
||||
|
||||
## Local Development
|
||||
|
||||
1. Run the local CI parity script:
|
||||
```powershell
|
||||
pwsh ./scripts/ci-local.ps1
|
||||
```
|
||||
2. Start the backend:
|
||||
```powershell
|
||||
Prerequisites:
|
||||
|
||||
- .NET SDK 10.0+
|
||||
- Node.js 22+
|
||||
- Firefox
|
||||
- geckodriver
|
||||
|
||||
Initial setup:
|
||||
|
||||
```bash
|
||||
dotnet tool restore
|
||||
npm ci
|
||||
```
|
||||
|
||||
Run locally:
|
||||
|
||||
1. Start the app:
|
||||
```bash
|
||||
dotnet run --project RpgRoller/RpgRoller.csproj
|
||||
```
|
||||
3. Open `http://localhost:5000` (or the port shown in the console).
|
||||
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.
|
||||
|
||||
To use a custom SQLite database path, set `ConnectionStrings__RpgRoller`.
|
||||
Browser smoke helpers:
|
||||
|
||||
- Run the checked-in smoke suite against an isolated temporary SQLite database:
|
||||
```bash
|
||||
node ./scripts/run-selenium.js
|
||||
```
|
||||
- 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`:
|
||||
|
||||
- `RpgRoller: Server`
|
||||
- `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.
|
||||
- Set `PathBase` to host the app under a sub-path such as `/rpgroller`.
|
||||
|
||||
Migration authoring:
|
||||
|
||||
```powershell
|
||||
dotnet dotnet-ef migrations add <MigrationName> --project RpgRoller/RpgRoller.csproj --startup-project RpgRoller/RpgRoller.csproj
|
||||
```
|
||||
|
||||
SQLite migration rule:
|
||||
|
||||
- Keep table-rebuild operations separate from unrelated schema or data changes so EF Core does not emit non-transactional migration warnings.
|
||||
|
||||
## Frontend Runtime
|
||||
|
||||
- Runtime frontend is Blazor Server with interactive components.
|
||||
- Browser interop is in `RpgRoller/wwwroot/js/rpgroller-api.js`.
|
||||
- OpenAPI contract source remains at `openapi/RpgRoller.json`.
|
||||
- The UI runs as route-local Blazor Server components for authenticated routes and as plain HTML plus JavaScript for the anonymous `/login` document.
|
||||
- Static assets are linked through Blazor's `@Assets[...]` pipeline for fingerprinted cache-busting URLs.
|
||||
- Workspace reads are resolved through API requests in `WorkspaceQueryService`; browser interop stays focused on auth forms, session storage, SSE wiring, and small DOM helpers.
|
||||
- Interactive authenticated startup begins in `WorkspaceRouteView.razor` after first render because `RpgRollerApiClient` still depends on JS interop-backed `fetch`.
|
||||
- Authenticated routes avoid global `Routes @rendermode` because upstream issue `dotnet/aspnetcore#58824` reports Firefox-specific failures with global interactivity and explicitly calls out per-page mode as the safer path.
|
||||
- Authenticated route changes use full document navigations so each route remounts its own per-page interactive root.
|
||||
- Live workspace refresh compares separate roster, per-character sheet, and log versions so unrelated changes do not trigger full reloads.
|
||||
- Campaign log data is loaded in bounded slices: campaign summaries, one selected roster, one selected character sheet, and a 25-row incremental log window from `/api/campaigns/{campaignId}/log/page`.
|
||||
- Log rows return compact summary data first and lazy-load full detail from `/api/rolls/{rollId}` when expanded.
|
||||
- Newly appended local rolls auto-expand in the play workspace and reuse the roll response as the initial detail payload.
|
||||
- Custom roll submission uses the selected character context; D6 uses baseline wild-die and fumble behavior, while D&D 5e and Rolemaster use the submitted expression directly.
|
||||
- API JSON contracts use the source-generated `RpgRollerJsonSerializerContext`.
|
||||
- HTTP JSON responses are gzip-compressed when the client advertises support.
|
||||
- The OpenAPI contract source lives at `openapi/RpgRoller.json`.
|
||||
|
||||
## Test and Coverage
|
||||
|
||||
- Tests:
|
||||
- Test command:
|
||||
```powershell
|
||||
dotnet test RpgRoller.Tests/RpgRoller.Tests.csproj --collect:"XPlat Code Coverage" --settings RpgRoller.Tests/coverlet.runsettings
|
||||
```
|
||||
@@ -73,31 +291,10 @@ To use a custom SQLite database path, set `ConnectionStrings__RpgRoller`.
|
||||
```powershell
|
||||
pwsh ./scripts/check-coverage.ps1 -MinLineRate 0.90 -MinBranchRate 0.70
|
||||
```
|
||||
- Coverage collector scope:
|
||||
- `RpgRoller.Tests/coverlet.runsettings` now measures the full backend assembly (`RpgRoller`), not only service namespace files.
|
||||
|
||||
## Implemented Backend Scope
|
||||
|
||||
- Auth: register, login, logout, current user context
|
||||
- Session cookie: `HttpOnly`, `SameSite=Strict`, `Secure` when served over HTTPS
|
||||
- Rulesets: d6 and dnd5e validation rules
|
||||
- Campaigns: create/list/read
|
||||
- Characters: create/update/activate/current-campaign list
|
||||
- Skills: create/update with ruleset-aware dice expression validation and d6 wild-dice/fumble options
|
||||
- Rolls: public/private skill rolls with append-only campaign log; d6 rolls include wild/crit/fumble/add/remove die-state payloads
|
||||
- State stream: SSE endpoint for campaign version updates
|
||||
|
||||
## Implemented Frontend Scope
|
||||
|
||||
- Blazor-driven UI for:
|
||||
- registration, login, logout
|
||||
- play screen and campaign management screen switch
|
||||
- campaign creation and selection
|
||||
- character create/edit/activate via modal forms
|
||||
- skill create/edit via modal forms including d6 wild dice + allow-fumble controls
|
||||
- public/private rolling and campaign log viewing
|
||||
- die-state visualization in Last Roll (critical, fumble, wild, removed, added)
|
||||
- responsive play UX:
|
||||
- desktop two-column (character + log)
|
||||
- tablet/mobile panel switching with bottom tab bar (`Character` / `Log`)
|
||||
- SSE-backed live refresh with reconnect status + manual refresh fallback
|
||||
- Local parity script:
|
||||
```powershell
|
||||
pwsh ./scripts/ci-local.ps1
|
||||
```
|
||||
- `scripts/ci-local.ps1` writes coverage collector output to a unique temporary results directory outside the repo, reads coverage from there, removes that directory at the end of the run, and sweeps stray `coverage.cobertura.xml` files from `RpgRoller.Tests/TestResults`.
|
||||
- Regression tests enforce payload budgets for character sheet reads, initial and incremental campaign log loads, roll mutation responses, and lazy-loaded Rolemaster roll detail payloads.
|
||||
- `RpgRoller.Tests/coverlet.runsettings` measures the full `RpgRoller` backend assembly.
|
||||
|
||||
308
REQUIREMENTS.md
308
REQUIREMENTS.md
@@ -1,308 +0,0 @@
|
||||
# 1. Stakeholders
|
||||
|
||||
### Primary Actors
|
||||
|
||||
* **Player**
|
||||
|
||||
* Owns and manages characters
|
||||
* Participates in campaigns
|
||||
* Performs skill checks (dice rolls)
|
||||
|
||||
* **Game Master (GM)**
|
||||
|
||||
* Like a player (with regards to owning and managing characters)
|
||||
* Owns and manages campaigns
|
||||
* Oversees gameplay within a campaign
|
||||
* Has visibility into all rolls (including private ones)
|
||||
|
||||
* **System / Platform**
|
||||
|
||||
* Enforces rulesets
|
||||
* Manages state (active character, current campaign)
|
||||
* Stores logs and configurations
|
||||
|
||||
---
|
||||
|
||||
### Secondary Stakeholders
|
||||
|
||||
* **Ruleset Designers / Maintainers**
|
||||
|
||||
* Define dice mechanics (e.g., d6, D&D 5e)
|
||||
* Configure skill roll formulas and constraints
|
||||
|
||||
* **Observers (future optional role)**
|
||||
|
||||
* View campaign logs without participating
|
||||
|
||||
---
|
||||
|
||||
# 2. Core Domain Model (Refined)
|
||||
|
||||
### User
|
||||
|
||||
* Attributes:
|
||||
|
||||
* username (unique)
|
||||
* password (secured)
|
||||
* displayName
|
||||
* Relationships:
|
||||
|
||||
* Owns multiple **Characters**
|
||||
* Owns multiple **Campaigns** (as GM)
|
||||
|
||||
---
|
||||
|
||||
### Campaign
|
||||
|
||||
* Attributes:
|
||||
|
||||
* name
|
||||
* ruleset (exactly one)
|
||||
* Relationships:
|
||||
|
||||
* Owned by one **User (GM)**
|
||||
* Contains multiple **Characters**
|
||||
* Has a **shared log**
|
||||
|
||||
---
|
||||
|
||||
### Ruleset
|
||||
|
||||
* Predefined initially:
|
||||
|
||||
* d6 system
|
||||
* D&D 5e
|
||||
* Defines:
|
||||
|
||||
* Dice notation rules
|
||||
* Skill behavior constraints
|
||||
|
||||
---
|
||||
|
||||
### Character
|
||||
|
||||
* Attributes:
|
||||
|
||||
* name
|
||||
* Relationships:
|
||||
|
||||
* Owned by one **User (Player)**
|
||||
* Belongs to one **Campaign**
|
||||
* Has multiple **Skills**
|
||||
|
||||
---
|
||||
|
||||
### Skill
|
||||
|
||||
* Attributes:
|
||||
|
||||
* name
|
||||
* diceRollDefinition (ruleset-compliant expression, e.g. `5D+4`, `2d12+2`)
|
||||
* wildDice (d6 only; number of wild dice)
|
||||
* allowFumble (d6 only; whether wild-1 fumbles remove dice)
|
||||
* Behavior:
|
||||
|
||||
* Can be rolled
|
||||
* Can be edited by Player or GM
|
||||
|
||||
---
|
||||
|
||||
### Dice Roll
|
||||
|
||||
* Attributes:
|
||||
|
||||
* result
|
||||
* visibility (public | private)
|
||||
* timestamp
|
||||
* Relationships:
|
||||
|
||||
* Linked to a **Skill**
|
||||
* Logged in **Campaign Log**
|
||||
|
||||
---
|
||||
|
||||
### Campaign Log
|
||||
|
||||
* Contains:
|
||||
|
||||
* Chronological list of dice rolls
|
||||
* Visibility:
|
||||
|
||||
* Visible to all users in the campaign
|
||||
* Private rolls visible only to:
|
||||
|
||||
* roller
|
||||
* GM
|
||||
|
||||
---
|
||||
|
||||
# 3. Functional Requirements (Expanded)
|
||||
|
||||
### User Management
|
||||
|
||||
* Users must be able to:
|
||||
|
||||
* Register with username, password, display name
|
||||
* Authenticate securely
|
||||
* System must:
|
||||
|
||||
* Enforce unique usernames
|
||||
* Store passwords securely (hashed)
|
||||
|
||||
---
|
||||
|
||||
### Campaign Management
|
||||
|
||||
* A GM can:
|
||||
|
||||
* Create campaigns
|
||||
* Assign a ruleset to a campaign
|
||||
* Manage participating characters
|
||||
* A campaign:
|
||||
|
||||
* Must always have exactly one GM
|
||||
* Must always use exactly one ruleset
|
||||
|
||||
---
|
||||
|
||||
### Character Management
|
||||
|
||||
* A Player can:
|
||||
|
||||
* Create characters
|
||||
* Assign characters to campaigns
|
||||
* Edit character details
|
||||
* Constraints:
|
||||
|
||||
* A character belongs to exactly one campaign
|
||||
* A player may have multiple characters across campaigns
|
||||
|
||||
---
|
||||
|
||||
### Active Character Context
|
||||
|
||||
* A Player can:
|
||||
|
||||
* Activate one character at a time
|
||||
* System must:
|
||||
|
||||
* Enforce **at most one active character per player**
|
||||
* Derive the **current campaign** from the active character
|
||||
|
||||
---
|
||||
|
||||
### Skill Management
|
||||
|
||||
* Players and GMs can:
|
||||
|
||||
* Create skills for characters
|
||||
* Edit skill definitions
|
||||
* System must:
|
||||
|
||||
* Validate dice expressions against the campaign ruleset
|
||||
* Validate d6 skill options (`wildDice`, `allowFumble`) as part of skill create/edit
|
||||
|
||||
---
|
||||
|
||||
### Dice Rolling
|
||||
|
||||
* Players and GMs can:
|
||||
|
||||
* Roll dice for a skill
|
||||
* Choose visibility:
|
||||
|
||||
* Public → visible to all
|
||||
* Private → visible only to roller + GM
|
||||
* System must:
|
||||
|
||||
* Evaluate dice expressions deterministically and fairly
|
||||
* For d6 skills, apply wild-die explosions and fumble-removal logic
|
||||
* Record all rolls in the campaign log
|
||||
* Return die-by-die roll states so the frontend can visualize critical/fumble/wild/removed/added outcomes
|
||||
|
||||
---
|
||||
|
||||
### Campaign Log
|
||||
|
||||
* System must:
|
||||
|
||||
* Maintain a chronological log of all dice rolls
|
||||
* Users can:
|
||||
|
||||
* View the log of the current campaign
|
||||
* Visibility rules:
|
||||
|
||||
* Public rolls → visible to all participants
|
||||
* Private rolls → restricted to roller + GM
|
||||
|
||||
---
|
||||
|
||||
# 4. User Stories
|
||||
|
||||
### User / Account
|
||||
|
||||
* As a **user**, I want to register with a username and password so that I can access the system.
|
||||
* As a **user**, I want a display name so that others can identify me in campaigns.
|
||||
|
||||
---
|
||||
|
||||
### Campaign (GM)
|
||||
|
||||
* As a **GM**, I want to create a campaign so that I can run a game session.
|
||||
* As a **GM**, I want to activate a campaign so that the system knows my current context.
|
||||
* As a **GM**, I want to select a ruleset so that gameplay follows a defined system.
|
||||
* As a **GM**, I want to manage which characters participate so that I control the session.
|
||||
* As a **GM**, I want to see all characters of my current campaign, including all of their skills.
|
||||
|
||||
---
|
||||
|
||||
### Character (Player)
|
||||
|
||||
* As a **player**, I want to create characters so that I can participate in campaigns.
|
||||
* As a **player**, I want to assign my character to a campaign so that I can join a game.
|
||||
* As a **player**, I want to activate one campaign so that the system knows my current context.
|
||||
* As a **player**, I want to see all my characters of the current current campaign, including all of their skills.
|
||||
|
||||
---
|
||||
|
||||
### Skills
|
||||
|
||||
* As a **player**, I want to define skills with dice formulas so that I can perform actions.
|
||||
* As a **player**, I want to configure wild dice and fumble behavior for d6 skills so the roll follows my table rules.
|
||||
* As a **GM**, I want to edit character skills so that I can enforce or adjust rules.
|
||||
|
||||
---
|
||||
|
||||
### Dice Rolling
|
||||
|
||||
* As a **player**, I want to roll dice for a skill so that I can resolve actions.
|
||||
* As a **player**, I want to see which dice were wild, exploded, fumbled, removed, or added so that I can audit the roll result.
|
||||
* As a **user**, I want to choose whether a roll is public or private so that I can control information visibility.
|
||||
* As a **GM**, I want to see all rolls (including private ones) so that I can oversee the game.
|
||||
|
||||
---
|
||||
|
||||
### Campaign Log
|
||||
|
||||
* As a **user**, I want to see the campaign log so that I can track what happened.
|
||||
* As a **user**, I want the log to update in real time so that I stay synchronized with the session.
|
||||
* As a **user**, I want private rolls hidden unless permitted so that secrecy is preserved.
|
||||
|
||||
---
|
||||
|
||||
# 5. Implicit Constraints & Edge Cases
|
||||
|
||||
* A player cannot:
|
||||
|
||||
* Activate multiple campaigns simultaneously
|
||||
* Join a campaign without a character
|
||||
* A character cannot:
|
||||
|
||||
* Exist without an owner
|
||||
* Belong to multiple campaigns simultaneously
|
||||
* Dice expressions must:
|
||||
|
||||
* Be validated per ruleset (no cross-system syntax leakage)
|
||||
* Log integrity:
|
||||
|
||||
* Must be append-only (no tampering with past rolls)
|
||||
@@ -1,24 +1,19 @@
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
|
||||
namespace RpgRoller.Tests;
|
||||
|
||||
public sealed class AuthApiTests : ApiTestBase
|
||||
public sealed class AuthApiTests(WebApplicationFactory<Program> factory) : ApiTestBase(factory)
|
||||
{
|
||||
public AuthApiTests(WebApplicationFactory<Program> factory)
|
||||
: base(factory)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RegisterLoginAndMeFlow_WorksWithDuplicateUsernameGuard()
|
||||
{
|
||||
using var factory = CreateFactory(4, 4, 4);
|
||||
using var client = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
|
||||
using var client = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
|
||||
var registerResult = await RegisterAsync(client, "alice", "Password123", "Alice");
|
||||
Assert.Equal("alice", registerResult.Username);
|
||||
Assert.Contains(registerResult.Roles, role => string.Equals(role, "admin", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
var duplicate = await client.PostAsJsonAsync("/api/auth/register", new RegisterRequest("alice", "Password123", "Alice 2"));
|
||||
var duplicate = await client.PostAsJsonAsync("/api/auth/register",
|
||||
new RegisterRequest("alice", "Password123", "Alice 2"));
|
||||
Assert.Equal(HttpStatusCode.BadRequest, duplicate.StatusCode);
|
||||
|
||||
var loginResult = await client.PostAsJsonAsync("/api/auth/login", new LoginRequest("alice", "Password123"));
|
||||
@@ -32,4 +27,45 @@ public sealed class AuthApiTests : ApiTestBase
|
||||
var invalidLogin = await client.PostAsJsonAsync("/api/auth/login", new LoginRequest("alice", "wrong-password"));
|
||||
Assert.Equal(HttpStatusCode.BadRequest, invalidLogin.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UsernamesEndpoint_RequiresAuthAndReturnsAlphabeticalList()
|
||||
{
|
||||
using var factory = CreateFactory();
|
||||
using var client = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
|
||||
await RegisterAsync(client, "zoe", "Password123", "Zoe");
|
||||
await RegisterAsync(client, "amy", "Password123", "Amy");
|
||||
await RegisterAsync(client, "bob", "Password123", "Bob");
|
||||
|
||||
var unauthorized = await client.GetAsync("/api/users/usernames");
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, unauthorized.StatusCode);
|
||||
|
||||
await LoginAsync(client, "bob", "Password123");
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,78 +1,498 @@
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using System.Text;
|
||||
|
||||
namespace RpgRoller.Tests;
|
||||
|
||||
public sealed class CampaignApiTests : ApiTestBase
|
||||
public sealed class CampaignApiTests(WebApplicationFactory<Program> factory) : ApiTestBase(factory)
|
||||
{
|
||||
public CampaignApiTests(WebApplicationFactory<Program> factory)
|
||||
: base(factory)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CampaignCharacterAndSkillFlow_EnforcesRulesetValidation()
|
||||
{
|
||||
using var factory = CreateFactory(6, 6, 6);
|
||||
using var gmClient = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
|
||||
using var gmClient = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
|
||||
await RegisterAsync(gmClient, "gm", "Password123", "Game Master");
|
||||
await LoginAsync(gmClient, "gm", "Password123");
|
||||
|
||||
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(
|
||||
gmClient,
|
||||
"/api/campaigns",
|
||||
new CreateCampaignRequest("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 CreateCharacterRequest("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 CreateSkillRequest("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 UpdateSkillRequest("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",
|
||||
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<CampaignDetails>(gmClient, $"/api/campaigns/{campaign.Id}");
|
||||
var details = await GetAsync<CampaignRoster>(gmClient, $"/api/campaigns/{campaign.Id}");
|
||||
Assert.Equal(campaign.Id, details.Id);
|
||||
Assert.Single(details.Characters);
|
||||
Assert.Single(details.Skills);
|
||||
Assert.Equal("Game Master", details.Characters[0].OwnerDisplayName);
|
||||
|
||||
var currentCampaignCharacters = await GetAsync<IReadOnlyList<CharacterSummary>>(gmClient, "/api/characters/current-campaign");
|
||||
var summaries = await GetAsync<IReadOnlyList<CampaignSummary>>(gmClient, "/api/campaigns");
|
||||
Assert.Single(summaries);
|
||||
Assert.Equal(1, summaries[0].CharacterCount);
|
||||
|
||||
var sheet = await GetAsync<CharacterSheet>(gmClient, $"/api/characters/{gmCharacter.Id}/sheet");
|
||||
Assert.Single(sheet.Skills);
|
||||
Assert.Equal(updatedSkill.Id, sheet.Skills[0].Id);
|
||||
|
||||
var currentCampaignCharacters = await GetAsync<IReadOnlyList<CharacterSummary>>(gmClient, "/api/characters");
|
||||
Assert.Single(currentCampaignCharacters);
|
||||
Assert.Equal(gmCharacter.Id, currentCampaignCharacters[0].Id);
|
||||
Assert.Equal("Game Master", currentCampaignCharacters[0].OwnerDisplayName);
|
||||
|
||||
var otherCampaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(
|
||||
gmClient,
|
||||
"/api/campaigns",
|
||||
new CreateCampaignRequest("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 UpdateCharacterRequest("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()
|
||||
{
|
||||
using var factory = CreateFactory(2, 2, 2);
|
||||
using var gmClient = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
|
||||
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"));
|
||||
|
||||
Assert.Equal("rolemaster", campaign.RulesetId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RolemasterSkillDefinitions_RoundTripRetryAndFumbleOptionsThroughApi()
|
||||
{
|
||||
using var factory = CreateFactory(88, 42, 17);
|
||||
using var gmClient = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
|
||||
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 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));
|
||||
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));
|
||||
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));
|
||||
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));
|
||||
Assert.Equal(4, updatedSkill.FumbleRange);
|
||||
Assert.True(updatedSkill.RolemasterAutoRetry);
|
||||
|
||||
var sheet = await GetAsync<CharacterSheet>(gmClient, $"/api/characters/{character.Id}/sheet");
|
||||
Assert.Equal(5, Assert.Single(sheet.SkillGroups).FumbleRange);
|
||||
var sheetSkill = Assert.Single(sheet.Skills);
|
||||
Assert.Equal(4, sheetSkill.FumbleRange);
|
||||
Assert.True(sheetSkill.RolemasterAutoRetry);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SkillGroupsAndOwnerTransfer_WorkThroughApi()
|
||||
{
|
||||
using var factory = CreateFactory(6, 4, 5, 3);
|
||||
using var gmClient = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
using var ownerClient = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
using var receiverClient = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
|
||||
await RegisterAsync(gmClient, "gm2", "Password123", "GM");
|
||||
await LoginAsync(gmClient, "gm2", "Password123");
|
||||
|
||||
await RegisterAsync(ownerClient, "owner2", "Password123", "Owner");
|
||||
await LoginAsync(ownerClient, "owner2", "Password123");
|
||||
|
||||
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 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));
|
||||
Assert.Equal(renamedGroup.Id, groupedSkill.SkillGroupId);
|
||||
|
||||
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));
|
||||
Assert.Equal(renamedGroup.Id, groupedAgainSkill.SkillGroupId);
|
||||
|
||||
var deleteSkill = await ownerClient.DeleteAsync($"/api/skills/{groupedAgainSkill.Id}");
|
||||
Assert.Equal(HttpStatusCode.OK, deleteSkill.StatusCode);
|
||||
|
||||
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"));
|
||||
Assert.Equal("Grouped Hero", transferResult.Name);
|
||||
Assert.Equal("Receiver", transferResult.OwnerDisplayName);
|
||||
|
||||
var gmCampaignView = await GetAsync<CampaignRoster>(gmClient, $"/api/campaigns/{campaign.Id}");
|
||||
var gmViewedCharacter = Assert.Single(gmCampaignView.Characters, c => c.Id == character.Id);
|
||||
Assert.Equal("Receiver", gmViewedCharacter.OwnerDisplayName);
|
||||
|
||||
var ownerActivate = await ownerClient.PostAsync($"/api/characters/{character.Id}/activate", null);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, ownerActivate.StatusCode);
|
||||
|
||||
var receiverActivate = await receiverClient.PostAsync($"/api/characters/{character.Id}/activate", null);
|
||||
Assert.Equal(HttpStatusCode.OK, receiverActivate.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AdminUserManagementAndCampaignDeletion_WorkThroughApi()
|
||||
{
|
||||
using var factory = CreateFactory(6, 5, 4);
|
||||
using var adminClient = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
using var gmClient = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
using var playerClient = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
|
||||
var admin = await RegisterAsync(adminClient, "admin3", "Password123", "Admin");
|
||||
var gm = await RegisterAsync(gmClient, "gm3", "Password123", "GM");
|
||||
var player = await RegisterAsync(playerClient, "player3", "Password123", "Player");
|
||||
|
||||
await LoginAsync(adminClient, "admin3", "Password123");
|
||||
await LoginAsync(gmClient, "gm3", "Password123");
|
||||
await LoginAsync(playerClient, "player3", "Password123");
|
||||
|
||||
var adminUsers = await GetAsync<IReadOnlyList<AdminUserSummary>>(adminClient, "/api/admin/users");
|
||||
var adminEntry = adminUsers.Single(user => user.Id == admin.Id);
|
||||
var playerEntry = adminUsers.Single(user => user.Id == player.Id);
|
||||
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"]));
|
||||
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));
|
||||
_ = await PostAsync<RollSkillRequest, RollResult>(playerClient, $"/api/skills/{skill.Id}/roll", new("public"));
|
||||
|
||||
var deleteCampaign = await adminClient.DeleteAsync($"/api/campaigns/{campaign.Id}");
|
||||
Assert.Equal(HttpStatusCode.OK, deleteCampaign.StatusCode);
|
||||
|
||||
var getDeletedCampaign = await gmClient.GetAsync($"/api/campaigns/{campaign.Id}");
|
||||
Assert.Equal(HttpStatusCode.BadRequest, getDeletedCampaign.StatusCode);
|
||||
|
||||
var playerCharacters = await GetAsync<IReadOnlyList<CharacterSummary>>(playerClient, "/api/characters");
|
||||
Assert.Single(playerCharacters);
|
||||
Assert.Null(playerCharacters[0].CampaignId);
|
||||
|
||||
var deleteUser = await adminClient.DeleteAsync($"/api/admin/users/{player.Id}");
|
||||
Assert.Equal(HttpStatusCode.OK, deleteUser.StatusCode);
|
||||
|
||||
var usersAfterDelete = await GetAsync<IReadOnlyList<AdminUserSummary>>(adminClient, "/api/admin/users");
|
||||
Assert.DoesNotContain(usersAfterDelete, user => user.Id == player.Id);
|
||||
Assert.Contains(usersAfterDelete, user => user.Id == gm.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AdminDatabaseDownload_RequiresAdminAndReturnsSqliteFile()
|
||||
{
|
||||
using var factory = CreateFactory();
|
||||
using var anonymousClient = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
using var adminClient = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
using var memberClient = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
|
||||
await RegisterAsync(adminClient, "admin-download", "Password123", "Admin Download");
|
||||
await LoginAsync(adminClient, "admin-download", "Password123");
|
||||
|
||||
await RegisterAsync(memberClient, "member-download", "Password123", "Member Download");
|
||||
await LoginAsync(memberClient, "member-download", "Password123");
|
||||
|
||||
var unauthorized = await anonymousClient.GetAsync("/api/admin/database");
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, unauthorized.StatusCode);
|
||||
|
||||
var forbidden = await memberClient.GetAsync("/api/admin/database");
|
||||
Assert.Equal(HttpStatusCode.BadRequest, forbidden.StatusCode);
|
||||
|
||||
var response = await adminClient.GetAsync("/api/admin/database");
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal("application/octet-stream", response.Content.Headers.ContentType?.MediaType);
|
||||
|
||||
var disposition = response.Content.Headers.ContentDisposition;
|
||||
Assert.NotNull(disposition);
|
||||
Assert.Equal("attachment", disposition.DispositionType);
|
||||
Assert.EndsWith(".db", disposition.FileName?.Trim('"'), StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
var bytes = await response.Content.ReadAsByteArrayAsync();
|
||||
Assert.True(bytes.Length >= 16);
|
||||
Assert.Equal("SQLite format 3\0", Encoding.ASCII.GetString(bytes, 0, 16));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CampaignOptionsEndpoint_ReturnsCampaignsBeyondVisibleCampaignList()
|
||||
{
|
||||
using var factory = CreateFactory(6, 5, 4);
|
||||
using var gmClient = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
using var otherGmClient = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
using var playerClient = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
|
||||
await RegisterAsync(gmClient, "gm-options-1", "Password123", "GM One");
|
||||
await LoginAsync(gmClient, "gm-options-1", "Password123");
|
||||
|
||||
await RegisterAsync(otherGmClient, "gm-options-2", "Password123", "GM Two");
|
||||
await LoginAsync(otherGmClient, "gm-options-2", "Password123");
|
||||
|
||||
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 playerVisibleCampaigns = await GetAsync<IReadOnlyList<CampaignSummary>>(playerClient, "/api/campaigns");
|
||||
Assert.Empty(playerVisibleCampaigns);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CharacterDelete_RequiresOwnerOrAdmin()
|
||||
{
|
||||
using var factory = CreateFactory(6, 5, 4);
|
||||
using var adminClient = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
using var gmClient = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
using var ownerClient = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
using var otherClient = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
|
||||
await RegisterAsync(adminClient, "admin-delete", "Password123", "Admin");
|
||||
await LoginAsync(adminClient, "admin-delete", "Password123");
|
||||
|
||||
await RegisterAsync(gmClient, "gm-delete", "Password123", "GM");
|
||||
await LoginAsync(gmClient, "gm-delete", "Password123");
|
||||
|
||||
await RegisterAsync(ownerClient, "owner-delete", "Password123", "Owner");
|
||||
await LoginAsync(ownerClient, "owner-delete", "Password123");
|
||||
|
||||
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 gmDeleteAttempt = await gmClient.DeleteAsync($"/api/characters/{ownerCharacter.Id}");
|
||||
Assert.Equal(HttpStatusCode.BadRequest, gmDeleteAttempt.StatusCode);
|
||||
|
||||
var otherDeleteAttempt = await otherClient.DeleteAsync($"/api/characters/{ownerCharacter.Id}");
|
||||
Assert.Equal(HttpStatusCode.BadRequest, otherDeleteAttempt.StatusCode);
|
||||
|
||||
var ownerDelete = await ownerClient.DeleteAsync($"/api/characters/{ownerCharacter.Id}");
|
||||
Assert.Equal(HttpStatusCode.OK, ownerDelete.StatusCode);
|
||||
|
||||
var adminDelete = await adminClient.DeleteAsync($"/api/characters/{otherCharacter.Id}");
|
||||
Assert.Equal(HttpStatusCode.OK, adminDelete.StatusCode);
|
||||
|
||||
var campaignAfterDeletes = await GetAsync<CampaignRoster>(gmClient, $"/api/campaigns/{campaign.Id}");
|
||||
Assert.Empty(campaignAfterDeletes.Characters);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CampaignLog_ReturnsMostRecentHundredEntries()
|
||||
{
|
||||
using var factory = CreateFactory();
|
||||
using var gmClient = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
using var playerClient = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
|
||||
await RegisterAsync(gmClient, "gm-log-cap", "Password123", "GM");
|
||||
await LoginAsync(gmClient, "gm-log-cap", "Password123");
|
||||
|
||||
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 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"));
|
||||
rollIds.Add(roll.RollId);
|
||||
}
|
||||
|
||||
var log = await GetAsync<IReadOnlyList<CampaignLogEntry>>(gmClient, $"/api/campaigns/{campaign.Id}/log");
|
||||
Assert.Equal(100, log.Count);
|
||||
Assert.Equal(rollIds[5], log[0].RollId);
|
||||
Assert.Equal(rollIds[^1], log[^1].RollId);
|
||||
Assert.All(log, entry =>
|
||||
{
|
||||
Assert.False(string.IsNullOrWhiteSpace(entry.CharacterName));
|
||||
Assert.False(string.IsNullOrWhiteSpace(entry.SkillName));
|
||||
Assert.False(string.IsNullOrWhiteSpace(entry.RollerDisplayName));
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CampaignLogPage_ReturnsInitialAndIncrementalResults()
|
||||
{
|
||||
using var factory = CreateFactory();
|
||||
using var gmClient = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
using var playerClient = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
|
||||
await RegisterAsync(gmClient, "gm-log-page", "Password123", "GM");
|
||||
await LoginAsync(gmClient, "gm-log-page", "Password123");
|
||||
|
||||
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 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"));
|
||||
rollIds.Add(roll.RollId);
|
||||
}
|
||||
|
||||
var initialPage = await GetAsync<CampaignLogPage>(gmClient, $"/api/campaigns/{campaign.Id}/log/page?limit=3");
|
||||
Assert.Equal(3, initialPage.Entries.Length);
|
||||
Assert.Equal(rollIds[2], initialPage.Entries[0].RollId);
|
||||
Assert.Equal(rollIds[^1], initialPage.Entries[^1].RollId);
|
||||
Assert.Equal(rollIds[^1], initialPage.Cursor);
|
||||
Assert.True(initialPage.HasMore);
|
||||
Assert.False(initialPage.ResetRequired);
|
||||
Assert.All(initialPage.Entries, entry =>
|
||||
{
|
||||
Assert.False(string.IsNullOrWhiteSpace(entry.SummaryText));
|
||||
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");
|
||||
|
||||
Assert.Single(incrementalPage.Entries);
|
||||
Assert.Equal(latestRoll.RollId, incrementalPage.Entries[0].RollId);
|
||||
Assert.Equal(latestRoll.RollId, incrementalPage.Cursor);
|
||||
Assert.False(incrementalPage.HasMore);
|
||||
Assert.False(incrementalPage.ResetRequired);
|
||||
|
||||
var detail = await GetAsync<CampaignRollDetail>(gmClient, $"/api/rolls/{latestRoll.RollId}");
|
||||
Assert.Equal(latestRoll.RollId, detail.RollId);
|
||||
Assert.Equal(latestRoll.Breakdown, detail.Breakdown);
|
||||
Assert.NotEmpty(detail.Dice);
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,61 @@
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
|
||||
namespace RpgRoller.Tests;
|
||||
|
||||
public sealed class FrontendHostTests : ApiTestBase
|
||||
public sealed class FrontendHostTests(WebApplicationFactory<Program> factory) : ApiTestBase(factory)
|
||||
{
|
||||
public FrontendHostTests(WebApplicationFactory<Program> factory)
|
||||
: base(factory)
|
||||
[Fact]
|
||||
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_ServesBlazorFrontendShell()
|
||||
public async Task RootPath_RedirectsToPlay_WhenAuthenticated()
|
||||
{
|
||||
using var factory = CreateFactory(1);
|
||||
using var client = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
|
||||
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);
|
||||
}
|
||||
}
|
||||
23
RpgRoller.Tests/Api/ResponseCompressionApiTests.cs
Normal file
23
RpgRoller.Tests/Api/ResponseCompressionApiTests.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
namespace RpgRoller.Tests;
|
||||
|
||||
public sealed class ResponseCompressionApiTests(WebApplicationFactory<Program> factory) : ApiTestBase(factory)
|
||||
{
|
||||
[Fact]
|
||||
public async Task AuthenticatedJsonResponses_EnableGzipCompression()
|
||||
{
|
||||
using var factory = CreateFactory();
|
||||
using var client = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
|
||||
await RegisterAsync(client, "gm-compress", "Password123", "GM");
|
||||
await LoginAsync(client, "gm-compress", "Password123");
|
||||
_ = await PostAsync<CreateCampaignRequest, CampaignSummary>(client, "/api/campaigns", new("Compressed", "d6"));
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, "/api/campaigns");
|
||||
request.Headers.Add("Accept-Encoding", "gzip");
|
||||
|
||||
var response = await client.SendAsync(request);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
Assert.Contains("gzip", response.Content.Headers.ContentEncoding);
|
||||
}
|
||||
}
|
||||
185
RpgRoller.Tests/Api/RolemasterApiTests.cs
Normal file
185
RpgRoller.Tests/Api/RolemasterApiTests.cs
Normal file
@@ -0,0 +1,185 @@
|
||||
namespace RpgRoller.Tests;
|
||||
|
||||
public sealed class RolemasterApiTests(WebApplicationFactory<Program> factory) : ApiTestBase(factory)
|
||||
{
|
||||
[Fact]
|
||||
public async Task RolemasterRollEndpoints_ExecuteGenericRolemasterExpressions()
|
||||
{
|
||||
using var factory = CreateFactory(8, 6, 74);
|
||||
using var client = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
|
||||
await RegisterAsync(client, "rolemaster-api", "Password123", "Rolemaster Api");
|
||||
await LoginAsync(client, "rolemaster-api", "Password123");
|
||||
|
||||
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(client, "/api/campaigns", new("Rolemaster", "rolemaster"));
|
||||
var character = await PostAsync<CreateCharacterRequest, CharacterSummary>(client, "/api/characters", new("Hero", campaign.Id));
|
||||
var initiative = await PostAsync<CreateSkillRequest, SkillSummary>(client, $"/api/characters/{character.Id}/skills", new("Healing", "2d10+48", 0, false));
|
||||
var perception = await PostAsync<CreateSkillRequest, SkillSummary>(client, $"/api/characters/{character.Id}/skills", new("Perception", "d100-15", 0, false));
|
||||
|
||||
var initiativeRoll = await PostAsync<RollSkillRequest, RollResult>(client, $"/api/skills/{initiative.Id}/roll", new("public"));
|
||||
var percentileRoll = await PostAsync<RollSkillRequest, RollResult>(client, $"/api/skills/{perception.Id}/roll", new("public"));
|
||||
var logPage = await GetAsync<CampaignLogPage>(client, $"/api/campaigns/{campaign.Id}/log/page?limit=10");
|
||||
|
||||
Assert.Equal(62, initiativeRoll.Result);
|
||||
Assert.Equal("8+6+48=62", initiativeRoll.Breakdown);
|
||||
Assert.All(initiativeRoll.Dice, die => Assert.Equal(RollDieKinds.RolemasterStandard, die.Kind));
|
||||
|
||||
Assert.Equal(59, percentileRoll.Result);
|
||||
Assert.Equal("74-15=59", percentileRoll.Breakdown);
|
||||
Assert.Equal(RollDieKinds.RolemasterStandard, Assert.Single(percentileRoll.Dice).Kind);
|
||||
|
||||
Assert.Equal(2, logPage.Entries.Length);
|
||||
Assert.Equal("8 + 6 | rolemaster", logPage.Entries[0].SummaryText);
|
||||
Assert.Null(logPage.Entries[0].EventBadges);
|
||||
Assert.Equal("74 | rolemaster", logPage.Entries[1].SummaryText);
|
||||
Assert.Null(logPage.Entries[1].EventBadges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RolemasterOpenEndedRolls_AppearInLogPageAndDetail()
|
||||
{
|
||||
using var factory = CreateFactory(5, 97, 100, 12);
|
||||
using var client = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
|
||||
await RegisterAsync(client, "rolemaster-open-api", "Password123", "Rolemaster Open Api");
|
||||
await LoginAsync(client, "rolemaster-open-api", "Password123");
|
||||
|
||||
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(client, "/api/campaigns", new("Rolemaster Open", "rolemaster"));
|
||||
var character = await PostAsync<CreateCharacterRequest, CharacterSummary>(client, "/api/characters", new("Hero", campaign.Id));
|
||||
var skill = await PostAsync<CreateSkillRequest, SkillSummary>(client, $"/api/characters/{character.Id}/skills", new("Awareness", "d100!+85", 0, false, null, 5));
|
||||
|
||||
var roll = await PostAsync<RollSkillRequest, RollResult>(client, $"/api/skills/{skill.Id}/roll", new("public"));
|
||||
var logPage = await GetAsync<CampaignLogPage>(client, $"/api/campaigns/{campaign.Id}/log/page?limit=5");
|
||||
var detail = await GetAsync<CampaignRollDetail>(client, $"/api/rolls/{roll.RollId}");
|
||||
|
||||
Assert.Equal(-124, roll.Result);
|
||||
Assert.Equal("(05) -97 -100 -12 +85 = -124", roll.Breakdown);
|
||||
var logEntry = Assert.Single(logPage.Entries);
|
||||
Assert.Equal("(05) -97 -100 -12 | open-ended low", logEntry.SummaryText);
|
||||
var eventBadges = Assert.IsType<string[]>(logEntry.EventBadges);
|
||||
Assert.Collection(eventBadges, badge => Assert.Equal("rf", badge), badge => Assert.Equal("r100", badge));
|
||||
Assert.Equal(roll.Breakdown, detail.Breakdown);
|
||||
Assert.Collection(detail.Dice, die =>
|
||||
{
|
||||
Assert.Equal(RollDieKinds.RolemasterOpenEndedInitial, die.Kind);
|
||||
Assert.Equal(1, die.Sequence);
|
||||
Assert.Null(die.SignedContribution);
|
||||
}, die =>
|
||||
{
|
||||
Assert.Equal(RollDieKinds.RolemasterOpenEndedLowSubtract, die.Kind);
|
||||
Assert.Equal(2, die.Sequence);
|
||||
Assert.Equal(-97, die.SignedContribution);
|
||||
}, die =>
|
||||
{
|
||||
Assert.Equal(RollDieKinds.RolemasterOpenEndedLowSubtract, die.Kind);
|
||||
Assert.Equal(3, die.Sequence);
|
||||
Assert.Equal(-100, die.SignedContribution);
|
||||
}, die =>
|
||||
{
|
||||
Assert.Equal(RollDieKinds.RolemasterOpenEndedLowSubtract, die.Kind);
|
||||
Assert.Equal(4, die.Sequence);
|
||||
Assert.Equal(-12, die.SignedContribution);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RolemasterAutoRetryRolls_AppearInLogPageAndDetail()
|
||||
{
|
||||
using var factory = CreateFactory(66, 42, 90, 32, 65);
|
||||
using var client = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
|
||||
await RegisterAsync(client, "rolemaster-retry-api", "Password123", "Rolemaster Retry Api");
|
||||
await LoginAsync(client, "rolemaster-retry-api", "Password123");
|
||||
|
||||
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(client, "/api/campaigns", new("Rolemaster Retry", "rolemaster"));
|
||||
var character = await PostAsync<CreateCharacterRequest, CharacterSummary>(client, "/api/characters", new("Hero", campaign.Id));
|
||||
var retryFiveSkill = await PostAsync<CreateSkillRequest, SkillSummary>(client, $"/api/characters/{character.Id}/skills", new("Awareness +5", "d100!+10", 0, false, null, 5, true));
|
||||
var retryTenSkill = await PostAsync<CreateSkillRequest, SkillSummary>(client, $"/api/characters/{character.Id}/skills", new("Awareness +10", "d100!+1", 0, false, null, 5, true));
|
||||
var disabledSkill = await PostAsync<CreateSkillRequest, SkillSummary>(client, $"/api/characters/{character.Id}/skills", new("Awareness Off", "d100!+10", 0, false, null, 5));
|
||||
|
||||
var retryFiveRoll = await PostAsync<RollSkillRequest, RollResult>(client, $"/api/skills/{retryFiveSkill.Id}/roll", new("public"));
|
||||
var retryTenRoll = await PostAsync<RollSkillRequest, RollResult>(client, $"/api/skills/{retryTenSkill.Id}/roll", new("public"));
|
||||
var disabledRoll = await PostAsync<RollSkillRequest, RollResult>(client, $"/api/skills/{disabledSkill.Id}/roll", new("public"));
|
||||
var logPage = await GetAsync<CampaignLogPage>(client, $"/api/campaigns/{campaign.Id}/log/page?limit=10");
|
||||
var detail = await GetAsync<CampaignRollDetail>(client, $"/api/rolls/{retryFiveRoll.RollId}");
|
||||
|
||||
Assert.Equal(57, retryFiveRoll.Result);
|
||||
Assert.Equal("66+10=76; retry(+5): 42+10=52; final=57", retryFiveRoll.Breakdown);
|
||||
Assert.Collection(retryFiveRoll.Dice, die =>
|
||||
{
|
||||
Assert.Equal(1, die.Attempt);
|
||||
Assert.Equal(1, die.Sequence);
|
||||
}, die =>
|
||||
{
|
||||
Assert.Equal(2, die.Attempt);
|
||||
Assert.Equal(1, die.Sequence);
|
||||
});
|
||||
|
||||
Assert.Equal(43, retryTenRoll.Result);
|
||||
Assert.Equal("90+1=91; retry(+10): 32+1=33; final=43", retryTenRoll.Breakdown);
|
||||
|
||||
Assert.Equal(75, disabledRoll.Result);
|
||||
Assert.Equal("65+10=75", disabledRoll.Breakdown);
|
||||
Assert.All(disabledRoll.Dice, die => Assert.Null(die.Attempt));
|
||||
|
||||
Assert.Equal(3, logPage.Entries.Length);
|
||||
Assert.Equal("66 | open-ended | retry +5", logPage.Entries[0].SummaryText);
|
||||
Assert.Equal(["r66", "rs5"], Assert.IsType<string[]>(logPage.Entries[0].EventBadges));
|
||||
Assert.Equal("90 | open-ended | retry +10", logPage.Entries[1].SummaryText);
|
||||
Assert.Equal(["rs10"], Assert.IsType<string[]>(logPage.Entries[1].EventBadges));
|
||||
Assert.Equal("65 | open-ended", logPage.Entries[2].SummaryText);
|
||||
Assert.Null(logPage.Entries[2].EventBadges);
|
||||
|
||||
Assert.Equal(retryFiveRoll.Breakdown, detail.Breakdown);
|
||||
Assert.Collection(detail.Dice, die =>
|
||||
{
|
||||
Assert.Equal(1, die.Attempt);
|
||||
Assert.Equal(RollDieKinds.RolemasterOpenEndedInitial, die.Kind);
|
||||
}, die =>
|
||||
{
|
||||
Assert.Equal(2, die.Attempt);
|
||||
Assert.Equal(RollDieKinds.RolemasterOpenEndedInitial, die.Kind);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RolemasterSkillRoll_AcceptsSituationalModifier_AndAppliesItToRetryMath()
|
||||
{
|
||||
using var factory = CreateFactory(8, 42);
|
||||
using var client = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
|
||||
await RegisterAsync(client, "rolemaster-situational-api", "Password123", "Rolemaster Situational Api");
|
||||
await LoginAsync(client, "rolemaster-situational-api", "Password123");
|
||||
|
||||
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(client, "/api/campaigns", new("Rolemaster Situational", "rolemaster"));
|
||||
var character = await PostAsync<CreateCharacterRequest, CharacterSummary>(client, "/api/characters", new("Hero", campaign.Id));
|
||||
var skill = await PostAsync<CreateSkillRequest, SkillSummary>(client, $"/api/characters/{character.Id}/skills", new("Observation", "d100!+50", 0, false, null, 5, true));
|
||||
|
||||
var roll = await PostAsync<RollSkillRequest, RollResult>(client, $"/api/skills/{skill.Id}/roll", new("public", 20));
|
||||
|
||||
Assert.Equal(117, roll.Result);
|
||||
Assert.Equal("8+50+20=78; retry(+5): 42+50+20=112; final=117", roll.Breakdown);
|
||||
Assert.Collection(roll.Dice, die => Assert.Equal(1, die.Attempt), die => Assert.Equal(2, die.Attempt));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SkillRoll_RejectsSituationalModifier_ForNonRolemasterCampaigns()
|
||||
{
|
||||
using var factory = CreateFactory(12);
|
||||
using var client = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
|
||||
await RegisterAsync(client, "non-rolemaster-situational-api", "Password123", "Non Rolemaster Situational Api");
|
||||
await LoginAsync(client, "non-rolemaster-situational-api", "Password123");
|
||||
|
||||
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(client, "/api/campaigns", new("Dnd Situational", "dnd5e"));
|
||||
var character = await PostAsync<CreateCharacterRequest, CharacterSummary>(client, "/api/characters", new("Hero", campaign.Id));
|
||||
var skill = await PostAsync<CreateSkillRequest, SkillSummary>(client, $"/api/characters/{character.Id}/skills", new("Attack", "1d20+5", 0, false));
|
||||
|
||||
var response = await client.PostAsJsonAsync($"/api/skills/{skill.Id}/roll", new RollSkillRequest("public", 20));
|
||||
var error = await response.Content.ReadFromJsonAsync<ApiError>();
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
Assert.NotNull(error);
|
||||
Assert.Equal("invalid_situational_modifier", error.Code);
|
||||
}
|
||||
}
|
||||
@@ -1,72 +1,63 @@
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
|
||||
namespace RpgRoller.Tests;
|
||||
|
||||
public sealed class RollVisibilityApiTests : ApiTestBase
|
||||
public sealed class RollVisibilityApiTests(WebApplicationFactory<Program> factory) : ApiTestBase(factory)
|
||||
{
|
||||
public RollVisibilityApiTests(WebApplicationFactory<Program> factory)
|
||||
: base(factory)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RollVisibilityAndAuthorization_AreEnforced()
|
||||
{
|
||||
using var factory = CreateFactory(4, 3, 5, 2, 6);
|
||||
using var gmClient = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
|
||||
using var playerClient = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
|
||||
using var observerClient = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
|
||||
using var outsiderClient = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
|
||||
using var gmClient = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
using var playerClient = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
using var observerClient = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
using var outsiderClient = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
|
||||
await RegisterAsync(gmClient, "gm", "Password123", "GM");
|
||||
await LoginAsync(gmClient, "gm", "Password123");
|
||||
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(
|
||||
gmClient,
|
||||
"/api/campaigns",
|
||||
new CreateCampaignRequest("Main", "d6"));
|
||||
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns", new("Main", "d6"));
|
||||
|
||||
await RegisterAsync(playerClient, "player", "Password123", "Player");
|
||||
await LoginAsync(playerClient, "player", "Password123");
|
||||
var playerCharacter = await PostAsync<CreateCharacterRequest, CharacterSummary>(
|
||||
playerClient,
|
||||
"/api/characters",
|
||||
new CreateCharacterRequest("Rogue", campaign.Id));
|
||||
var playerCharacter = await PostAsync<CreateCharacterRequest, CharacterSummary>(playerClient, "/api/characters", new("Rogue", campaign.Id));
|
||||
|
||||
var skill = await PostAsync<CreateSkillRequest, SkillSummary>(
|
||||
playerClient,
|
||||
$"/api/characters/{playerCharacter.Id}/skills",
|
||||
new CreateSkillRequest("Stealth", "2D+1", 1, true));
|
||||
var skill = await PostAsync<CreateSkillRequest, SkillSummary>(playerClient, $"/api/characters/{playerCharacter.Id}/skills", new("Stealth", "2D+1", 1, true));
|
||||
Assert.Equal(1, skill.WildDice);
|
||||
Assert.True(skill.AllowFumble);
|
||||
|
||||
await RegisterAsync(observerClient, "observer", "Password123", "Observer");
|
||||
await LoginAsync(observerClient, "observer", "Password123");
|
||||
await PostAsync<CreateCharacterRequest, CharacterSummary>(
|
||||
observerClient,
|
||||
"/api/characters",
|
||||
new CreateCharacterRequest("Watcher", campaign.Id));
|
||||
await PostAsync<CreateCharacterRequest, CharacterSummary>(observerClient, "/api/characters", new("Watcher", campaign.Id));
|
||||
|
||||
var privateRoll = await PostAsync<RollSkillRequest, RollResult>(
|
||||
playerClient,
|
||||
$"/api/skills/{skill.Id}/roll",
|
||||
new RollSkillRequest("private"));
|
||||
var publicRoll = await PostAsync<RollSkillRequest, RollResult>(
|
||||
playerClient,
|
||||
$"/api/skills/{skill.Id}/roll",
|
||||
new RollSkillRequest("public"));
|
||||
var privateRoll = await PostAsync<RollSkillRequest, RollResult>(playerClient, $"/api/skills/{skill.Id}/roll", new("private"));
|
||||
var publicRoll = await PostAsync<RollSkillRequest, RollResult>(playerClient, $"/api/skills/{skill.Id}/roll", new("public"));
|
||||
|
||||
Assert.Equal("private", privateRoll.Visibility);
|
||||
Assert.Equal("public", publicRoll.Visibility);
|
||||
|
||||
var gmLog = await GetAsync<IReadOnlyList<CampaignLogEntry>>(gmClient, $"/api/campaigns/{campaign.Id}/log");
|
||||
Assert.Equal(2, gmLog.Count);
|
||||
Assert.All(gmLog, entry => Assert.NotEmpty(entry.Dice));
|
||||
|
||||
var playerLog = await GetAsync<IReadOnlyList<CampaignLogEntry>>(playerClient, $"/api/campaigns/{campaign.Id}/log");
|
||||
Assert.Equal(2, playerLog.Count);
|
||||
Assert.All(playerLog, entry => Assert.NotEmpty(entry.Dice));
|
||||
|
||||
var observerLog = await GetAsync<IReadOnlyList<CampaignLogEntry>>(observerClient, $"/api/campaigns/{campaign.Id}/log");
|
||||
Assert.Single(observerLog);
|
||||
Assert.Equal("public", observerLog[0].Visibility);
|
||||
Assert.NotEmpty(observerLog[0].Dice);
|
||||
|
||||
var observerLogPage = await GetAsync<CampaignLogPage>(observerClient, $"/api/campaigns/{campaign.Id}/log/page");
|
||||
Assert.Single(observerLogPage.Entries);
|
||||
Assert.Equal(publicRoll.RollId, observerLogPage.Entries[0].RollId);
|
||||
Assert.Equal(publicRoll.RollId, observerLogPage.Cursor);
|
||||
Assert.Equal("Public", observerLogPage.Entries[0].VisibilityLabel);
|
||||
|
||||
var observerPublicDetail = await GetAsync<CampaignRollDetail>(observerClient, $"/api/rolls/{publicRoll.RollId}");
|
||||
Assert.Equal(publicRoll.RollId, observerPublicDetail.RollId);
|
||||
Assert.NotEmpty(observerPublicDetail.Dice);
|
||||
|
||||
var observerPrivateDetail = await observerClient.GetAsync($"/api/rolls/{privateRoll.RollId}");
|
||||
Assert.Equal(HttpStatusCode.BadRequest, observerPrivateDetail.StatusCode);
|
||||
|
||||
await RegisterAsync(outsiderClient, "outsider", "Password123", "Outsider");
|
||||
await LoginAsync(outsiderClient, "outsider", "Password123");
|
||||
@@ -74,15 +65,27 @@ public sealed class RollVisibilityApiTests : ApiTestBase
|
||||
var forbiddenCampaign = await outsiderClient.GetAsync($"/api/campaigns/{campaign.Id}");
|
||||
Assert.Equal(HttpStatusCode.BadRequest, forbiddenCampaign.StatusCode);
|
||||
|
||||
var invalidVisibility = await playerClient.PostAsJsonAsync(
|
||||
$"/api/skills/{skill.Id}/roll",
|
||||
new RollSkillRequest("hidden"));
|
||||
var outsiderPublicDetail = await outsiderClient.GetAsync($"/api/rolls/{publicRoll.RollId}");
|
||||
Assert.Equal(HttpStatusCode.BadRequest, outsiderPublicDetail.StatusCode);
|
||||
|
||||
var invalidVisibility = await playerClient.PostAsJsonAsync($"/api/skills/{skill.Id}/roll", new RollSkillRequest("hidden"));
|
||||
Assert.Equal(HttpStatusCode.BadRequest, invalidVisibility.StatusCode);
|
||||
|
||||
using var anonymousClient = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
|
||||
var unauthorizedCampaignCreate = await anonymousClient.PostAsJsonAsync(
|
||||
"/api/campaigns",
|
||||
new CreateCampaignRequest("Nope", "d6"));
|
||||
var customRoll = await PostAsync<CustomRollRequest, RollResult>(playerClient, $"/api/characters/{playerCharacter.Id}/custom-rolls", new("1D+2", "public"));
|
||||
Assert.Equal(Guid.Empty, customRoll.SkillId);
|
||||
|
||||
var customRollLogPage = await GetAsync<CampaignLogPage>(observerClient, $"/api/campaigns/{campaign.Id}/log/page");
|
||||
Assert.Equal(2, customRollLogPage.Entries.Length);
|
||||
Assert.Equal("Custom roll", customRollLogPage.Entries[1].SkillName);
|
||||
|
||||
var invalidCustomRollResponse = await playerClient.PostAsJsonAsync($"/api/characters/{playerCharacter.Id}/custom-rolls", new CustomRollRequest("bad", "public"));
|
||||
Assert.Equal(HttpStatusCode.BadRequest, invalidCustomRollResponse.StatusCode);
|
||||
var invalidCustomRoll = await invalidCustomRollResponse.Content.ReadFromJsonAsync<ApiError>();
|
||||
Assert.NotNull(invalidCustomRoll);
|
||||
Assert.Equal("invalid_expression", invalidCustomRoll.Code);
|
||||
|
||||
using var anonymousClient = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
var unauthorizedCampaignCreate = await anonymousClient.PostAsJsonAsync("/api/campaigns", new CreateCampaignRequest("Nope", "d6"));
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, unauthorizedCampaignCreate.StatusCode);
|
||||
|
||||
var invalidSessionRequest = new HttpRequestMessage(HttpMethod.Get, "/api/campaigns");
|
||||
|
||||
@@ -1,30 +1,22 @@
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
|
||||
namespace RpgRoller.Tests;
|
||||
|
||||
public sealed class SystemApiTests : ApiTestBase
|
||||
public sealed class SystemApiTests(WebApplicationFactory<Program> factory) : ApiTestBase(factory)
|
||||
{
|
||||
public SystemApiTests(WebApplicationFactory<Program> factory)
|
||||
: base(factory)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RulesetAndSseEndpoints_ReturnExpectedResponses()
|
||||
{
|
||||
using var factory = CreateFactory(2, 2, 2);
|
||||
using var client = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
|
||||
using var client = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
|
||||
var rulesets = await GetAsync<IReadOnlyList<RulesetDefinition>>(client, "/api/rulesets");
|
||||
Assert.Equal(2, rulesets.Count);
|
||||
Assert.Equal(3, rulesets.Count);
|
||||
var rolemaster = Assert.Single(rulesets, ruleset => ruleset.Id == "rolemaster");
|
||||
Assert.Equal("Rolemaster", rolemaster.Name);
|
||||
|
||||
await RegisterAsync(client, "sse", "Password123", "Sse User");
|
||||
await LoginAsync(client, "sse", "Password123");
|
||||
|
||||
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(
|
||||
client,
|
||||
"/api/campaigns",
|
||||
new CreateCampaignRequest("SSE", "d6"));
|
||||
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(client, "/api/campaigns", new("SSE", "d6"));
|
||||
|
||||
var sseResponse = await client.GetAsync($"/api/events/state?campaignId={campaign.Id}", HttpCompletionOption.ResponseHeadersRead);
|
||||
Assert.Equal(HttpStatusCode.OK, sseResponse.StatusCode);
|
||||
|
||||
@@ -11,13 +11,11 @@ public sealed class BackendCoverageTests
|
||||
var extensionsType = typeof(Program).Assembly.GetType("RpgRoller.Api.SessionTokenHttpContextExtensions");
|
||||
Assert.NotNull(extensionsType);
|
||||
|
||||
var method = extensionsType!.GetMethod(
|
||||
"GetRequiredSessionToken",
|
||||
BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic);
|
||||
var method = extensionsType.GetMethod("GetRequiredSessionToken", BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic);
|
||||
Assert.NotNull(method);
|
||||
|
||||
var context = new DefaultHttpContext();
|
||||
var exception = Assert.Throws<TargetInvocationException>(() => method!.Invoke(null, [context]));
|
||||
var exception = Assert.Throws<TargetInvocationException>(() => method.Invoke(null, [context]));
|
||||
Assert.IsType<InvalidOperationException>(exception.InnerException);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
namespace RpgRoller.Tests;
|
||||
|
||||
// Service-level tests were split by concern under RpgRoller.Tests/Services.
|
||||
@@ -1,5 +1,13 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using RpgRoller.Data;
|
||||
using RpgRoller.Hosting;
|
||||
|
||||
namespace RpgRoller.Tests;
|
||||
@@ -10,21 +18,477 @@ public sealed class HostingCoverageTests
|
||||
public void AddRpgRollerCore_WithInMemoryConnectionString_RegistersCoreServices()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["ConnectionStrings:RpgRoller"] = "Data Source=:memory:"
|
||||
})
|
||||
.Build();
|
||||
var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?> { ["ConnectionStrings:RpgRoller"] = "Data Source=:memory:" }).Build();
|
||||
|
||||
var environment = new TestWebHostEnvironment
|
||||
{
|
||||
ContentRootPath = Path.GetTempPath()
|
||||
};
|
||||
var environment = new TestWebHostEnvironment { ContentRootPath = Path.GetTempPath() };
|
||||
|
||||
services.AddRpgRollerCore(configuration, environment);
|
||||
|
||||
Assert.Contains(services, d => d.ServiceType == typeof(RpgRoller.Services.IGameService));
|
||||
Assert.Contains(services, d => d.ServiceType == typeof(RpgRoller.Services.IDiceRoller));
|
||||
Assert.Contains(services, d => d.ServiceType == typeof(IGameService));
|
||||
Assert.Contains(services, d => d.ServiceType == typeof(IDiceRoller));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddRpgRollerCore_WithFileConnectionString_RegistersResolvedSqliteDatabaseFile()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
var contentRoot = Path.Combine(Path.GetTempPath(), $"rpgroller-hosting-{Guid.NewGuid():N}");
|
||||
var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?> { ["ConnectionStrings:RpgRoller"] = "Data Source=App_Data/rpgroller.db" }).Build();
|
||||
var environment = new TestWebHostEnvironment { ContentRootPath = contentRoot };
|
||||
|
||||
services.AddRpgRollerCore(configuration, environment);
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var databaseFile = provider.GetRequiredService<SqliteDatabaseFile>();
|
||||
|
||||
Assert.Equal(Path.Combine(contentRoot, "App_Data", "rpgroller.db"), databaseFile.Path);
|
||||
Assert.True(Directory.Exists(Path.Combine(contentRoot, "App_Data")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SqliteSchemaUpgrader_MigratesLegacySchema()
|
||||
{
|
||||
var dbPath = Path.Combine(Path.GetTempPath(), $"rpgroller-legacy-upgrade-{Guid.NewGuid():N}.db");
|
||||
var connectionString = $"Data Source={dbPath}";
|
||||
|
||||
using (var connection = new SqliteConnection(connectionString))
|
||||
{
|
||||
connection.Open();
|
||||
using var command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
CREATE TABLE "Users" (
|
||||
"Id" TEXT NOT NULL CONSTRAINT "PK_Users" PRIMARY KEY,
|
||||
"Username" TEXT NOT NULL,
|
||||
"UsernameNormalized" TEXT NOT NULL,
|
||||
"PasswordHash" TEXT NOT NULL,
|
||||
"DisplayName" TEXT NOT NULL,
|
||||
"ActiveCharacterId" TEXT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "Sessions" (
|
||||
"Token" TEXT NOT NULL CONSTRAINT "PK_Sessions" PRIMARY KEY,
|
||||
"UserId" TEXT NOT NULL,
|
||||
"CreatedAtUtc" TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "Campaigns" (
|
||||
"Id" TEXT NOT NULL CONSTRAINT "PK_Campaigns" PRIMARY KEY,
|
||||
"GmUserId" TEXT NOT NULL,
|
||||
"Name" TEXT NOT NULL,
|
||||
"Ruleset" TEXT NOT NULL,
|
||||
"Version" INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "Characters" (
|
||||
"Id" TEXT NOT NULL CONSTRAINT "PK_Characters" PRIMARY KEY,
|
||||
"OwnerUserId" TEXT NOT NULL,
|
||||
"CampaignId" TEXT NOT NULL,
|
||||
"Name" TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "Skills" (
|
||||
"Id" TEXT NOT NULL CONSTRAINT "PK_Skills" PRIMARY KEY,
|
||||
"CharacterId" TEXT NOT NULL,
|
||||
"Name" TEXT NOT NULL,
|
||||
"DiceRollDefinition" TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "RollLogEntries" (
|
||||
"Id" TEXT NOT NULL CONSTRAINT "PK_RollLogEntries" PRIMARY KEY,
|
||||
"CampaignId" TEXT NOT NULL,
|
||||
"CharacterId" TEXT NOT NULL,
|
||||
"SkillId" TEXT NOT NULL,
|
||||
"RollerUserId" TEXT NOT NULL,
|
||||
"Visibility" TEXT NOT NULL,
|
||||
"Result" INTEGER NOT NULL,
|
||||
"Breakdown" TEXT NOT NULL,
|
||||
"TimestampUtc" TEXT NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO "Users" ("Id", "Username", "UsernameNormalized", "PasswordHash", "DisplayName", "ActiveCharacterId")
|
||||
VALUES ('00000000-0000-0000-0000-000000000001', 'legacy-admin', 'LEGACY-ADMIN', 'hash', 'Legacy Admin', NULL);
|
||||
""";
|
||||
_ = command.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
var options = new DbContextOptionsBuilder<RpgRollerDbContext>().UseSqlite(connectionString).Options;
|
||||
|
||||
using (var db = new RpgRollerDbContext(options))
|
||||
{
|
||||
SqliteSchemaUpgrader.ApplyPendingChanges(db);
|
||||
}
|
||||
|
||||
using var verifyConnection = new SqliteConnection(connectionString);
|
||||
verifyConnection.Open();
|
||||
|
||||
using var tableInfoCommand = verifyConnection.CreateCommand();
|
||||
tableInfoCommand.CommandText = "PRAGMA table_info('Skills');";
|
||||
using var tableInfoReader = tableInfoCommand.ExecuteReader();
|
||||
var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
while (tableInfoReader.Read())
|
||||
columns.Add(tableInfoReader.GetString(1));
|
||||
|
||||
Assert.Contains("WildDice", columns);
|
||||
Assert.Contains("AllowFumble", columns);
|
||||
Assert.Contains("FumbleRange", columns);
|
||||
Assert.Contains("RolemasterAutoRetry", columns);
|
||||
|
||||
using var skillGroupsTableInfoCommand = verifyConnection.CreateCommand();
|
||||
skillGroupsTableInfoCommand.CommandText = "PRAGMA table_info('SkillGroups');";
|
||||
using var skillGroupsTableInfoReader = skillGroupsTableInfoCommand.ExecuteReader();
|
||||
var skillGroupColumns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
while (skillGroupsTableInfoReader.Read())
|
||||
skillGroupColumns.Add(skillGroupsTableInfoReader.GetString(1));
|
||||
|
||||
Assert.Contains("FumbleRange", skillGroupColumns);
|
||||
|
||||
using var rollTableInfoCommand = verifyConnection.CreateCommand();
|
||||
rollTableInfoCommand.CommandText = "PRAGMA table_info('RollLogEntries');";
|
||||
using var rollTableInfoReader = rollTableInfoCommand.ExecuteReader();
|
||||
var rollColumns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
while (rollTableInfoReader.Read())
|
||||
rollColumns.Add(rollTableInfoReader.GetString(1));
|
||||
|
||||
Assert.Contains("Dice", rollColumns);
|
||||
|
||||
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("Roles", usersColumns);
|
||||
|
||||
using var usersRoleCommand = verifyConnection.CreateCommand();
|
||||
usersRoleCommand.CommandText = "SELECT Roles FROM Users WHERE UsernameNormalized = 'LEGACY-ADMIN';";
|
||||
var roles = Convert.ToString(usersRoleCommand.ExecuteScalar());
|
||||
Assert.Equal("admin", roles);
|
||||
|
||||
using var charactersTableInfoCommand = verifyConnection.CreateCommand();
|
||||
charactersTableInfoCommand.CommandText = "PRAGMA table_info('Characters');";
|
||||
using var charactersTableInfoReader = charactersTableInfoCommand.ExecuteReader();
|
||||
var campaignIdNotNull = true;
|
||||
while (charactersTableInfoReader.Read())
|
||||
{
|
||||
if (!string.Equals(charactersTableInfoReader.GetString(1), "CampaignId", StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
campaignIdNotNull = charactersTableInfoReader.GetInt32(3) == 1;
|
||||
break;
|
||||
}
|
||||
|
||||
Assert.False(campaignIdNotNull);
|
||||
|
||||
using var historyCommand = verifyConnection.CreateCommand();
|
||||
historyCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260226084000_InitialSchema';";
|
||||
var historyCount = Convert.ToInt32(historyCommand.ExecuteScalar());
|
||||
Assert.Equal(1, historyCount);
|
||||
|
||||
using var modelSyncHistoryCommand = verifyConnection.CreateCommand();
|
||||
modelSyncHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260226090000_ModelSync';";
|
||||
var modelSyncHistoryCount = Convert.ToInt32(modelSyncHistoryCommand.ExecuteScalar());
|
||||
Assert.Equal(1, modelSyncHistoryCount);
|
||||
|
||||
using var rollDiceHistoryCommand = verifyConnection.CreateCommand();
|
||||
rollDiceHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260226100000_AddRollLogDice';";
|
||||
var rollDiceHistoryCount = Convert.ToInt32(rollDiceHistoryCommand.ExecuteScalar());
|
||||
Assert.Equal(1, rollDiceHistoryCount);
|
||||
|
||||
using var rolesHistoryCommand = verifyConnection.CreateCommand();
|
||||
rolesHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260226160859_AddAuthorizationRolesAndCampaignDeletion';";
|
||||
var rolesHistoryCount = Convert.ToInt32(rolesHistoryCommand.ExecuteScalar());
|
||||
Assert.Equal(1, rolesHistoryCount);
|
||||
|
||||
using var authorizationRolesHistoryCommand = verifyConnection.CreateCommand();
|
||||
authorizationRolesHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260226170000_AddAuthorizationRoles';";
|
||||
var authorizationRolesHistoryCount = Convert.ToInt32(authorizationRolesHistoryCommand.ExecuteScalar());
|
||||
Assert.Equal(1, authorizationRolesHistoryCount);
|
||||
|
||||
using var rolemasterHistoryCommand = verifyConnection.CreateCommand();
|
||||
rolemasterHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260402222501_AddRolemasterFumbleRange';";
|
||||
var rolemasterHistoryCount = Convert.ToInt32(rolemasterHistoryCommand.ExecuteScalar());
|
||||
Assert.Equal(1, rolemasterHistoryCount);
|
||||
|
||||
using var retryHistoryCommand = verifyConnection.CreateCommand();
|
||||
retryHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260414204309_AddRolemasterAutoRetry';";
|
||||
var retryHistoryCount = Convert.ToInt32(retryHistoryCommand.ExecuteScalar());
|
||||
Assert.Equal(1, retryHistoryCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuthorizationMigrations_SplitCharactersRebuildFromRolesColumnAddition()
|
||||
{
|
||||
var dbPath = Path.Combine(Path.GetTempPath(), $"rpgroller-migration-script-{Guid.NewGuid():N}.db");
|
||||
var options = new DbContextOptionsBuilder<RpgRollerDbContext>().UseSqlite($"Data Source={dbPath}").Options;
|
||||
|
||||
using var db = new RpgRollerDbContext(options);
|
||||
var migrator = db.GetService<IMigrator>();
|
||||
var charactersScript = migrator.GenerateScript("20260226131003_AddSkillGroupPrototypes", "20260226160859_AddAuthorizationRolesAndCampaignDeletion");
|
||||
var rolesScript = migrator.GenerateScript("20260226160859_AddAuthorizationRolesAndCampaignDeletion", "20260226170000_AddAuthorizationRoles");
|
||||
|
||||
Assert.Contains("""CREATE TABLE "ef_temp_Characters" (""", charactersScript);
|
||||
Assert.DoesNotContain("""ALTER TABLE "Users" ADD "Roles" TEXT NOT NULL DEFAULT 'admin';""", charactersScript);
|
||||
Assert.Contains("""ALTER TABLE "Users" ADD "Roles" TEXT NOT NULL DEFAULT 'admin';""", rolesScript);
|
||||
Assert.DoesNotContain("""CREATE TABLE "ef_temp_Characters" (""", rolesScript);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SqliteSchemaUpgrader_BackfillsSplitAuthorizationRolesMigrationHistory()
|
||||
{
|
||||
var dbPath = Path.Combine(Path.GetTempPath(), $"rpgroller-split-history-{Guid.NewGuid():N}.db");
|
||||
|
||||
using (var connection = new SqliteConnection($"Data Source={dbPath}"))
|
||||
{
|
||||
connection.Open();
|
||||
using var command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
CREATE TABLE "__EFMigrationsHistory" (
|
||||
"MigrationId" TEXT NOT NULL CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY,
|
||||
"ProductVersion" TEXT NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
|
||||
VALUES
|
||||
('20260226084000_InitialSchema', '10.0.2'),
|
||||
('20260226090000_ModelSync', '10.0.2'),
|
||||
('20260226100000_AddRollLogDice', '10.0.2'),
|
||||
('20260226124941_AddSkillGroupsAndCharacterOwnerTransfer', '10.0.2'),
|
||||
('20260226131003_AddSkillGroupPrototypes', '10.0.2'),
|
||||
('20260226160859_AddAuthorizationRolesAndCampaignDeletion', '10.0.2');
|
||||
|
||||
CREATE TABLE "Users" (
|
||||
"Id" TEXT NOT NULL CONSTRAINT "PK_Users" PRIMARY KEY,
|
||||
"Username" TEXT NOT NULL,
|
||||
"UsernameNormalized" TEXT NOT NULL,
|
||||
"PasswordHash" TEXT NOT NULL,
|
||||
"DisplayName" TEXT NOT NULL,
|
||||
"ActiveCharacterId" TEXT NULL,
|
||||
"Roles" TEXT NOT NULL DEFAULT 'admin'
|
||||
);
|
||||
|
||||
CREATE TABLE "Sessions" (
|
||||
"Token" TEXT NOT NULL CONSTRAINT "PK_Sessions" PRIMARY KEY,
|
||||
"UserId" TEXT NOT NULL,
|
||||
"CreatedAtUtc" TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "Campaigns" (
|
||||
"Id" TEXT NOT NULL CONSTRAINT "PK_Campaigns" PRIMARY KEY,
|
||||
"GmUserId" TEXT NOT NULL,
|
||||
"Name" TEXT NOT NULL,
|
||||
"Ruleset" TEXT NOT NULL,
|
||||
"Version" INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "Characters" (
|
||||
"Id" TEXT NOT NULL CONSTRAINT "PK_Characters" PRIMARY KEY,
|
||||
"OwnerUserId" TEXT NOT NULL,
|
||||
"CampaignId" TEXT NULL,
|
||||
"Name" TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX "IX_Characters_OwnerUserId" ON "Characters" ("OwnerUserId");
|
||||
CREATE INDEX "IX_Characters_CampaignId" ON "Characters" ("CampaignId");
|
||||
|
||||
CREATE TABLE "SkillGroups" (
|
||||
"Id" TEXT NOT NULL CONSTRAINT "PK_SkillGroups" PRIMARY KEY,
|
||||
"CharacterId" TEXT NOT NULL,
|
||||
"Name" TEXT NOT NULL,
|
||||
"AllowFumble" INTEGER NOT NULL,
|
||||
"DiceRollDefinition" TEXT NOT NULL,
|
||||
"WildDice" INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX "IX_SkillGroups_CharacterId" ON "SkillGroups" ("CharacterId");
|
||||
|
||||
CREATE TABLE "Skills" (
|
||||
"Id" TEXT NOT NULL CONSTRAINT "PK_Skills" PRIMARY KEY,
|
||||
"CharacterId" TEXT NOT NULL,
|
||||
"Name" TEXT NOT NULL,
|
||||
"DiceRollDefinition" TEXT NOT NULL,
|
||||
"WildDice" INTEGER NOT NULL,
|
||||
"AllowFumble" INTEGER NOT NULL,
|
||||
"SkillGroupId" TEXT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX "IX_Skills_CharacterId" ON "Skills" ("CharacterId");
|
||||
CREATE INDEX "IX_Skills_SkillGroupId" ON "Skills" ("SkillGroupId");
|
||||
|
||||
CREATE TABLE "RollLogEntries" (
|
||||
"Id" TEXT NOT NULL CONSTRAINT "PK_RollLogEntries" PRIMARY KEY,
|
||||
"CampaignId" TEXT NOT NULL,
|
||||
"CharacterId" TEXT NOT NULL,
|
||||
"SkillId" TEXT NOT NULL,
|
||||
"RollerUserId" TEXT NOT NULL,
|
||||
"Visibility" TEXT NOT NULL,
|
||||
"Result" INTEGER NOT NULL,
|
||||
"Breakdown" TEXT NOT NULL,
|
||||
"TimestampUtc" TEXT NOT NULL,
|
||||
"Dice" TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX "IX_RollLogEntries_CampaignId" ON "RollLogEntries" ("CampaignId");
|
||||
CREATE INDEX "IX_RollLogEntries_CharacterId" ON "RollLogEntries" ("CharacterId");
|
||||
CREATE INDEX "IX_RollLogEntries_RollerUserId" ON "RollLogEntries" ("RollerUserId");
|
||||
CREATE INDEX "IX_RollLogEntries_SkillId" ON "RollLogEntries" ("SkillId");
|
||||
""";
|
||||
_ = command.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
var options = new DbContextOptionsBuilder<RpgRollerDbContext>().UseSqlite($"Data Source={dbPath}").Options;
|
||||
using (var db = new RpgRollerDbContext(options))
|
||||
{
|
||||
SqliteSchemaUpgrader.ApplyPendingChanges(db);
|
||||
}
|
||||
|
||||
using var verifyConnection = new SqliteConnection($"Data Source={dbPath}");
|
||||
verifyConnection.Open();
|
||||
|
||||
using var authorizationRolesHistoryCommand = verifyConnection.CreateCommand();
|
||||
authorizationRolesHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260226170000_AddAuthorizationRoles';";
|
||||
var authorizationRolesHistoryCount = Convert.ToInt32(authorizationRolesHistoryCommand.ExecuteScalar());
|
||||
Assert.Equal(1, authorizationRolesHistoryCount);
|
||||
|
||||
using var rolemasterHistoryCommand = verifyConnection.CreateCommand();
|
||||
rolemasterHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260402222501_AddRolemasterFumbleRange';";
|
||||
var rolemasterHistoryCount = Convert.ToInt32(rolemasterHistoryCommand.ExecuteScalar());
|
||||
Assert.Equal(1, rolemasterHistoryCount);
|
||||
|
||||
using var retryHistoryCommand = verifyConnection.CreateCommand();
|
||||
retryHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260414204309_AddRolemasterAutoRetry';";
|
||||
var retryHistoryCount = Convert.ToInt32(retryHistoryCommand.ExecuteScalar());
|
||||
Assert.Equal(1, retryHistoryCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InitializeRpgRollerState_MigratesCopiedDevelopmentDatabaseAndPreservesD6Rolling()
|
||||
{
|
||||
var sourceDbPath = Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "RpgRoller", "App_Data", "rpgroller.development.db");
|
||||
var copiedDbPath = Path.Combine(Path.GetTempPath(), $"rpgroller-dev-copy-{Guid.NewGuid():N}.db");
|
||||
File.Copy(Path.GetFullPath(sourceDbPath), copiedDbPath, true);
|
||||
|
||||
Guid skillId;
|
||||
Guid ownerUserId;
|
||||
Guid characterId;
|
||||
var campaignCountBefore = 0;
|
||||
var skillCountBefore = 0;
|
||||
using (var connection = new SqliteConnection($"Data Source={copiedDbPath}"))
|
||||
{
|
||||
connection.Open();
|
||||
|
||||
using var countsCommand = connection.CreateCommand();
|
||||
countsCommand.CommandText = """
|
||||
SELECT (SELECT COUNT(*) FROM Campaigns),
|
||||
(SELECT COUNT(*) FROM Skills);
|
||||
""";
|
||||
using var countsReader = countsCommand.ExecuteReader();
|
||||
Assert.True(countsReader.Read());
|
||||
campaignCountBefore = countsReader.GetInt32(0);
|
||||
skillCountBefore = countsReader.GetInt32(1);
|
||||
|
||||
using var existingSkillCommand = connection.CreateCommand();
|
||||
existingSkillCommand.CommandText = """
|
||||
SELECT s.Id, c.OwnerUserId, c.Id
|
||||
FROM Skills s
|
||||
INNER JOIN Characters c ON c.Id = s.CharacterId
|
||||
INNER JOIN Campaigns cp ON cp.Id = c.CampaignId
|
||||
WHERE cp.Ruleset = 'D6'
|
||||
ORDER BY s.Name
|
||||
LIMIT 1;
|
||||
""";
|
||||
using var existingSkillReader = existingSkillCommand.ExecuteReader();
|
||||
Assert.True(existingSkillReader.Read());
|
||||
skillId = Guid.Parse(existingSkillReader.GetString(0));
|
||||
ownerUserId = Guid.Parse(existingSkillReader.GetString(1));
|
||||
characterId = Guid.Parse(existingSkillReader.GetString(2));
|
||||
|
||||
using var sessionCommand = connection.CreateCommand();
|
||||
sessionCommand.CommandText = """
|
||||
INSERT INTO Sessions ("Token", "UserId", "CreatedAtUtc")
|
||||
VALUES ($token, $userId, $createdAtUtc);
|
||||
""";
|
||||
var tokenParameter = sessionCommand.CreateParameter();
|
||||
tokenParameter.ParameterName = "$token";
|
||||
tokenParameter.Value = "migration-test-session";
|
||||
sessionCommand.Parameters.Add(tokenParameter);
|
||||
|
||||
var userParameter = sessionCommand.CreateParameter();
|
||||
userParameter.ParameterName = "$userId";
|
||||
userParameter.Value = ownerUserId.ToString();
|
||||
sessionCommand.Parameters.Add(userParameter);
|
||||
|
||||
var createdAtParameter = sessionCommand.CreateParameter();
|
||||
createdAtParameter.ParameterName = "$createdAtUtc";
|
||||
createdAtParameter.Value = DateTimeOffset.UtcNow.ToString("O");
|
||||
sessionCommand.Parameters.Add(createdAtParameter);
|
||||
|
||||
_ = sessionCommand.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
var builder = WebApplication.CreateBuilder(new WebApplicationOptions
|
||||
{
|
||||
ContentRootPath = Path.GetTempPath(),
|
||||
EnvironmentName = Environments.Development
|
||||
});
|
||||
builder.Logging.AddFilter("Microsoft.AspNetCore", LogLevel.Warning);
|
||||
builder.Logging.AddFilter("Microsoft.EntityFrameworkCore", LogLevel.Warning);
|
||||
builder.Logging.AddFilter("Microsoft.Hosting", LogLevel.Warning);
|
||||
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?> { ["ConnectionStrings:RpgRoller"] = $"Data Source={copiedDbPath}" });
|
||||
builder.Services.AddRpgRollerCore(builder.Configuration, builder.Environment);
|
||||
|
||||
using var app = builder.Build();
|
||||
app.InitializeRpgRollerState();
|
||||
|
||||
using var scope = app.Services.CreateScope();
|
||||
var game = scope.ServiceProvider.GetRequiredService<IGameService>();
|
||||
var rollResult = game.RollSkill("migration-test-session", skillId, "public");
|
||||
Assert.True(rollResult.Succeeded);
|
||||
Assert.NotEmpty(ServiceTestSupport.GetValue(rollResult).Dice);
|
||||
|
||||
var migratedSheet = ServiceTestSupport.GetValue(game.GetCharacterSheet("migration-test-session", characterId));
|
||||
Assert.Contains(migratedSheet.Skills, skill => skill.Id == skillId);
|
||||
|
||||
using var verifyConnection = new SqliteConnection($"Data Source={copiedDbPath}");
|
||||
verifyConnection.Open();
|
||||
|
||||
using var countsAfterCommand = verifyConnection.CreateCommand();
|
||||
countsAfterCommand.CommandText = """
|
||||
SELECT (SELECT COUNT(*) FROM Campaigns),
|
||||
(SELECT COUNT(*) FROM Skills);
|
||||
""";
|
||||
using var countsAfterReader = countsAfterCommand.ExecuteReader();
|
||||
Assert.True(countsAfterReader.Read());
|
||||
Assert.Equal(campaignCountBefore, countsAfterReader.GetInt32(0));
|
||||
Assert.Equal(skillCountBefore, countsAfterReader.GetInt32(1));
|
||||
|
||||
using var skillsTableInfoCommand = verifyConnection.CreateCommand();
|
||||
skillsTableInfoCommand.CommandText = "PRAGMA table_info('Skills');";
|
||||
using var skillsTableInfoReader = skillsTableInfoCommand.ExecuteReader();
|
||||
var skillColumns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
while (skillsTableInfoReader.Read())
|
||||
skillColumns.Add(skillsTableInfoReader.GetString(1));
|
||||
|
||||
Assert.Contains("FumbleRange", skillColumns);
|
||||
Assert.Contains("RolemasterAutoRetry", skillColumns);
|
||||
|
||||
using var skillGroupsTableInfoCommand = verifyConnection.CreateCommand();
|
||||
skillGroupsTableInfoCommand.CommandText = "PRAGMA table_info('SkillGroups');";
|
||||
using var skillGroupsTableInfoReader = skillGroupsTableInfoCommand.ExecuteReader();
|
||||
var skillGroupColumns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
while (skillGroupsTableInfoReader.Read())
|
||||
skillGroupColumns.Add(skillGroupsTableInfoReader.GetString(1));
|
||||
|
||||
Assert.Contains("FumbleRange", skillGroupColumns);
|
||||
|
||||
using var authorizationRolesHistoryCommand = verifyConnection.CreateCommand();
|
||||
authorizationRolesHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260226170000_AddAuthorizationRoles';";
|
||||
var authorizationRolesHistoryCount = Convert.ToInt32(authorizationRolesHistoryCommand.ExecuteScalar());
|
||||
Assert.Equal(1, authorizationRolesHistoryCount);
|
||||
|
||||
using var retryHistoryCommand = verifyConnection.CreateCommand();
|
||||
retryHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260414204309_AddRolemasterAutoRetry';";
|
||||
var retryHistoryCount = Convert.ToInt32(retryHistoryCommand.ExecuteScalar());
|
||||
Assert.Equal(1, retryHistoryCount);
|
||||
}
|
||||
}
|
||||
196
RpgRoller.Tests/PayloadBudgetTests.cs
Normal file
196
RpgRoller.Tests/PayloadBudgetTests.cs
Normal file
@@ -0,0 +1,196 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace RpgRoller.Tests;
|
||||
|
||||
public sealed class PayloadBudgetTests
|
||||
{
|
||||
[Fact]
|
||||
public void CharacterSheetPayload_StaysWithinBudget()
|
||||
{
|
||||
using var harness = ServiceTestSupport.CreateHarness();
|
||||
var service = harness.Service;
|
||||
|
||||
service.Register("gm-sheet", "Password123", "GM");
|
||||
service.Register("owner-sheet", "Password123", "Owner");
|
||||
|
||||
var gmSession = ServiceTestSupport.GetValue(service.Login("gm-sheet", "Password123")).SessionToken;
|
||||
var ownerSession = ServiceTestSupport.GetValue(service.Login("owner-sheet", "Password123")).SessionToken;
|
||||
|
||||
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Payload Sheet", "d6"));
|
||||
var character = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Payload Hero", campaign.Id));
|
||||
|
||||
var groupIds = new List<Guid>
|
||||
{
|
||||
ServiceTestSupport.GetValue(service.CreateSkillGroup(ownerSession, character.Id, "Combat", "2D+1", 1, true)).Id,
|
||||
ServiceTestSupport.GetValue(service.CreateSkillGroup(ownerSession, character.Id, "Social", "2D+1", 1, true)).Id,
|
||||
ServiceTestSupport.GetValue(service.CreateSkillGroup(ownerSession, character.Id, "Knowledge", "2D+1", 1, true)).Id,
|
||||
ServiceTestSupport.GetValue(service.CreateSkillGroup(ownerSession, character.Id, "Survival", "2D+1", 1, true)).Id
|
||||
};
|
||||
|
||||
for (var i = 0; i < 18; i++)
|
||||
{
|
||||
Guid? skillGroupId = i < 16 ? groupIds[i % groupIds.Count] : null;
|
||||
_ = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, character.Id, $"Skill {i:D2}", "2D+1", 1, true, skillGroupId));
|
||||
}
|
||||
|
||||
var sheet = ServiceTestSupport.GetValue(service.GetCharacterSheet(ownerSession, character.Id));
|
||||
AssertPayloadWithinBudget(sheet, 12 * 1024, "initial character sheet");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CampaignLogInitialPagePayload_StaysWithinBudget()
|
||||
{
|
||||
using var harness = ServiceTestSupport.CreateHarness(CreateScriptedRolls(220));
|
||||
var service = harness.Service;
|
||||
|
||||
service.Register("gm-log-budget", "Password123", "GM");
|
||||
service.Register("owner-log-budget", "Password123", "Owner");
|
||||
|
||||
var gmSession = ServiceTestSupport.GetValue(service.Login("gm-log-budget", "Password123")).SessionToken;
|
||||
var ownerSession = ServiceTestSupport.GetValue(service.Login("owner-log-budget", "Password123")).SessionToken;
|
||||
|
||||
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Payload Log", "d6"));
|
||||
var character = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Log Hero", campaign.Id));
|
||||
var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, character.Id, "Stealth", "2D+1", 1, true));
|
||||
|
||||
for (var i = 0; i < 25; i++)
|
||||
_ = ServiceTestSupport.GetValue(service.RollSkill(ownerSession, skill.Id, "public"));
|
||||
|
||||
var page = ServiceTestSupport.GetValue(service.GetCampaignLogPage(gmSession, campaign.Id, limit: 25));
|
||||
AssertPayloadWithinBudget(page, 8 * 1024, "initial log page");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CampaignLogIncrementalPayload_StaysWithinBudget()
|
||||
{
|
||||
using var harness = ServiceTestSupport.CreateHarness(CreateScriptedRolls(240));
|
||||
var service = harness.Service;
|
||||
|
||||
service.Register("gm-log-delta", "Password123", "GM");
|
||||
service.Register("owner-log-delta", "Password123", "Owner");
|
||||
|
||||
var gmSession = ServiceTestSupport.GetValue(service.Login("gm-log-delta", "Password123")).SessionToken;
|
||||
var ownerSession = ServiceTestSupport.GetValue(service.Login("owner-log-delta", "Password123")).SessionToken;
|
||||
|
||||
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Payload Delta", "d6"));
|
||||
var character = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Delta Hero", campaign.Id));
|
||||
var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, character.Id, "Stealth", "2D+1", 1, true));
|
||||
|
||||
for (var i = 0; i < 25; i++)
|
||||
_ = ServiceTestSupport.GetValue(service.RollSkill(ownerSession, skill.Id, "public"));
|
||||
|
||||
var initialPage = ServiceTestSupport.GetValue(service.GetCampaignLogPage(gmSession, campaign.Id, limit: 25));
|
||||
_ = ServiceTestSupport.GetValue(service.RollSkill(ownerSession, skill.Id, "public"));
|
||||
|
||||
var incrementalPage = ServiceTestSupport.GetValue(service.GetCampaignLogPage(gmSession, campaign.Id, initialPage.Cursor, 25));
|
||||
AssertPayloadWithinBudget(incrementalPage, 2 * 1024, "incremental log update");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RolemasterCampaignLogInitialPagePayload_StaysWithinBudget()
|
||||
{
|
||||
using var harness = ServiceTestSupport.CreateHarness(CreateRolemasterOpenEndedRolls(90));
|
||||
var service = harness.Service;
|
||||
|
||||
service.Register("gm-rm-log-budget", "Password123", "GM");
|
||||
service.Register("owner-rm-log-budget", "Password123", "Owner");
|
||||
|
||||
var gmSession = ServiceTestSupport.GetValue(service.Login("gm-rm-log-budget", "Password123")).SessionToken;
|
||||
var ownerSession = ServiceTestSupport.GetValue(service.Login("owner-rm-log-budget", "Password123")).SessionToken;
|
||||
|
||||
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Rolemaster Payload Log", "rolemaster"));
|
||||
var character = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Open Hero", campaign.Id));
|
||||
var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, character.Id, "Awareness", "d100!+85", 0, false, null, 5));
|
||||
|
||||
for (var i = 0; i < 25; i++)
|
||||
_ = ServiceTestSupport.GetValue(service.RollSkill(ownerSession, skill.Id, "public"));
|
||||
|
||||
var page = ServiceTestSupport.GetValue(service.GetCampaignLogPage(gmSession, campaign.Id, limit: 25));
|
||||
AssertPayloadWithinBudget(page, 8 * 1024, "initial rolemaster log page");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RollResultPayload_StaysWithinJsInteropBudget()
|
||||
{
|
||||
using var harness = ServiceTestSupport.CreateHarness(CreateScriptedRolls(40));
|
||||
var service = harness.Service;
|
||||
|
||||
service.Register("gm-roll-budget", "Password123", "GM");
|
||||
service.Register("owner-roll-budget", "Password123", "Owner");
|
||||
|
||||
var gmSession = ServiceTestSupport.GetValue(service.Login("gm-roll-budget", "Password123")).SessionToken;
|
||||
var ownerSession = ServiceTestSupport.GetValue(service.Login("owner-roll-budget", "Password123")).SessionToken;
|
||||
|
||||
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Payload Roll", "d6"));
|
||||
var character = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Roll Hero", campaign.Id));
|
||||
var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, character.Id, "Wild Roll", "6D+3", 3, true));
|
||||
|
||||
var roll = ServiceTestSupport.GetValue(service.RollSkill(ownerSession, skill.Id, "public"));
|
||||
AssertPayloadWithinBudget(roll, 16 * 1024, "roll mutation response");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RolemasterRollDetailPayload_StaysWithinBudget_AndRetryMetadataRemainsLazy()
|
||||
{
|
||||
using var harness = ServiceTestSupport.CreateHarness(66, 42);
|
||||
var service = harness.Service;
|
||||
|
||||
service.Register("gm-rm-detail-budget", "Password123", "GM");
|
||||
service.Register("owner-rm-detail-budget", "Password123", "Owner");
|
||||
|
||||
var gmSession = ServiceTestSupport.GetValue(service.Login("gm-rm-detail-budget", "Password123")).SessionToken;
|
||||
var ownerSession = ServiceTestSupport.GetValue(service.Login("owner-rm-detail-budget", "Password123")).SessionToken;
|
||||
|
||||
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Rolemaster Payload Detail", "rolemaster"));
|
||||
var character = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Detail Hero", campaign.Id));
|
||||
var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, character.Id, "Sense", "d100!+10", 0, false, null, 5, true));
|
||||
|
||||
var roll = ServiceTestSupport.GetValue(service.RollSkill(ownerSession, skill.Id, "public"));
|
||||
var logPage = ServiceTestSupport.GetValue(service.GetCampaignLogPage(gmSession, campaign.Id, limit: 5));
|
||||
var detail = ServiceTestSupport.GetValue(service.GetRollDetail(gmSession, roll.RollId));
|
||||
Assert.Equal("66 | open-ended | retry +5", Assert.Single(logPage.Entries).SummaryText);
|
||||
|
||||
AssertPayloadWithinBudget(detail, 4 * 1024, "rolemaster roll detail");
|
||||
|
||||
var rollJson = JsonSerializer.Serialize(roll, SerializerOptions);
|
||||
var logPageJson = JsonSerializer.Serialize(logPage, SerializerOptions);
|
||||
var detailJson = JsonSerializer.Serialize(detail, SerializerOptions);
|
||||
|
||||
Assert.DoesNotContain("\"signedContribution\":null", rollJson, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("\"signedContribution\"", logPageJson, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("\"sequence\"", logPageJson, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("\"breakdown\"", logPageJson, StringComparison.Ordinal);
|
||||
Assert.Contains("\"kind\":\"rolemaster-open-ended-initial\"", detailJson, StringComparison.Ordinal);
|
||||
Assert.Contains("\"signedContribution\":66", detailJson, StringComparison.Ordinal);
|
||||
Assert.Contains("\"attempt\":1", detailJson, StringComparison.Ordinal);
|
||||
Assert.Contains("\"attempt\":2", detailJson, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static void AssertPayloadWithinBudget<T>(T payload, int maxBytes, string label)
|
||||
{
|
||||
var byteCount = JsonSerializer.SerializeToUtf8Bytes(payload, SerializerOptions).Length;
|
||||
Assert.True(byteCount <= maxBytes, $"{label} payload was {byteCount} bytes, above the {maxBytes}-byte budget.");
|
||||
}
|
||||
|
||||
private static int[] CreateScriptedRolls(int count)
|
||||
{
|
||||
var values = new[] { 6, 5, 4, 3, 2, 1 };
|
||||
var scriptedRolls = new int[count];
|
||||
for (var i = 0; i < count; i++)
|
||||
scriptedRolls[i] = values[i % values.Length];
|
||||
|
||||
return scriptedRolls;
|
||||
}
|
||||
|
||||
private static int[] CreateRolemasterOpenEndedRolls(int count)
|
||||
{
|
||||
var values = new[] { 96, 100, 12 };
|
||||
var scriptedRolls = new int[count];
|
||||
for (var i = 0; i < count; i++)
|
||||
scriptedRolls[i] = values[i % values.Length];
|
||||
|
||||
return scriptedRolls;
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions SerializerOptions = RpgRollerJson.CreateSerializerOptions();
|
||||
}
|
||||
@@ -1,26 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4"/>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1"/>
|
||||
<PackageReference Include="xunit" Version="2.9.3"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\RpgRoller\RpgRoller.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\RpgRoller\RpgRoller.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
using RpgRoller.Services;
|
||||
|
||||
namespace RpgRoller.Tests;
|
||||
|
||||
public sealed class DiceRulesTests
|
||||
@@ -9,28 +7,53 @@ public sealed class DiceRulesTests
|
||||
{
|
||||
Assert.Equal(RulesetKind.D6, DiceRules.TryParseRulesetId("d6"));
|
||||
Assert.Equal(RulesetKind.Dnd5e, DiceRules.TryParseRulesetId("dnd5e"));
|
||||
Assert.Equal(RulesetKind.Rolemaster, DiceRules.TryParseRulesetId("rolemaster"));
|
||||
Assert.Null(DiceRules.TryParseRulesetId("unknown"));
|
||||
|
||||
var d6 = DiceRules.ParseExpression(RulesetKind.D6, "5D+4");
|
||||
var dnd = DiceRules.ParseExpression(RulesetKind.Dnd5e, "2d12+2");
|
||||
var rolemasterImplicitSingle = DiceRules.ParseExpression(RulesetKind.Rolemaster, "d10");
|
||||
var rolemasterManyDice = DiceRules.ParseExpression(RulesetKind.Rolemaster, "15d10-15");
|
||||
var rolemasterPercentile = DiceRules.ParseExpression(RulesetKind.Rolemaster, "d100+4");
|
||||
var rolemasterOpenEnded = DiceRules.ParseExpression(RulesetKind.Rolemaster, "1d100!+85");
|
||||
var emptyExpression = DiceRules.ParseExpression(RulesetKind.Dnd5e, "");
|
||||
var badFormat = DiceRules.ParseExpression(RulesetKind.Dnd5e, "abc");
|
||||
var tooManyDice = DiceRules.ParseExpression(RulesetKind.D6, "51D+1");
|
||||
var tooManySides = DiceRules.ParseExpression(RulesetKind.Dnd5e, "1d1001");
|
||||
var tooLargeModifier = DiceRules.ParseExpression(RulesetKind.Dnd5e, "1d20+1001");
|
||||
var negativeDndModifier = DiceRules.ParseExpression(RulesetKind.Dnd5e, "1d20-1");
|
||||
var invalidRolemasterOpenEndedFormat = DiceRules.ParseExpression(RulesetKind.Rolemaster, "2d10!+1");
|
||||
var tooNegativeRolemasterModifier = DiceRules.ParseExpression(RulesetKind.Rolemaster, "d100-1001");
|
||||
var unknownRulesetExpression = DiceRules.ParseExpression((RulesetKind)99, "1d20+1");
|
||||
|
||||
Assert.True(d6.Succeeded);
|
||||
Assert.True(dnd.Succeeded);
|
||||
Assert.True(rolemasterImplicitSingle.Succeeded);
|
||||
Assert.True(rolemasterManyDice.Succeeded);
|
||||
Assert.True(rolemasterPercentile.Succeeded);
|
||||
Assert.True(rolemasterOpenEnded.Succeeded);
|
||||
Assert.False(emptyExpression.Succeeded);
|
||||
Assert.False(badFormat.Succeeded);
|
||||
Assert.False(tooManyDice.Succeeded);
|
||||
Assert.False(tooManySides.Succeeded);
|
||||
Assert.False(tooLargeModifier.Succeeded);
|
||||
Assert.False(negativeDndModifier.Succeeded);
|
||||
Assert.False(invalidRolemasterOpenEndedFormat.Succeeded);
|
||||
Assert.False(tooNegativeRolemasterModifier.Succeeded);
|
||||
Assert.False(unknownRulesetExpression.Succeeded);
|
||||
|
||||
Assert.Equal("d10", rolemasterImplicitSingle.Value!.Canonical);
|
||||
Assert.Equal(DiceExpressionKind.Standard, rolemasterImplicitSingle.Value.Kind);
|
||||
Assert.Equal("15d10-15", rolemasterManyDice.Value!.Canonical);
|
||||
Assert.Equal(DiceExpressionKind.Standard, rolemasterManyDice.Value.Kind);
|
||||
Assert.Equal("d100+4", rolemasterPercentile.Value!.Canonical);
|
||||
Assert.Equal(DiceExpressionKind.Standard, rolemasterPercentile.Value.Kind);
|
||||
Assert.Equal("d100!+85", rolemasterOpenEnded.Value!.Canonical);
|
||||
Assert.Equal(DiceExpressionKind.RolemasterOpenEndedPercentile, rolemasterOpenEnded.Value.Kind);
|
||||
|
||||
Assert.Equal("d6", DiceRules.ToRulesetId(RulesetKind.D6));
|
||||
Assert.Equal("dnd5e", DiceRules.ToRulesetId(RulesetKind.Dnd5e));
|
||||
Assert.Equal("rolemaster", DiceRules.ToRulesetId(RulesetKind.Rolemaster));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => DiceRules.ToRulesetId((RulesetKind)99));
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
using RpgRoller.Services;
|
||||
|
||||
namespace RpgRoller.Tests;
|
||||
|
||||
public sealed class RandomDiceRollerTests
|
||||
|
||||
140
RpgRoller.Tests/Services/ServiceAdminAndCampaignDeletionTests.cs
Normal file
140
RpgRoller.Tests/Services/ServiceAdminAndCampaignDeletionTests.cs
Normal file
@@ -0,0 +1,140 @@
|
||||
namespace RpgRoller.Tests;
|
||||
|
||||
public sealed class ServiceAdminAndCampaignDeletionTests
|
||||
{
|
||||
[Fact]
|
||||
public void AdminRoleManagement_RequiresAdminAndProtectsSelf()
|
||||
{
|
||||
using var harness = ServiceTestSupport.CreateHarness();
|
||||
var service = harness.Service;
|
||||
|
||||
var bootstrapAdmin = ServiceTestSupport.GetValue(service.Register("admin", "Password123", "Admin"));
|
||||
_ = ServiceTestSupport.GetValue(service.Register("member", "Password123", "Member"));
|
||||
|
||||
Assert.Contains(bootstrapAdmin.Roles, role => string.Equals(role, UserRoles.Admin, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
var adminSession = ServiceTestSupport.GetValue(service.Login("admin", "Password123")).SessionToken;
|
||||
var memberSession = ServiceTestSupport.GetValue(service.Login("member", "Password123")).SessionToken;
|
||||
|
||||
var usernames = ServiceTestSupport.GetValue(service.GetUsernames(memberSession));
|
||||
Assert.Equal(["admin", "member"], usernames);
|
||||
|
||||
var forbiddenList = service.GetUsers(memberSession);
|
||||
Assert.False(forbiddenList.Succeeded);
|
||||
|
||||
var users = ServiceTestSupport.GetValue(service.GetUsers(adminSession));
|
||||
var memberUser = users.Single(user => string.Equals(user.Username, "member", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Empty(memberUser.Roles);
|
||||
|
||||
var promoted = ServiceTestSupport.GetValue(service.UpdateUserRoles(adminSession, memberUser.Id, [UserRoles.Admin]));
|
||||
Assert.Contains(promoted.Roles, role => string.Equals(role, UserRoles.Admin, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
var invalidRole = service.UpdateUserRoles(adminSession, memberUser.Id, [UserRoles.Admin, "gm"]);
|
||||
Assert.False(invalidRole.Succeeded);
|
||||
Assert.Equal("invalid_role", invalidRole.Error?.Code);
|
||||
|
||||
var selfDemote = service.UpdateUserRoles(adminSession, bootstrapAdmin.Id, Array.Empty<string>());
|
||||
Assert.False(selfDemote.Succeeded);
|
||||
|
||||
var selfDelete = service.DeleteUser(adminSession, bootstrapAdmin.Id);
|
||||
Assert.False(selfDelete.Succeeded);
|
||||
|
||||
var deletedMember = ServiceTestSupport.GetValue(service.DeleteUser(adminSession, memberUser.Id));
|
||||
Assert.True(deletedMember);
|
||||
|
||||
var remainingUsers = ServiceTestSupport.GetValue(service.GetUsers(adminSession));
|
||||
Assert.Single(remainingUsers);
|
||||
Assert.Equal(bootstrapAdmin.Id, remainingUsers[0].Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CampaignDeletion_ByOwnerOrAdmin_UnlinksCharactersAndClearsLog()
|
||||
{
|
||||
using var harness = ServiceTestSupport.CreateHarness(4, 5, 6);
|
||||
var service = harness.Service;
|
||||
|
||||
_ = ServiceTestSupport.GetValue(service.Register("admin", "Password123", "Admin"));
|
||||
_ = ServiceTestSupport.GetValue(service.Register("gm", "Password123", "GM"));
|
||||
_ = ServiceTestSupport.GetValue(service.Register("player", "Password123", "Player"));
|
||||
|
||||
var adminSession = ServiceTestSupport.GetValue(service.Login("admin", "Password123")).SessionToken;
|
||||
var gmSession = ServiceTestSupport.GetValue(service.Login("gm", "Password123")).SessionToken;
|
||||
var playerSession = ServiceTestSupport.GetValue(service.Login("player", "Password123")).SessionToken;
|
||||
|
||||
var ownerDeletedCampaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Owner Delete", "d6"));
|
||||
var ownerDeleteResult = ServiceTestSupport.GetValue(service.DeleteCampaign(gmSession, ownerDeletedCampaign.Id));
|
||||
Assert.True(ownerDeleteResult);
|
||||
Assert.False(service.GetCampaign(gmSession, ownerDeletedCampaign.Id).Succeeded);
|
||||
|
||||
var adminDeletedCampaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Admin Delete", "d6"));
|
||||
var playerCharacter = ServiceTestSupport.GetValue(service.CreateCharacter(playerSession, "Scout", adminDeletedCampaign.Id));
|
||||
var playerSkill = ServiceTestSupport.GetValue(service.CreateSkill(playerSession, playerCharacter.Id, "Stealth", "2D+1", 1, true));
|
||||
_ = ServiceTestSupport.GetValue(service.RollSkill(playerSession, playerSkill.Id, "public"));
|
||||
|
||||
var forbiddenDelete = service.DeleteCampaign(playerSession, adminDeletedCampaign.Id);
|
||||
Assert.False(forbiddenDelete.Succeeded);
|
||||
|
||||
var adminDelete = ServiceTestSupport.GetValue(service.DeleteCampaign(adminSession, adminDeletedCampaign.Id));
|
||||
Assert.True(adminDelete);
|
||||
Assert.False(service.GetCampaign(gmSession, adminDeletedCampaign.Id).Succeeded);
|
||||
|
||||
var playerCharacters = ServiceTestSupport.GetValue(service.GetOwnCharacters(playerSession));
|
||||
Assert.Single(playerCharacters);
|
||||
Assert.Null(playerCharacters[0].CampaignId);
|
||||
|
||||
using var db = harness.CreateDbContext();
|
||||
Assert.Empty(db.RollLogEntries.Where(entry => entry.CampaignId == adminDeletedCampaign.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeleteUser_DeletesOwnedCampaigns_AndUnlinksCharactersInThoseCampaigns()
|
||||
{
|
||||
using var harness = ServiceTestSupport.CreateHarness(4, 5, 6);
|
||||
var service = harness.Service;
|
||||
|
||||
_ = ServiceTestSupport.GetValue(service.Register("admin", "Password123", "Admin"));
|
||||
_ = ServiceTestSupport.GetValue(service.Register("gm", "Password123", "GM"));
|
||||
_ = ServiceTestSupport.GetValue(service.Register("player", "Password123", "Player"));
|
||||
|
||||
var adminSession = ServiceTestSupport.GetValue(service.Login("admin", "Password123")).SessionToken;
|
||||
var gmSession = ServiceTestSupport.GetValue(service.Login("gm", "Password123")).SessionToken;
|
||||
var playerSession = ServiceTestSupport.GetValue(service.Login("player", "Password123")).SessionToken;
|
||||
|
||||
var gmOwnedCampaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "GM Campaign", "d6"));
|
||||
var adminOwnedCampaign = ServiceTestSupport.GetValue(service.CreateCampaign(adminSession, "Admin Campaign", "d6"));
|
||||
|
||||
var gmCharacterInOwnedCampaign = ServiceTestSupport.GetValue(service.CreateCharacter(gmSession, "GM Hero", gmOwnedCampaign.Id));
|
||||
var playerCharacterInGmCampaign = ServiceTestSupport.GetValue(service.CreateCharacter(playerSession, "Player Hero", gmOwnedCampaign.Id));
|
||||
var gmCharacterOutsideOwnedCampaign = ServiceTestSupport.GetValue(service.CreateCharacter(gmSession, "Visitor", adminOwnedCampaign.Id));
|
||||
|
||||
var playerSkill = ServiceTestSupport.GetValue(service.CreateSkill(playerSession, playerCharacterInGmCampaign.Id, "Scout", "2D+1", 1, true));
|
||||
_ = ServiceTestSupport.GetValue(service.RollSkill(playerSession, playerSkill.Id, "public"));
|
||||
|
||||
var gmUsers = ServiceTestSupport.GetValue(service.GetUsers(adminSession));
|
||||
var gmUser = gmUsers.Single(user => string.Equals(user.Username, "gm", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
var deleteResult = ServiceTestSupport.GetValue(service.DeleteUser(adminSession, gmUser.Id));
|
||||
Assert.True(deleteResult);
|
||||
|
||||
Assert.Null(service.GetUserBySession(gmSession));
|
||||
Assert.False(service.GetMe(gmSession).Succeeded);
|
||||
Assert.False(service.GetUsernames(gmSession).Succeeded);
|
||||
Assert.False(service.GetCampaign(adminSession, gmOwnedCampaign.Id).Succeeded);
|
||||
|
||||
var playerCharacters = ServiceTestSupport.GetValue(service.GetOwnCharacters(playerSession));
|
||||
var unlinkedPlayerCharacter = playerCharacters.Single(character => character.Id == playerCharacterInGmCampaign.Id);
|
||||
Assert.Null(unlinkedPlayerCharacter.CampaignId);
|
||||
|
||||
using var db = harness.CreateDbContext();
|
||||
Assert.DoesNotContain(db.Campaigns, campaign => campaign.Id == gmOwnedCampaign.Id);
|
||||
Assert.Empty(db.RollLogEntries.Where(entry => entry.CampaignId == gmOwnedCampaign.Id));
|
||||
|
||||
var preservedGmCharacter = db.Characters.Single(character => character.Id == gmCharacterInOwnedCampaign.Id);
|
||||
Assert.Null(preservedGmCharacter.CampaignId);
|
||||
|
||||
var preservedPlayerCharacter = db.Characters.Single(character => character.Id == playerCharacterInGmCampaign.Id);
|
||||
Assert.Null(preservedPlayerCharacter.CampaignId);
|
||||
|
||||
Assert.DoesNotContain(db.Characters, character => character.Id == gmCharacterOutsideOwnedCampaign.Id);
|
||||
}
|
||||
}
|
||||
@@ -56,4 +56,22 @@ public sealed class ServiceAuthTests
|
||||
Assert.True(login.Succeeded);
|
||||
Assert.Equal(2, hasher.HashCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetUsernames_RequiresAuthAndReturnsSortedUsernames()
|
||||
{
|
||||
using var harness = ServiceTestSupport.CreateHarness();
|
||||
var service = harness.Service;
|
||||
|
||||
service.Register("zoe", "Password123", "Zoe");
|
||||
service.Register("amy", "Password123", "Amy");
|
||||
service.Register("bob", "Password123", "Bob");
|
||||
|
||||
var unauthorized = service.GetUsernames(string.Empty);
|
||||
Assert.False(unauthorized.Succeeded);
|
||||
|
||||
var session = ServiceTestSupport.GetValue(service.Login("bob", "Password123")).SessionToken;
|
||||
var usernames = ServiceTestSupport.GetValue(service.GetUsernames(session));
|
||||
Assert.Equal(["amy", "bob", "zoe"], usernames);
|
||||
}
|
||||
}
|
||||
@@ -14,9 +14,11 @@ public sealed class ServiceCampaignTests
|
||||
service.Register("gm", "Password123", "GM");
|
||||
var gmSession = ServiceTestSupport.GetValue(service.Login("gm", "Password123")).SessionToken;
|
||||
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Name", "d6"));
|
||||
var rolemasterCampaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Rolemaster Name", "rolemaster"));
|
||||
|
||||
var invalidRuleset = service.CreateCampaign(gmSession, "Name 2", "unknown");
|
||||
Assert.False(invalidRuleset.Succeeded);
|
||||
Assert.Equal("rolemaster", rolemasterCampaign.RulesetId);
|
||||
|
||||
var noCampaignCharacter = service.CreateCharacter(gmSession, "Hero", Guid.NewGuid());
|
||||
Assert.False(noCampaignCharacter.Succeeded);
|
||||
@@ -29,7 +31,7 @@ public sealed class ServiceCampaignTests
|
||||
var activateSuccess = service.ActivateCharacter(gmSession, character.Id);
|
||||
Assert.True(activateSuccess.Succeeded);
|
||||
|
||||
var currentCharacters = service.GetCurrentCampaignCharacters(gmSession);
|
||||
var currentCharacters = service.GetOwnCharacters(gmSession);
|
||||
Assert.True(currentCharacters.Succeeded);
|
||||
Assert.Single(ServiceTestSupport.GetValue(currentCharacters));
|
||||
|
||||
@@ -45,8 +47,9 @@ public sealed class ServiceCampaignTests
|
||||
service.Register("user", "Password123", "User");
|
||||
var sessionToken = ServiceTestSupport.GetValue(service.Login("user", "Password123")).SessionToken;
|
||||
|
||||
var result = service.GetCurrentCampaignCharacters(sessionToken);
|
||||
Assert.False(result.Succeeded);
|
||||
var result = service.GetOwnCharacters(sessionToken);
|
||||
Assert.True(result.Succeeded);
|
||||
Assert.Empty(ServiceTestSupport.GetValue(result));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -71,7 +74,31 @@ public sealed class ServiceCampaignTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetCampaign_ForNonGm_ReturnsOnlyOwnedCharactersAndSkills()
|
||||
public void GetCharacterCampaignOptions_ReturnsAllCampaignsForCharacterAssignment()
|
||||
{
|
||||
using var harness = ServiceTestSupport.CreateHarness();
|
||||
var service = harness.Service;
|
||||
service.Register("gm1", "Password123", "GM One");
|
||||
service.Register("gm2", "Password123", "GM Two");
|
||||
service.Register("player", "Password123", "Player");
|
||||
var gmSession = ServiceTestSupport.GetValue(service.Login("gm1", "Password123")).SessionToken;
|
||||
var gmTwoSession = ServiceTestSupport.GetValue(service.Login("gm2", "Password123")).SessionToken;
|
||||
var playerSession = ServiceTestSupport.GetValue(service.Login("player", "Password123")).SessionToken;
|
||||
|
||||
var firstCampaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Alpha", "d6"));
|
||||
var secondCampaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmTwoSession, "Beta", "d6"));
|
||||
|
||||
var visibleCampaigns = ServiceTestSupport.GetValue(service.GetCampaigns(playerSession));
|
||||
Assert.Empty(visibleCampaigns);
|
||||
|
||||
var options = ServiceTestSupport.GetValue(service.GetCharacterCampaignOptions(playerSession));
|
||||
Assert.Equal(2, options.Count);
|
||||
Assert.Contains(options, option => option.Id == firstCampaign.Id);
|
||||
Assert.Contains(options, option => option.Id == secondCampaign.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetCampaignAndCharacterSheet_ForNonGmParticipant_ReturnCampaignRosterAndSheet()
|
||||
{
|
||||
using var harness = ServiceTestSupport.CreateHarness();
|
||||
var service = harness.Service;
|
||||
@@ -92,9 +119,48 @@ public sealed class ServiceCampaignTests
|
||||
_ = ServiceTestSupport.GetValue(service.CreateSkill(otherSession, otherCharacter.Id, "Perception", "1D+2", 1, true));
|
||||
|
||||
var ownerView = ServiceTestSupport.GetValue(service.GetCampaign(ownerSession, campaign.Id));
|
||||
Assert.Single(ownerView.Characters);
|
||||
Assert.Equal(ownerCharacter.Id, ownerView.Characters[0].Id);
|
||||
Assert.Single(ownerView.Skills);
|
||||
Assert.Equal(ownerSkill.Id, ownerView.Skills[0].Id);
|
||||
Assert.Equal(2, ownerView.Characters.Length);
|
||||
Assert.Contains(ownerView.Characters, character => character.Id == ownerCharacter.Id);
|
||||
Assert.Contains(ownerView.Characters, character => character.Id == otherCharacter.Id);
|
||||
|
||||
var ownerSheet = ServiceTestSupport.GetValue(service.GetCharacterSheet(ownerSession, ownerCharacter.Id));
|
||||
var otherSheet = ServiceTestSupport.GetValue(service.GetCharacterSheet(ownerSession, otherCharacter.Id));
|
||||
Assert.Single(ownerSheet.Skills);
|
||||
Assert.Contains(ownerSheet.Skills, skill => skill.Id == ownerSkill.Id);
|
||||
Assert.Single(otherSheet.Skills);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CampaignStateSnapshot_TracksRosterCharacterAndLogSlicesIndependently()
|
||||
{
|
||||
using var harness = ServiceTestSupport.CreateHarness(4, 5, 6, 3);
|
||||
var service = harness.Service;
|
||||
|
||||
service.Register("gm-state", "Password123", "GM");
|
||||
service.Register("owner-state", "Password123", "Owner");
|
||||
|
||||
var gmSession = ServiceTestSupport.GetValue(service.Login("gm-state", "Password123")).SessionToken;
|
||||
var ownerSession = ServiceTestSupport.GetValue(service.Login("owner-state", "Password123")).SessionToken;
|
||||
|
||||
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "State Campaign", "d6"));
|
||||
var character = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "State Hero", campaign.Id));
|
||||
var afterCharacterCreate = ServiceTestSupport.GetValue(service.GetCampaignStateSnapshot(ownerSession, campaign.Id));
|
||||
|
||||
var initialCharacterVersion = Assert.Single(afterCharacterCreate.CharacterVersions, version => version.CharacterId == character.Id).Version;
|
||||
Assert.True(afterCharacterCreate.RosterVersion > 1);
|
||||
|
||||
var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, character.Id, "Stealth", "2D+1", 1, true));
|
||||
var afterSkillCreate = ServiceTestSupport.GetValue(service.GetCampaignStateSnapshot(ownerSession, campaign.Id));
|
||||
var updatedCharacterVersion = Assert.Single(afterSkillCreate.CharacterVersions, version => version.CharacterId == character.Id).Version;
|
||||
|
||||
Assert.Equal(afterCharacterCreate.RosterVersion, afterSkillCreate.RosterVersion);
|
||||
Assert.True(updatedCharacterVersion > initialCharacterVersion);
|
||||
|
||||
_ = ServiceTestSupport.GetValue(service.RollSkill(ownerSession, skill.Id, "public"));
|
||||
var afterRoll = ServiceTestSupport.GetValue(service.GetCampaignStateSnapshot(ownerSession, campaign.Id));
|
||||
|
||||
Assert.Equal(afterSkillCreate.RosterVersion, afterRoll.RosterVersion);
|
||||
Assert.Equal(updatedCharacterVersion, Assert.Single(afterRoll.CharacterVersions, version => version.CharacterId == character.Id).Version);
|
||||
Assert.True(afterRoll.LogVersion > afterSkillCreate.LogVersion);
|
||||
}
|
||||
}
|
||||
85
RpgRoller.Tests/Services/ServiceHelperExtractionTests.cs
Normal file
85
RpgRoller.Tests/Services/ServiceHelperExtractionTests.cs
Normal file
@@ -0,0 +1,85 @@
|
||||
namespace RpgRoller.Tests;
|
||||
|
||||
public sealed class ServiceHelperExtractionTests
|
||||
{
|
||||
[Fact]
|
||||
public void RoleSerializer_NormalizesParsesAndSerializesRoles()
|
||||
{
|
||||
var normalized = RoleSerializer.Normalize([" Admin ", "gm", "admin", "", "GM"]);
|
||||
var serialized = RoleSerializer.Serialize(normalized);
|
||||
var parsed = RoleSerializer.Parse(" admin,GM,admin ");
|
||||
|
||||
Assert.Equal(["admin", "gm"], normalized);
|
||||
Assert.Equal("admin,gm", serialized);
|
||||
Assert.Equal(["admin", "gm"], parsed);
|
||||
Assert.True(RoleSerializer.HasRole(serialized, "ADMIN"));
|
||||
Assert.False(RoleSerializer.HasRole(serialized, "owner"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("public", RollVisibility.Public)]
|
||||
[InlineData("PRIVATE", RollVisibility.Private)]
|
||||
public void RollVisibilityParser_ParsesKnownValues(string input, RollVisibility expected)
|
||||
{
|
||||
var result = RollVisibilityParser.Parse(input);
|
||||
|
||||
Assert.True(result.Succeeded);
|
||||
Assert.Equal(expected, result.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RollVisibilityParser_RejectsUnknownValue()
|
||||
{
|
||||
var result = RollVisibilityParser.Parse("hidden");
|
||||
|
||||
Assert.False(result.Succeeded);
|
||||
Assert.Equal("invalid_visibility", result.Error!.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CustomRollOptionsResolver_ReturnsD6DefaultsOnlyForD6()
|
||||
{
|
||||
Assert.Equal((1, true, (int?)null), CustomRollOptionsResolver.Resolve(RulesetKind.D6));
|
||||
Assert.Equal((0, false, (int?)null), CustomRollOptionsResolver.Resolve(RulesetKind.Dnd5e));
|
||||
Assert.Equal((0, false, (int?)null), CustomRollOptionsResolver.Resolve(RulesetKind.Rolemaster));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SkillDefinitionValidator_ValidatesRulesetSpecificOptions()
|
||||
{
|
||||
var d6 = SkillDefinitionValidator.Validate(RulesetKind.D6, "2D+1", 1, true, null);
|
||||
var rolemaster = SkillDefinitionValidator.Validate(RulesetKind.Rolemaster, "d100!+15", 0, false, 5, true);
|
||||
var invalidD6 = SkillDefinitionValidator.Validate(RulesetKind.D6, "2D+1", 0, true, null);
|
||||
var invalidRolemaster = SkillDefinitionValidator.Validate(RulesetKind.Rolemaster, "d100!+15", 0, false, null);
|
||||
var invalidRetry = SkillDefinitionValidator.Validate(RulesetKind.Rolemaster, "d100+15", 0, false, null, true);
|
||||
|
||||
Assert.True(d6.Succeeded);
|
||||
Assert.Equal(("2D+1", 1, true, (int?)null, false), d6.Value);
|
||||
|
||||
Assert.True(rolemaster.Succeeded);
|
||||
Assert.Equal(("d100!+15", 0, false, (int?)5, true), rolemaster.Value);
|
||||
|
||||
Assert.False(invalidD6.Succeeded);
|
||||
Assert.Equal("invalid_wild_dice", invalidD6.Error!.Code);
|
||||
|
||||
Assert.False(invalidRolemaster.Succeeded);
|
||||
Assert.Equal("invalid_fumble_range", invalidRolemaster.Error!.Code);
|
||||
|
||||
Assert.False(invalidRetry.Succeeded);
|
||||
Assert.Equal("invalid_rolemaster_retry", invalidRetry.Error!.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RolemasterRetryPolicy_ResolvesRetryBandsAndMarkers()
|
||||
{
|
||||
Assert.Equal(5, RolemasterRetryPolicy.ResolveAutoRetryBonus(76));
|
||||
Assert.Equal(5, RolemasterRetryPolicy.ResolveAutoRetryBonus(90));
|
||||
Assert.Equal(10, RolemasterRetryPolicy.ResolveAutoRetryBonus(91));
|
||||
Assert.Equal(10, RolemasterRetryPolicy.ResolveAutoRetryBonus(110));
|
||||
Assert.Null(RolemasterRetryPolicy.ResolveAutoRetryBonus(75));
|
||||
Assert.Null(RolemasterRetryPolicy.ResolveAutoRetryBonus(111));
|
||||
Assert.Equal(5, RolemasterRetryPolicy.TryExtractRetryBonus("68+10=78; retry(+5): 42+10=52; final=57"));
|
||||
Assert.Equal(10, RolemasterRetryPolicy.TryExtractRetryBonus("90+1=91; retry(+10): 32+1=33; final=43"));
|
||||
Assert.Null(RolemasterRetryPolicy.TryExtractRetryBonus("68+10=78"));
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,8 @@ public sealed class ServicePersistenceTests
|
||||
var otherSession = ServiceTestSupport.GetValue(service.Login("other", "Password123")).SessionToken;
|
||||
|
||||
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Main", "d6"));
|
||||
var ownerCharacter = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Owner Character", campaign.Id));
|
||||
var ownerCharacter =
|
||||
ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Owner Character", campaign.Id));
|
||||
|
||||
Assert.False(service.GetMe(string.Empty).Succeeded);
|
||||
Assert.False(service.CreateCampaign(gmSession, "", "d6").Succeeded);
|
||||
@@ -32,12 +33,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.False(service.GetCurrentCampaignCharacters(string.Empty).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");
|
||||
@@ -67,14 +72,16 @@ public sealed class ServicePersistenceTests
|
||||
using var staleCurrentHarness = ServiceTestSupport.CreateHarnessFromPath(harness.DbPath, 2, 3, 4);
|
||||
var staleCurrentService = staleCurrentHarness.Service;
|
||||
|
||||
var staleCurrentCampaign = staleCurrentService.GetCurrentCampaignCharacters(ownerSession);
|
||||
Assert.False(staleCurrentCampaign.Succeeded);
|
||||
var staleCurrentCampaign = staleCurrentService.GetOwnCharacters(ownerSession);
|
||||
Assert.True(staleCurrentCampaign.Succeeded);
|
||||
Assert.Single(ServiceTestSupport.GetValue(staleCurrentCampaign));
|
||||
using (var db = harness.CreateDbContext())
|
||||
{
|
||||
Assert.Null(db.Users.Single(u => u.UsernameNormalized == "OWNER").ActiveCharacterId);
|
||||
Assert.NotNull(db.Users.Single(u => u.UsernameNormalized == "OWNER").ActiveCharacterId);
|
||||
}
|
||||
|
||||
var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, ownerCharacter.Id, "Stealth", "2D+1", 1, true));
|
||||
var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, ownerCharacter.Id, "Stealth", "2D+1",
|
||||
1, true));
|
||||
Assert.False(service.UpdateSkill(ownerSession, skill.Id, "", "2D+1", 1, true).Succeeded);
|
||||
Assert.False(service.UpdateSkill(string.Empty, skill.Id, "Stealth", "2D+1", 1, true).Succeeded);
|
||||
Assert.False(service.UpdateSkill(ownerSession, skill.Id, "Stealth", "bad", 1, true).Succeeded);
|
||||
@@ -91,4 +98,36 @@ public sealed class ServicePersistenceTests
|
||||
Assert.False(invalidExpressionHarness.Service.RollSkill(ownerSession, skill.Id, "public").Succeeded);
|
||||
Assert.False(service.GetCampaignLog(string.Empty, campaign.Id).Succeeded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RolemasterSkillOptions_PersistAcrossDatabaseReload()
|
||||
{
|
||||
using var harness = ServiceTestSupport.CreateHarness();
|
||||
var service = harness.Service;
|
||||
|
||||
service.Register("gm-rm-persist", "Password123", "GM");
|
||||
service.Register("owner-rm-persist", "Password123", "Owner");
|
||||
|
||||
var gmSession = ServiceTestSupport.GetValue(service.Login("gm-rm-persist", "Password123")).SessionToken;
|
||||
var ownerSession = ServiceTestSupport.GetValue(service.Login("owner-rm-persist", "Password123")).SessionToken;
|
||||
|
||||
var campaign =
|
||||
ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Rolemaster Persistence", "rolemaster"));
|
||||
var character = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Loremaster", campaign.Id));
|
||||
var group = ServiceTestSupport.GetValue(service.CreateSkillGroup(ownerSession, character.Id, "Perception",
|
||||
"d100!+25", 0, false, 5));
|
||||
var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, character.Id, "Read Runes",
|
||||
"d100!+35", 0, false, group.Id, 3, true));
|
||||
|
||||
using var reloadedHarness = ServiceTestSupport.CreateHarnessFromPath(harness.DbPath);
|
||||
var reloadedSheet =
|
||||
ServiceTestSupport.GetValue(reloadedHarness.Service.GetCharacterSheet(ownerSession, character.Id));
|
||||
|
||||
var reloadedGroup = Assert.Single(reloadedSheet.SkillGroups, current => current.Id == group.Id);
|
||||
Assert.Equal(5, reloadedGroup.FumbleRange);
|
||||
|
||||
var reloadedSkill = Assert.Single(reloadedSheet.Skills, current => current.Id == skill.Id);
|
||||
Assert.Equal(3, reloadedSkill.FumbleRange);
|
||||
Assert.True(reloadedSkill.RolemasterAutoRetry);
|
||||
}
|
||||
}
|
||||
317
RpgRoller.Tests/Services/ServiceRolemasterRollTests.cs
Normal file
317
RpgRoller.Tests/Services/ServiceRolemasterRollTests.cs
Normal file
@@ -0,0 +1,317 @@
|
||||
namespace RpgRoller.Tests;
|
||||
|
||||
public sealed class ServiceRolemasterRollTests
|
||||
{
|
||||
[Fact]
|
||||
public void RollSkill_RolemasterStandardMultiDie_ComputesTotalAndTagsDice()
|
||||
{
|
||||
using var harness = ServiceTestSupport.CreateHarness(7, 10);
|
||||
var service = harness.Service;
|
||||
|
||||
service.Register("gm-init", "Password123", "GM");
|
||||
var session = ServiceTestSupport.GetValue(service.Login("gm-init", "Password123")).SessionToken;
|
||||
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(session, "Rolemaster", "rolemaster"));
|
||||
var character = ServiceTestSupport.GetValue(service.CreateCharacter(session, "Hero", campaign.Id));
|
||||
var skill = ServiceTestSupport.GetValue(service.CreateSkill(session, character.Id, "Healing", "2d10+48", 0, false));
|
||||
|
||||
var roll = ServiceTestSupport.GetValue(service.RollSkill(session, skill.Id, "public"));
|
||||
var logPage = ServiceTestSupport.GetValue(service.GetCampaignLogPage(session, campaign.Id, limit: 5));
|
||||
|
||||
Assert.Equal(65, roll.Result);
|
||||
Assert.Equal("7+10+48=65", roll.Breakdown);
|
||||
Assert.Equal("7 + 10 | rolemaster", Assert.Single(logPage.Entries).SummaryText);
|
||||
Assert.Collection(roll.Dice, die =>
|
||||
{
|
||||
Assert.Equal(7, die.Roll);
|
||||
Assert.Equal(1, die.Sequence);
|
||||
Assert.Equal(RollDieKinds.RolemasterStandard, die.Kind);
|
||||
Assert.Equal(7, die.SignedContribution);
|
||||
}, die =>
|
||||
{
|
||||
Assert.Equal(10, die.Roll);
|
||||
Assert.Equal(2, die.Sequence);
|
||||
Assert.Equal(RollDieKinds.RolemasterStandard, die.Kind);
|
||||
Assert.Equal(10, die.SignedContribution);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RollSkill_RolemasterStandardSingleDie_ComputesTotalAndTagsDice()
|
||||
{
|
||||
using var harness = ServiceTestSupport.CreateHarness(73);
|
||||
var service = harness.Service;
|
||||
|
||||
service.Register("gm-percentile", "Password123", "GM");
|
||||
var session = ServiceTestSupport.GetValue(service.Login("gm-percentile", "Password123")).SessionToken;
|
||||
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(session, "Rolemaster", "rolemaster"));
|
||||
var character = ServiceTestSupport.GetValue(service.CreateCharacter(session, "Hero", campaign.Id));
|
||||
var skill = ServiceTestSupport.GetValue(service.CreateSkill(session, character.Id, "Perception", "d100-15", 0, false));
|
||||
|
||||
var roll = ServiceTestSupport.GetValue(service.RollSkill(session, skill.Id, "public"));
|
||||
var logPage = ServiceTestSupport.GetValue(service.GetCampaignLogPage(session, campaign.Id, limit: 5));
|
||||
|
||||
Assert.Equal(58, roll.Result);
|
||||
Assert.Equal("73-15=58", roll.Breakdown);
|
||||
Assert.Equal("73 | rolemaster", Assert.Single(logPage.Entries).SummaryText);
|
||||
Assert.Null(Assert.Single(logPage.Entries).EventBadges);
|
||||
|
||||
var die = Assert.Single(roll.Dice);
|
||||
Assert.Equal(73, die.Roll);
|
||||
Assert.Equal(1, die.Sequence);
|
||||
Assert.Equal(RollDieKinds.RolemasterStandard, die.Kind);
|
||||
Assert.Equal(73, die.SignedContribution);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RollSkill_RolemasterStandardSingleDie_AppliesSituationalModifier()
|
||||
{
|
||||
using var harness = ServiceTestSupport.CreateHarness(73);
|
||||
var service = harness.Service;
|
||||
|
||||
service.Register("gm-percentile-bonus", "Password123", "GM");
|
||||
var session = ServiceTestSupport.GetValue(service.Login("gm-percentile-bonus", "Password123")).SessionToken;
|
||||
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(session, "Rolemaster", "rolemaster"));
|
||||
var character = ServiceTestSupport.GetValue(service.CreateCharacter(session, "Hero", campaign.Id));
|
||||
var skill = ServiceTestSupport.GetValue(service.CreateSkill(session, character.Id, "Perception", "d100-15", 0, false));
|
||||
|
||||
var roll = ServiceTestSupport.GetValue(service.RollSkill(session, skill.Id, "public", 20));
|
||||
|
||||
Assert.Equal(78, roll.Result);
|
||||
Assert.Equal("73-15+20=78", roll.Breakdown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RollSkill_RolemasterOpenEndedHigh_RecursesAndBuildsReadableLogSummary()
|
||||
{
|
||||
using var harness = ServiceTestSupport.CreateHarness(97, 96, 45);
|
||||
var service = harness.Service;
|
||||
|
||||
service.Register("gm-open-high", "Password123", "GM");
|
||||
var session = ServiceTestSupport.GetValue(service.Login("gm-open-high", "Password123")).SessionToken;
|
||||
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(session, "Rolemaster", "rolemaster"));
|
||||
var character = ServiceTestSupport.GetValue(service.CreateCharacter(session, "Hero", campaign.Id));
|
||||
var skill = ServiceTestSupport.GetValue(service.CreateSkill(session, character.Id, "Awareness", "d100!+85", 0, false, null, 5));
|
||||
|
||||
var roll = ServiceTestSupport.GetValue(service.RollSkill(session, skill.Id, "public"));
|
||||
var detail = ServiceTestSupport.GetValue(service.GetRollDetail(session, roll.RollId));
|
||||
var logPage = ServiceTestSupport.GetValue(service.GetCampaignLogPage(session, campaign.Id, limit: 5));
|
||||
|
||||
Assert.Equal(323, roll.Result);
|
||||
Assert.Equal("97+96+45+85=323", roll.Breakdown);
|
||||
Assert.Equal("97 + 96 + 45 | open-ended high", Assert.Single(logPage.Entries).SummaryText);
|
||||
Assert.Null(Assert.Single(logPage.Entries).EventBadges);
|
||||
Assert.Equal(roll.Breakdown, detail.Breakdown);
|
||||
Assert.Collection(detail.Dice, die =>
|
||||
{
|
||||
Assert.Equal(97, die.Roll);
|
||||
Assert.Equal(1, die.Sequence);
|
||||
Assert.Equal(RollDieKinds.RolemasterOpenEndedInitial, die.Kind);
|
||||
Assert.Equal(97, die.SignedContribution);
|
||||
Assert.False(die.Added);
|
||||
}, die =>
|
||||
{
|
||||
Assert.Equal(96, die.Roll);
|
||||
Assert.Equal(2, die.Sequence);
|
||||
Assert.Equal(RollDieKinds.RolemasterOpenEndedHigh, die.Kind);
|
||||
Assert.Equal(96, die.SignedContribution);
|
||||
Assert.True(die.Added);
|
||||
}, die =>
|
||||
{
|
||||
Assert.Equal(45, die.Roll);
|
||||
Assert.Equal(3, die.Sequence);
|
||||
Assert.Equal(RollDieKinds.RolemasterOpenEndedHigh, die.Kind);
|
||||
Assert.Equal(45, die.SignedContribution);
|
||||
Assert.True(die.Added);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RollSkill_RolemasterOpenEndedLow_SubtractsRecursiveHighChain()
|
||||
{
|
||||
using var harness = ServiceTestSupport.CreateHarness(5, 97, 100, 12);
|
||||
var service = harness.Service;
|
||||
|
||||
service.Register("gm-open-low", "Password123", "GM");
|
||||
var session = ServiceTestSupport.GetValue(service.Login("gm-open-low", "Password123")).SessionToken;
|
||||
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(session, "Rolemaster", "rolemaster"));
|
||||
var character = ServiceTestSupport.GetValue(service.CreateCharacter(session, "Hero", campaign.Id));
|
||||
var skill = ServiceTestSupport.GetValue(service.CreateSkill(session, character.Id, "Awareness", "d100!+85", 0, false, null, 5));
|
||||
|
||||
var roll = ServiceTestSupport.GetValue(service.RollSkill(session, skill.Id, "public"));
|
||||
var logPage = ServiceTestSupport.GetValue(service.GetCampaignLogPage(session, campaign.Id, limit: 5));
|
||||
|
||||
Assert.Equal(-124, roll.Result);
|
||||
Assert.Equal("(05) -97 -100 -12 +85 = -124", roll.Breakdown);
|
||||
var logEntry = Assert.Single(logPage.Entries);
|
||||
Assert.Equal("(05) -97 -100 -12 | open-ended low", logEntry.SummaryText);
|
||||
var lowEventBadges = Assert.IsType<string[]>(logEntry.EventBadges);
|
||||
Assert.Collection(lowEventBadges, badge => Assert.Equal("rf", badge), badge => Assert.Equal("r100", badge));
|
||||
Assert.Collection(roll.Dice, die =>
|
||||
{
|
||||
Assert.Equal(5, die.Roll);
|
||||
Assert.Equal(1, die.Sequence);
|
||||
Assert.Equal(RollDieKinds.RolemasterOpenEndedInitial, die.Kind);
|
||||
Assert.Null(die.SignedContribution);
|
||||
}, die =>
|
||||
{
|
||||
Assert.Equal(97, die.Roll);
|
||||
Assert.Equal(2, die.Sequence);
|
||||
Assert.Equal(RollDieKinds.RolemasterOpenEndedLowSubtract, die.Kind);
|
||||
Assert.Equal(-97, die.SignedContribution);
|
||||
}, die =>
|
||||
{
|
||||
Assert.Equal(100, die.Roll);
|
||||
Assert.Equal(3, die.Sequence);
|
||||
Assert.Equal(RollDieKinds.RolemasterOpenEndedLowSubtract, die.Kind);
|
||||
Assert.Equal(-100, die.SignedContribution);
|
||||
}, die =>
|
||||
{
|
||||
Assert.Equal(12, die.Roll);
|
||||
Assert.Equal(4, die.Sequence);
|
||||
Assert.Equal(RollDieKinds.RolemasterOpenEndedLowSubtract, die.Kind);
|
||||
Assert.Equal(-12, die.SignedContribution);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RollSkill_RolemasterSixtySix_AddsRareBadgeToLogSummary()
|
||||
{
|
||||
using var harness = ServiceTestSupport.CreateHarness(66);
|
||||
var service = harness.Service;
|
||||
|
||||
service.Register("gm-sixty-six", "Password123", "GM");
|
||||
var session = ServiceTestSupport.GetValue(service.Login("gm-sixty-six", "Password123")).SessionToken;
|
||||
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(session, "Rolemaster", "rolemaster"));
|
||||
var character = ServiceTestSupport.GetValue(service.CreateCharacter(session, "Hero", campaign.Id));
|
||||
var skill = ServiceTestSupport.GetValue(service.CreateSkill(session, character.Id, "Perception", "d100-15", 0, false));
|
||||
|
||||
_ = ServiceTestSupport.GetValue(service.RollSkill(session, skill.Id, "public"));
|
||||
var logEntry = Assert.Single(ServiceTestSupport.GetValue(service.GetCampaignLogPage(session, campaign.Id, limit: 5)).Entries);
|
||||
|
||||
var badge = Assert.Single(Assert.IsType<string[]>(logEntry.EventBadges));
|
||||
Assert.Equal("r66", badge);
|
||||
Assert.Equal("66 | rolemaster", logEntry.SummaryText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RollSkill_RolemasterAutoRetryPlusFive_UsesRetryResultAndMarksAttempts()
|
||||
{
|
||||
using var harness = ServiceTestSupport.CreateHarness(66, 42);
|
||||
var service = harness.Service;
|
||||
|
||||
service.Register("gm-retry-five", "Password123", "GM");
|
||||
var session = ServiceTestSupport.GetValue(service.Login("gm-retry-five", "Password123")).SessionToken;
|
||||
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(session, "Rolemaster Retry", "rolemaster"));
|
||||
var character = ServiceTestSupport.GetValue(service.CreateCharacter(session, "Hero", campaign.Id));
|
||||
var skill = ServiceTestSupport.GetValue(service.CreateSkill(session, character.Id, "Awareness", "d100!+10", 0, false, null, 5, true));
|
||||
|
||||
var roll = ServiceTestSupport.GetValue(service.RollSkill(session, skill.Id, "public"));
|
||||
var detail = ServiceTestSupport.GetValue(service.GetRollDetail(session, roll.RollId));
|
||||
var logEntry = Assert.Single(ServiceTestSupport.GetValue(service.GetCampaignLogPage(session, campaign.Id, limit: 5)).Entries);
|
||||
|
||||
Assert.Equal(57, roll.Result);
|
||||
Assert.Equal("66+10=76; retry(+5): 42+10=52; final=57", roll.Breakdown);
|
||||
Assert.Equal("66 | open-ended | retry +5", logEntry.SummaryText);
|
||||
Assert.Equal(["r66", "rs5"], Assert.IsType<string[]>(logEntry.EventBadges));
|
||||
Assert.Equal(roll.Breakdown, detail.Breakdown);
|
||||
Assert.Collection(detail.Dice, die =>
|
||||
{
|
||||
Assert.Equal(66, die.Roll);
|
||||
Assert.Equal(1, die.Sequence);
|
||||
Assert.Equal(1, die.Attempt);
|
||||
Assert.Equal(RollDieKinds.RolemasterOpenEndedInitial, die.Kind);
|
||||
Assert.Equal(66, die.SignedContribution);
|
||||
}, die =>
|
||||
{
|
||||
Assert.Equal(42, die.Roll);
|
||||
Assert.Equal(1, die.Sequence);
|
||||
Assert.Equal(2, die.Attempt);
|
||||
Assert.Equal(RollDieKinds.RolemasterOpenEndedInitial, die.Kind);
|
||||
Assert.Equal(42, die.SignedContribution);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RollSkill_RolemasterAutoRetry_UsesSituationalModifierInBothAttempts()
|
||||
{
|
||||
using var harness = ServiceTestSupport.CreateHarness(8, 42);
|
||||
var service = harness.Service;
|
||||
|
||||
service.Register("gm-retry-situational", "Password123", "GM");
|
||||
var session = ServiceTestSupport.GetValue(service.Login("gm-retry-situational", "Password123")).SessionToken;
|
||||
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(session, "Rolemaster Retry", "rolemaster"));
|
||||
var character = ServiceTestSupport.GetValue(service.CreateCharacter(session, "Hero", campaign.Id));
|
||||
var skill = ServiceTestSupport.GetValue(service.CreateSkill(session, character.Id, "Observation", "d100!+50", 0, false, null, 5, true));
|
||||
|
||||
var roll = ServiceTestSupport.GetValue(service.RollSkill(session, skill.Id, "public", 20));
|
||||
var logEntry = Assert.Single(ServiceTestSupport.GetValue(service.GetCampaignLogPage(session, campaign.Id, limit: 5)).Entries);
|
||||
|
||||
Assert.Equal(117, roll.Result);
|
||||
Assert.Equal("8+50+20=78; retry(+5): 42+50+20=112; final=117", roll.Breakdown);
|
||||
Assert.Equal("8 | open-ended | retry +5", logEntry.SummaryText);
|
||||
Assert.Equal(["rs5"], Assert.IsType<string[]>(logEntry.EventBadges));
|
||||
Assert.All(roll.Dice, die => Assert.True(die.Attempt is 1 or 2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RollSkill_RolemasterAutoRetryPlusTen_UsesRetryResultAndMarksAttempts()
|
||||
{
|
||||
using var harness = ServiceTestSupport.CreateHarness(90, 32);
|
||||
var service = harness.Service;
|
||||
|
||||
service.Register("gm-retry-ten", "Password123", "GM");
|
||||
var session = ServiceTestSupport.GetValue(service.Login("gm-retry-ten", "Password123")).SessionToken;
|
||||
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(session, "Rolemaster Retry", "rolemaster"));
|
||||
var character = ServiceTestSupport.GetValue(service.CreateCharacter(session, "Hero", campaign.Id));
|
||||
var skill = ServiceTestSupport.GetValue(service.CreateSkill(session, character.Id, "Awareness", "d100!+1", 0, false, null, 5, true));
|
||||
|
||||
var roll = ServiceTestSupport.GetValue(service.RollSkill(session, skill.Id, "public"));
|
||||
var logEntry = Assert.Single(ServiceTestSupport.GetValue(service.GetCampaignLogPage(session, campaign.Id, limit: 5)).Entries);
|
||||
|
||||
Assert.Equal(43, roll.Result);
|
||||
Assert.Equal("90+1=91; retry(+10): 32+1=33; final=43", roll.Breakdown);
|
||||
Assert.Equal("90 | open-ended | retry +10", logEntry.SummaryText);
|
||||
Assert.Equal(["rs10"], Assert.IsType<string[]>(logEntry.EventBadges));
|
||||
Assert.All(roll.Dice, die => Assert.True(die.Attempt is 1 or 2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RollSkill_RolemasterAutoRetryDisabled_KeepsOriginalResult()
|
||||
{
|
||||
using var harness = ServiceTestSupport.CreateHarness(65);
|
||||
var service = harness.Service;
|
||||
|
||||
service.Register("gm-retry-off", "Password123", "GM");
|
||||
var session = ServiceTestSupport.GetValue(service.Login("gm-retry-off", "Password123")).SessionToken;
|
||||
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(session, "Rolemaster Retry", "rolemaster"));
|
||||
var character = ServiceTestSupport.GetValue(service.CreateCharacter(session, "Hero", campaign.Id));
|
||||
var skill = ServiceTestSupport.GetValue(service.CreateSkill(session, character.Id, "Awareness", "d100!+10", 0, false, null, 5));
|
||||
|
||||
var roll = ServiceTestSupport.GetValue(service.RollSkill(session, skill.Id, "public"));
|
||||
var logEntry = Assert.Single(ServiceTestSupport.GetValue(service.GetCampaignLogPage(session, campaign.Id, limit: 5)).Entries);
|
||||
|
||||
Assert.Equal(75, roll.Result);
|
||||
Assert.Equal("65+10=75", roll.Breakdown);
|
||||
Assert.Equal("65 | open-ended", logEntry.SummaryText);
|
||||
Assert.Null(logEntry.EventBadges);
|
||||
Assert.All(roll.Dice, die => Assert.Null(die.Attempt));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RollSkill_SituationalModifier_IsRejectedForNonRolemasterCampaigns()
|
||||
{
|
||||
using var harness = ServiceTestSupport.CreateHarness(12);
|
||||
var service = harness.Service;
|
||||
|
||||
service.Register("gm-non-rolemaster-modifier", "Password123", "GM");
|
||||
var session = ServiceTestSupport.GetValue(service.Login("gm-non-rolemaster-modifier", "Password123")).SessionToken;
|
||||
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(session, "DnD", "dnd5e"));
|
||||
var character = ServiceTestSupport.GetValue(service.CreateCharacter(session, "Hero", campaign.Id));
|
||||
var skill = ServiceTestSupport.GetValue(service.CreateSkill(session, character.Id, "Attack", "1d20+5", 0, false));
|
||||
|
||||
var roll = service.RollSkill(session, skill.Id, "public", 20);
|
||||
|
||||
Assert.False(roll.Succeeded);
|
||||
Assert.Equal("invalid_situational_modifier", roll.Error!.Code);
|
||||
}
|
||||
}
|
||||
67
RpgRoller.Tests/Services/ServiceRollHelperTests.cs
Normal file
67
RpgRoller.Tests/Services/ServiceRollHelperTests.cs
Normal file
@@ -0,0 +1,67 @@
|
||||
namespace RpgRoller.Tests;
|
||||
|
||||
public sealed class ServiceRollHelperTests
|
||||
{
|
||||
private sealed class FixedDiceRoller(IEnumerable<int> values) : IDiceRoller
|
||||
{
|
||||
public int Roll(int sides)
|
||||
{
|
||||
var next = m_Values.Count > 0 ? m_Values.Dequeue() : 1;
|
||||
return Math.Clamp(next, 1, sides);
|
||||
}
|
||||
|
||||
private readonly Queue<int> m_Values = new(values);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RollBreakdownFormatter_FormatsStandardAndRolemasterOpenEndedBreakdowns()
|
||||
{
|
||||
Assert.Equal("4+5+2=11", RollBreakdownFormatter.BuildBreakdown([4, 5], 2, 11));
|
||||
Assert.Equal("0=0", RollBreakdownFormatter.BuildBreakdown([], 0, 0));
|
||||
Assert.Equal("4+5+2+7=18", RollBreakdownFormatter.BuildRolemasterModifierBreakdown([4, 5], 2, 7, 18));
|
||||
Assert.Equal("97+96+45+85=323", RollBreakdownFormatter.BuildRolemasterOpenEndedBreakdown(97, [96, 45], false, 85, 323));
|
||||
Assert.Equal("8+50+20=78", RollBreakdownFormatter.BuildRolemasterOpenEndedBreakdown(8, [], false, 50, 78, 20));
|
||||
Assert.Equal("(05) -97 -100 -12 +85 = -124", RollBreakdownFormatter.BuildRolemasterOpenEndedBreakdown(5, [97, 100, 12], true, 85, -124));
|
||||
Assert.Equal("(05) -97 -100 -12 +85 +20 = -104", RollBreakdownFormatter.BuildRolemasterOpenEndedBreakdown(5, [97, 100, 12], true, 85, -104, 20));
|
||||
Assert.Equal("66+10=76; retry(+5): 42+10=52; final=57", RollBreakdownFormatter.BuildRolemasterRetryBreakdown("66+10=76", 5, "42+10=52", 57));
|
||||
Assert.Equal("05", RollBreakdownFormatter.FormatRolemasterTriggerRoll(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CampaignLogSummaryBuilder_ExtractsExpressionsAndBuildsBadgesAndSummaries()
|
||||
{
|
||||
var d6Dice = new[] { new RollDieResult(6, true, false, true, false, false), new RollDieResult(1, false, true, true, false, false) };
|
||||
var rolemasterDice = new[] { new RollDieResult(5, false, false, false, false, false, 1, RollDieKinds.RolemasterOpenEndedInitial), new RollDieResult(97, false, false, false, false, false, 2, RollDieKinds.RolemasterOpenEndedLowSubtract, -97), new RollDieResult(100, false, false, false, false, false, 3, RollDieKinds.RolemasterOpenEndedLowSubtract, -100) };
|
||||
var retryDice = new[] { new RollDieResult(66, false, false, false, false, false, 1, RollDieKinds.RolemasterOpenEndedInitial, 66, 1), new RollDieResult(42, false, false, false, false, false, 1, RollDieKinds.RolemasterOpenEndedInitial, 42, 2) };
|
||||
const string retryBreakdown = "66+10=76; retry(+5): 42+10=52; final=57";
|
||||
|
||||
Assert.Equal("1d20+5", CampaignLogSummaryBuilder.ExtractCustomRollExpression("1d20+5 => 20+5=25", " => "));
|
||||
Assert.Null(CampaignLogSummaryBuilder.ExtractCustomRollExpression("20+5=25", " => "));
|
||||
Assert.Equal("6, 1", CampaignLogSummaryBuilder.BuildCompactLogSummary(d6Dice));
|
||||
Assert.Equal("(05) -97 -100 | open-ended low", CampaignLogSummaryBuilder.BuildCompactLogSummary(rolemasterDice));
|
||||
Assert.Equal("66 | open-ended | retry +5", CampaignLogSummaryBuilder.BuildCompactLogSummary(retryDice, retryBreakdown));
|
||||
Assert.Equal("No detail available.", CampaignLogSummaryBuilder.BuildCompactLogSummary([]));
|
||||
Assert.Equal(["w6", "w1"], Assert.IsType<string[]>(CampaignLogSummaryBuilder.BuildCompactLogEventBadges(RulesetKind.D6, null, d6Dice)));
|
||||
Assert.Equal(["n20"], Assert.IsType<string[]>(CampaignLogSummaryBuilder.BuildCompactLogEventBadges(RulesetKind.Dnd5e, "1d20+5", [new(20, false, false, false, false, false)])));
|
||||
Assert.Equal(["rf", "r100"], Assert.IsType<string[]>(CampaignLogSummaryBuilder.BuildCompactLogEventBadges(RulesetKind.Rolemaster, "d100!+85", rolemasterDice)));
|
||||
Assert.Equal(["r66", "rs5"], Assert.IsType<string[]>(CampaignLogSummaryBuilder.BuildCompactLogEventBadges(RulesetKind.Rolemaster, "d100!+10", retryDice, retryBreakdown)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RollEngine_DelegatesToRulesetSpecificEngines()
|
||||
{
|
||||
var engine = new RollEngine(new(new FixedDiceRoller([7, 10])), new(new FixedDiceRoller([6, 4, 2])), new(new FixedDiceRoller([97, 96, 45])));
|
||||
|
||||
var d6Roll = engine.Roll(RulesetKind.D6, new(2, 6, 1, "2D+1"), 1, true, null);
|
||||
Assert.Equal(13, d6Roll.Total);
|
||||
Assert.Equal("6+4+2+1=13", d6Roll.Breakdown);
|
||||
|
||||
var standardRoll = engine.Roll(RulesetKind.Dnd5e, new(2, 10, 3, "2d10+3"), 0, false, null);
|
||||
Assert.Equal(20, standardRoll.Total);
|
||||
Assert.Equal("7+10+3=20", standardRoll.Breakdown);
|
||||
|
||||
var rolemasterRoll = engine.Roll(RulesetKind.Rolemaster, new(1, 100, 85, "d100!+85", DiceExpressionKind.RolemasterOpenEndedPercentile), 0, false, 5);
|
||||
Assert.Equal(323, rolemasterRoll.Total);
|
||||
Assert.Equal("97+96+45+85=323", rolemasterRoll.Breakdown);
|
||||
}
|
||||
}
|
||||
385
RpgRoller.Tests/Services/ServiceSharedHelperTests.cs
Normal file
385
RpgRoller.Tests/Services/ServiceSharedHelperTests.cs
Normal file
@@ -0,0 +1,385 @@
|
||||
namespace RpgRoller.Tests;
|
||||
|
||||
public sealed class ServiceSharedHelperTests
|
||||
{
|
||||
[Fact]
|
||||
public void GameStateStore_TracksCampaignSlicesAndCharacterVersions()
|
||||
{
|
||||
var campaignId = Guid.NewGuid();
|
||||
var characterId = Guid.NewGuid();
|
||||
var store = new GameStateStore();
|
||||
|
||||
store.CampaignsById[campaignId] = new()
|
||||
{
|
||||
Id = campaignId,
|
||||
GmUserId = Guid.NewGuid(),
|
||||
Name = "Alpha",
|
||||
Ruleset = RulesetKind.D6,
|
||||
Version = 1
|
||||
};
|
||||
store.CharactersById[characterId] = new()
|
||||
{
|
||||
Id = characterId,
|
||||
OwnerUserId = Guid.NewGuid(),
|
||||
CampaignId = campaignId,
|
||||
Name = "Scout"
|
||||
};
|
||||
|
||||
store.RebuildCampaignStateLocked();
|
||||
|
||||
var initialState = store.GetOrCreateCampaignStateLocked(campaignId);
|
||||
Assert.Equal(1, initialState.CharacterVersions[characterId]);
|
||||
|
||||
store.TouchRosterLocked(campaignId);
|
||||
store.TouchCharacterLocked(campaignId, characterId);
|
||||
store.TouchLogLocked(campaignId);
|
||||
|
||||
Assert.Equal(4, initialState.TotalVersion);
|
||||
Assert.Equal(2, initialState.RosterVersion);
|
||||
Assert.Equal(2, initialState.LogVersion);
|
||||
Assert.Equal(2, initialState.CharacterVersions[characterId]);
|
||||
|
||||
store.RemoveCharacterStateLocked(campaignId, characterId);
|
||||
Assert.Empty(initialState.CharacterVersions);
|
||||
|
||||
store.AddCharacterStateLocked(campaignId, characterId);
|
||||
Assert.Equal(1, initialState.CharacterVersions[characterId]);
|
||||
|
||||
store.TouchRosterLocked(null);
|
||||
store.TouchCharacterLocked(Guid.NewGuid(), Guid.NewGuid());
|
||||
store.TouchLogLocked(Guid.NewGuid());
|
||||
store.RemoveCharacterStateLocked(Guid.NewGuid(), Guid.NewGuid());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GameAuthorization_CoversCampaignAndRollVisibility()
|
||||
{
|
||||
var adminId = Guid.NewGuid();
|
||||
var gmId = Guid.NewGuid();
|
||||
var playerId = Guid.NewGuid();
|
||||
var outsiderId = Guid.NewGuid();
|
||||
var campaignId = Guid.NewGuid();
|
||||
var store = new GameStateStore();
|
||||
|
||||
store.UsersById[adminId] = new()
|
||||
{
|
||||
Id = adminId,
|
||||
Username = "admin",
|
||||
UsernameNormalized = "ADMIN",
|
||||
PasswordHash = "hash",
|
||||
DisplayName = "Admin",
|
||||
Roles = UserRoles.Admin
|
||||
};
|
||||
store.UsersById[gmId] = new()
|
||||
{
|
||||
Id = gmId,
|
||||
Username = "gm",
|
||||
UsernameNormalized = "GM",
|
||||
PasswordHash = "hash",
|
||||
DisplayName = "GM",
|
||||
Roles = string.Empty
|
||||
};
|
||||
store.UsersById[playerId] = new()
|
||||
{
|
||||
Id = playerId,
|
||||
Username = "player",
|
||||
UsernameNormalized = "PLAYER",
|
||||
PasswordHash = "hash",
|
||||
DisplayName = "Player",
|
||||
Roles = string.Empty
|
||||
};
|
||||
store.UsersById[outsiderId] = new()
|
||||
{
|
||||
Id = outsiderId,
|
||||
Username = "outsider",
|
||||
UsernameNormalized = "OUTSIDER",
|
||||
PasswordHash = "hash",
|
||||
DisplayName = "Outsider",
|
||||
Roles = string.Empty
|
||||
};
|
||||
|
||||
var campaign = new Campaign
|
||||
{
|
||||
Id = campaignId,
|
||||
GmUserId = gmId,
|
||||
Name = "Alpha",
|
||||
Ruleset = RulesetKind.D6,
|
||||
Version = 1
|
||||
};
|
||||
store.CampaignsById[campaignId] = campaign;
|
||||
var playerCharacterId = Guid.NewGuid();
|
||||
store.CharactersById[playerCharacterId] = new()
|
||||
{
|
||||
Id = playerCharacterId,
|
||||
OwnerUserId = playerId,
|
||||
CampaignId = campaignId,
|
||||
Name = "Scout"
|
||||
};
|
||||
|
||||
var publicEntry = new RollLogEntry
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CampaignId = campaignId,
|
||||
CharacterId = Guid.NewGuid(),
|
||||
SkillId = Guid.NewGuid(),
|
||||
RollerUserId = playerId,
|
||||
Visibility = RollVisibility.Public,
|
||||
Result = 12,
|
||||
Breakdown = "6+6=12",
|
||||
Dice = "[]",
|
||||
TimestampUtc = DateTimeOffset.UtcNow
|
||||
};
|
||||
var privateEntry = new RollLogEntry
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CampaignId = publicEntry.CampaignId,
|
||||
CharacterId = publicEntry.CharacterId,
|
||||
SkillId = publicEntry.SkillId,
|
||||
RollerUserId = publicEntry.RollerUserId,
|
||||
Visibility = RollVisibility.Private,
|
||||
Result = publicEntry.Result,
|
||||
Breakdown = publicEntry.Breakdown,
|
||||
Dice = publicEntry.Dice,
|
||||
TimestampUtc = publicEntry.TimestampUtc
|
||||
};
|
||||
|
||||
Assert.True(GameAuthorization.HasRole(store.UsersById[adminId], UserRoles.Admin));
|
||||
Assert.True(GameAuthorization.CanViewCampaign(store, adminId, campaignId));
|
||||
Assert.True(GameAuthorization.CanViewCampaign(store, gmId, campaignId));
|
||||
Assert.True(GameAuthorization.CanViewCampaign(store, playerId, campaignId));
|
||||
Assert.False(GameAuthorization.CanViewCampaign(store, outsiderId, campaignId));
|
||||
|
||||
Assert.True(GameAuthorization.CanEditCharacter(playerId, store.CharactersById.Values.Single(), campaign));
|
||||
Assert.True(GameAuthorization.CanEditCharacter(gmId, store.CharactersById.Values.Single(), campaign));
|
||||
Assert.False(GameAuthorization.CanEditCharacter(outsiderId, store.CharactersById.Values.Single(), campaign));
|
||||
|
||||
Assert.True(GameAuthorization.CanViewRoll(store, gmId, campaign, privateEntry));
|
||||
Assert.True(GameAuthorization.CanViewRoll(store, playerId, campaign, privateEntry));
|
||||
Assert.False(GameAuthorization.CanViewRoll(store, outsiderId, campaign, publicEntry));
|
||||
Assert.False(GameAuthorization.CanViewRoll(store, outsiderId, campaign, privateEntry));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GameContextResolver_HandlesUnauthorizedForbiddenAndSuccessPaths()
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
var otherUserId = Guid.NewGuid();
|
||||
var campaignId = Guid.NewGuid();
|
||||
var store = new GameStateStore();
|
||||
|
||||
store.UsersById[userId] = new()
|
||||
{
|
||||
Id = userId,
|
||||
Username = "user",
|
||||
UsernameNormalized = "USER",
|
||||
PasswordHash = "hash",
|
||||
DisplayName = "User",
|
||||
Roles = string.Empty
|
||||
};
|
||||
store.UsersById[otherUserId] = new()
|
||||
{
|
||||
Id = otherUserId,
|
||||
Username = "other",
|
||||
UsernameNormalized = "OTHER",
|
||||
PasswordHash = "hash",
|
||||
DisplayName = "Other",
|
||||
Roles = string.Empty
|
||||
};
|
||||
store.SessionsByToken["valid"] = new()
|
||||
{
|
||||
Token = "valid",
|
||||
UserId = userId,
|
||||
CreatedAtUtc = DateTimeOffset.UtcNow
|
||||
};
|
||||
store.CampaignsById[campaignId] = new()
|
||||
{
|
||||
Id = campaignId,
|
||||
GmUserId = otherUserId,
|
||||
Name = "Alpha",
|
||||
Ruleset = RulesetKind.D6,
|
||||
Version = 1
|
||||
};
|
||||
var participant = new Character
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OwnerUserId = userId,
|
||||
CampaignId = campaignId,
|
||||
Name = "Scout"
|
||||
};
|
||||
store.CharactersById[participant.Id] = participant;
|
||||
|
||||
Assert.Null(GameContextResolver.ResolveUserLocked(store, string.Empty));
|
||||
Assert.Null(GameContextResolver.ResolveUserLocked(store, "missing"));
|
||||
Assert.Equal(userId, GameContextResolver.ResolveUserLocked(store, "valid")!.Id);
|
||||
|
||||
Assert.Equal("unauthorized", GameContextResolver.ResolveCampaignContextLocked(store, string.Empty, campaignId).Error!.Code);
|
||||
Assert.Equal("campaign_not_found", GameContextResolver.ResolveCampaignContextLocked(store, "valid", Guid.NewGuid()).Error!.Code);
|
||||
|
||||
var forbiddenStore = new GameStateStore();
|
||||
forbiddenStore.UsersById[userId] = store.UsersById[userId];
|
||||
forbiddenStore.SessionsByToken["valid"] = store.SessionsByToken["valid"];
|
||||
forbiddenStore.CampaignsById[campaignId] = store.CampaignsById[campaignId];
|
||||
Assert.Equal("forbidden", GameContextResolver.ResolveCampaignContextLocked(forbiddenStore, "valid", campaignId).Error!.Code);
|
||||
|
||||
var context = ServiceTestSupport.GetValue(GameContextResolver.ResolveCampaignContextLocked(store, "valid", campaignId));
|
||||
Assert.Equal(userId, context.User.Id);
|
||||
Assert.Equal(campaignId, context.Campaign.Id);
|
||||
|
||||
var orphan = new Character
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OwnerUserId = userId,
|
||||
CampaignId = null,
|
||||
Name = "Orphan"
|
||||
};
|
||||
Assert.False(GameContextResolver.TryResolveCharacterCampaignLocked(store, orphan, out _, out var orphanError));
|
||||
Assert.Equal("character_not_in_campaign", orphanError!.Code);
|
||||
|
||||
var missingCampaignCharacter = new Character
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OwnerUserId = orphan.OwnerUserId,
|
||||
CampaignId = Guid.NewGuid(),
|
||||
Name = orphan.Name
|
||||
};
|
||||
Assert.False(GameContextResolver.TryResolveCharacterCampaignLocked(store, missingCampaignCharacter, out _, out var missingCampaignError));
|
||||
Assert.Equal("character_not_in_campaign", missingCampaignError!.Code);
|
||||
|
||||
Assert.True(GameContextResolver.TryResolveCharacterCampaignLocked(store, participant, out var resolvedCampaign, out var noError));
|
||||
Assert.Equal(campaignId, resolvedCampaign.Id);
|
||||
Assert.Null(noError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GameDtoMapper_MapsServiceContractsAndFallbacks()
|
||||
{
|
||||
var gmId = Guid.NewGuid();
|
||||
var ownerId = Guid.NewGuid();
|
||||
var blankOwnerId = Guid.NewGuid();
|
||||
var campaignId = Guid.NewGuid();
|
||||
var characterId = Guid.NewGuid();
|
||||
var skillGroupId = Guid.NewGuid();
|
||||
var skillId = Guid.NewGuid();
|
||||
var rollId = Guid.NewGuid();
|
||||
var store = new GameStateStore();
|
||||
|
||||
store.UsersById[gmId] = new()
|
||||
{
|
||||
Id = gmId,
|
||||
Username = "gm",
|
||||
UsernameNormalized = "GM",
|
||||
PasswordHash = "hash",
|
||||
DisplayName = "GM",
|
||||
Roles = UserRoles.Admin
|
||||
};
|
||||
store.UsersById[ownerId] = new()
|
||||
{
|
||||
Id = ownerId,
|
||||
Username = "owner",
|
||||
UsernameNormalized = "OWNER",
|
||||
PasswordHash = "hash",
|
||||
DisplayName = "Owner",
|
||||
Roles = string.Empty
|
||||
};
|
||||
store.UsersById[blankOwnerId] = new()
|
||||
{
|
||||
Id = blankOwnerId,
|
||||
Username = "blank",
|
||||
UsernameNormalized = "BLANK",
|
||||
PasswordHash = "hash",
|
||||
DisplayName = "",
|
||||
Roles = string.Empty
|
||||
};
|
||||
store.CampaignsById[campaignId] = new()
|
||||
{
|
||||
Id = campaignId,
|
||||
GmUserId = gmId,
|
||||
Name = "Alpha",
|
||||
Ruleset = RulesetKind.Rolemaster,
|
||||
Version = 1
|
||||
};
|
||||
store.CharactersById[characterId] = new()
|
||||
{
|
||||
Id = characterId,
|
||||
OwnerUserId = ownerId,
|
||||
CampaignId = campaignId,
|
||||
Name = "Scout"
|
||||
};
|
||||
store.SkillGroupsById[skillGroupId] = new()
|
||||
{
|
||||
Id = skillGroupId,
|
||||
CharacterId = characterId,
|
||||
Name = "Awareness",
|
||||
DiceRollDefinition = "d100!+15",
|
||||
WildDice = 0,
|
||||
AllowFumble = false,
|
||||
FumbleRange = 5
|
||||
};
|
||||
store.SkillsById[skillId] = new()
|
||||
{
|
||||
Id = skillId,
|
||||
CharacterId = characterId,
|
||||
SkillGroupId = skillGroupId,
|
||||
Name = "Perception",
|
||||
DiceRollDefinition = "d100!+25",
|
||||
WildDice = 0,
|
||||
AllowFumble = false,
|
||||
FumbleRange = 3,
|
||||
RolemasterAutoRetry = true
|
||||
};
|
||||
store.RebuildCampaignStateLocked();
|
||||
store.TouchRosterLocked(campaignId);
|
||||
store.TouchCharacterLocked(campaignId, characterId);
|
||||
store.TouchLogLocked(campaignId);
|
||||
|
||||
var dice = new[] { new RollDieResult(66, false, false, false, false, false, 1, RollDieKinds.RolemasterStandard, 66) };
|
||||
var logEntry = new RollLogEntry
|
||||
{
|
||||
Id = rollId,
|
||||
CampaignId = campaignId,
|
||||
CharacterId = characterId,
|
||||
SkillId = skillId,
|
||||
RollerUserId = ownerId,
|
||||
Visibility = RollVisibility.Private,
|
||||
Result = 91,
|
||||
Breakdown = "66+25=91",
|
||||
Dice = "[]",
|
||||
TimestampUtc = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
var userSummary = GameDtoMapper.ToUserSummary(store.UsersById[gmId]);
|
||||
var adminSummary = GameDtoMapper.ToAdminUserSummary(store.UsersById[gmId]);
|
||||
var campaignOption = GameDtoMapper.ToCampaignOption(store.CampaignsById[campaignId]);
|
||||
var campaignSummary = GameDtoMapper.ToCampaignSummary(store, store.CampaignsById[campaignId]);
|
||||
var campaignRoster = GameDtoMapper.ToCampaignRoster(store, store.CampaignsById[campaignId]);
|
||||
var characterSummary = GameDtoMapper.ToCharacterSummary(store, store.CharactersById[characterId]);
|
||||
var sheet = GameDtoMapper.ToCharacterSheet(store, characterId);
|
||||
var groupSummary = GameDtoMapper.ToSkillGroupSummary(store.SkillGroupsById[skillGroupId]);
|
||||
var skillSummary = GameDtoMapper.ToSkillSummary(store.SkillsById[skillId]);
|
||||
var rollResult = GameDtoMapper.ToRollResult(logEntry, dice);
|
||||
var logDto = GameDtoMapper.ToCampaignLogEntry(logEntry, "Scout", "Perception", "Owner", dice);
|
||||
var logListDto = GameDtoMapper.ToCampaignLogListEntry(logEntry, "Scout", "Perception", "You", "Private (you)", "private-self", "66 | rolemaster", ["r66"]);
|
||||
var detail = GameDtoMapper.ToCampaignRollDetail(logEntry, dice);
|
||||
var snapshot = GameDtoMapper.ToCampaignStateSnapshot(store, campaignId);
|
||||
|
||||
Assert.Contains(UserRoles.Admin, userSummary.Roles);
|
||||
Assert.Contains(UserRoles.Admin, adminSummary.Roles);
|
||||
Assert.Equal("Alpha", campaignOption.Name);
|
||||
Assert.Equal("rolemaster", campaignSummary.RulesetId);
|
||||
Assert.Single(campaignRoster.Characters);
|
||||
Assert.Equal("Owner", characterSummary.OwnerDisplayName);
|
||||
Assert.Single(sheet.SkillGroups);
|
||||
Assert.Single(sheet.Skills);
|
||||
Assert.Equal(5, groupSummary.FumbleRange);
|
||||
Assert.Equal(3, skillSummary.FumbleRange);
|
||||
Assert.True(skillSummary.RolemasterAutoRetry);
|
||||
Assert.Equal("private", rollResult.Visibility);
|
||||
Assert.Equal("Owner", logDto.RollerDisplayName);
|
||||
Assert.Equal("private-self", logListDto.VisibilityStyle);
|
||||
Assert.Equal(logEntry.Breakdown, detail.Breakdown);
|
||||
Assert.Equal(campaignId, snapshot.CampaignId);
|
||||
Assert.Single(snapshot.CharacterVersions);
|
||||
Assert.Equal("fallback", GameDtoMapper.ResolveOwnerDisplayName(store, blankOwnerId, "fallback"));
|
||||
Assert.Equal("fallback", GameDtoMapper.ResolveOwnerDisplayName(store, Guid.NewGuid(), "fallback"));
|
||||
}
|
||||
}
|
||||
235
RpgRoller.Tests/Services/ServiceSkillGroupAndOwnershipTests.cs
Normal file
235
RpgRoller.Tests/Services/ServiceSkillGroupAndOwnershipTests.cs
Normal file
@@ -0,0 +1,235 @@
|
||||
namespace RpgRoller.Tests;
|
||||
|
||||
public sealed class ServiceSkillGroupAndOwnershipTests
|
||||
{
|
||||
[Fact]
|
||||
public void SkillGroups_CanBeManagedAndAssignedWithinCharacterScope()
|
||||
{
|
||||
using var harness = ServiceTestSupport.CreateHarness(4, 3, 2);
|
||||
var service = harness.Service;
|
||||
|
||||
service.Register("gm", "Password123", "GM");
|
||||
service.Register("owner", "Password123", "Owner");
|
||||
service.Register("other", "Password123", "Other");
|
||||
|
||||
var gmSession = ServiceTestSupport.GetValue(service.Login("gm", "Password123")).SessionToken;
|
||||
var ownerSession = ServiceTestSupport.GetValue(service.Login("owner", "Password123")).SessionToken;
|
||||
var otherSession = ServiceTestSupport.GetValue(service.Login("other", "Password123")).SessionToken;
|
||||
|
||||
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Main", "d6"));
|
||||
var ownerCharacter = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Owner Char", campaign.Id));
|
||||
var otherCharacter = ServiceTestSupport.GetValue(service.CreateCharacter(otherSession, "Other Char", campaign.Id));
|
||||
|
||||
var ownerGroup = ServiceTestSupport.GetValue(service.CreateSkillGroup(ownerSession, ownerCharacter.Id, "Combat", "2D+1", 1, true));
|
||||
Assert.False(service.CreateSkillGroup(ownerSession, otherCharacter.Id, "Not Allowed", "2D+1", 1, true).Succeeded);
|
||||
|
||||
Assert.False(service.UpdateSkillGroup(otherSession, ownerGroup.Id, "Renamed by Other", "2D+1", 1, true).Succeeded);
|
||||
var renamedGroup = ServiceTestSupport.GetValue(service.UpdateSkillGroup(gmSession, ownerGroup.Id, "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 skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, ownerCharacter.Id, "Strike", "2D+1", 1, true, renamedGroup.Id));
|
||||
Assert.Equal(renamedGroup.Id, skill.SkillGroupId);
|
||||
|
||||
var otherGroup = ServiceTestSupport.GetValue(service.CreateSkillGroup(otherSession, otherCharacter.Id, "Other Group", "2D+1", 1, true));
|
||||
Assert.False(service.UpdateSkill(ownerSession, skill.Id, "Strike", "2D+1", 1, true, otherGroup.Id).Succeeded);
|
||||
|
||||
var ungroupedSkill = ServiceTestSupport.GetValue(service.UpdateSkill(ownerSession, skill.Id, "Strike", "2D+1", 1, true));
|
||||
Assert.Null(ungroupedSkill.SkillGroupId);
|
||||
|
||||
var regroupedSkill = ServiceTestSupport.GetValue(service.UpdateSkill(ownerSession, skill.Id, "Strike", "2D+1", 1, true, renamedGroup.Id));
|
||||
Assert.Equal(renamedGroup.Id, regroupedSkill.SkillGroupId);
|
||||
|
||||
var deletedGroup = ServiceTestSupport.GetValue(service.DeleteSkillGroup(ownerSession, renamedGroup.Id));
|
||||
Assert.True(deletedGroup);
|
||||
|
||||
var ownerSheetAfterGroupDelete = ServiceTestSupport.GetValue(service.GetCharacterSheet(ownerSession, ownerCharacter.Id));
|
||||
var otherSheetAfterGroupDelete = ServiceTestSupport.GetValue(service.GetCharacterSheet(ownerSession, otherCharacter.Id));
|
||||
Assert.DoesNotContain(ownerSheetAfterGroupDelete.SkillGroups, group => group.Id == renamedGroup.Id);
|
||||
Assert.Contains(otherSheetAfterGroupDelete.SkillGroups, group => group.Id == otherGroup.Id);
|
||||
Assert.Null(ownerSheetAfterGroupDelete.Skills.Single(s => s.Id == regroupedSkill.Id).SkillGroupId);
|
||||
|
||||
var deletedSkill = ServiceTestSupport.GetValue(service.DeleteSkill(ownerSession, regroupedSkill.Id));
|
||||
Assert.True(deletedSkill);
|
||||
|
||||
var ownerView = ServiceTestSupport.GetValue(service.GetCharacterSheet(ownerSession, ownerCharacter.Id));
|
||||
var otherView = ServiceTestSupport.GetValue(service.GetCharacterSheet(ownerSession, otherCharacter.Id));
|
||||
Assert.DoesNotContain(ownerView.SkillGroups, group => group.Id == renamedGroup.Id);
|
||||
Assert.Contains(otherView.SkillGroups, group => group.Id == otherGroup.Id);
|
||||
Assert.DoesNotContain(ownerView.Skills, skillSummary => skillSummary.Id == regroupedSkill.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CharacterOwnerTransfer_RequiresGmPrivileges()
|
||||
{
|
||||
using var harness = ServiceTestSupport.CreateHarness();
|
||||
var service = harness.Service;
|
||||
|
||||
service.Register("gm", "Password123", "GM");
|
||||
service.Register("owner", "Password123", "Owner");
|
||||
service.Register("receiver", "Password123", "Receiver");
|
||||
|
||||
var gmSession = ServiceTestSupport.GetValue(service.Login("gm", "Password123")).SessionToken;
|
||||
var ownerSession = ServiceTestSupport.GetValue(service.Login("owner", "Password123")).SessionToken;
|
||||
var receiverSession = ServiceTestSupport.GetValue(service.Login("receiver", "Password123")).SessionToken;
|
||||
|
||||
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Main", "d6"));
|
||||
var character = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Transfer Me", campaign.Id));
|
||||
Assert.True(service.ActivateCharacter(ownerSession, character.Id).Succeeded);
|
||||
|
||||
var ownerTransferAttempt = service.UpdateCharacter(ownerSession, character.Id, "Transfer Me", campaign.Id, "receiver");
|
||||
Assert.False(ownerTransferAttempt.Succeeded);
|
||||
|
||||
var missingOwnerAttempt = service.UpdateCharacter(gmSession, character.Id, "Transfer Me", campaign.Id, "missing-user");
|
||||
Assert.False(missingOwnerAttempt.Succeeded);
|
||||
|
||||
var gmTransfer = ServiceTestSupport.GetValue(service.UpdateCharacter(gmSession, character.Id, "Transferred", campaign.Id, "receiver"));
|
||||
var receiver = service.GetUserBySession(receiverSession);
|
||||
Assert.NotNull(receiver);
|
||||
Assert.Equal(receiver.Id, gmTransfer.OwnerUserId);
|
||||
|
||||
var previousOwnerMe = ServiceTestSupport.GetValue(service.GetMe(ownerSession));
|
||||
Assert.Null(previousOwnerMe.ActiveCharacterId);
|
||||
|
||||
Assert.False(service.ActivateCharacter(ownerSession, character.Id).Succeeded);
|
||||
Assert.True(service.ActivateCharacter(receiverSession, character.Id).Succeeded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CharacterUnlink_AllowsOwnerGmAndAdmin()
|
||||
{
|
||||
using var harness = ServiceTestSupport.CreateHarness();
|
||||
var service = harness.Service;
|
||||
|
||||
service.Register("gm", "Password123", "GM");
|
||||
service.Register("owner", "Password123", "Owner");
|
||||
service.Register("outsider", "Password123", "Outsider");
|
||||
service.Register("admin2", "Password123", "Admin Two");
|
||||
|
||||
var gmSession = ServiceTestSupport.GetValue(service.Login("gm", "Password123")).SessionToken;
|
||||
var ownerSession = ServiceTestSupport.GetValue(service.Login("owner", "Password123")).SessionToken;
|
||||
var outsiderSession = ServiceTestSupport.GetValue(service.Login("outsider", "Password123")).SessionToken;
|
||||
var adminTwoSession = ServiceTestSupport.GetValue(service.Login("admin2", "Password123")).SessionToken;
|
||||
|
||||
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Main", "d6"));
|
||||
var character = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Unlink Me", campaign.Id));
|
||||
|
||||
var outsiderUnlink = service.UpdateCharacter(outsiderSession, character.Id, "Unlink Me", null);
|
||||
Assert.False(outsiderUnlink.Succeeded);
|
||||
|
||||
var ownerUnlink = ServiceTestSupport.GetValue(service.UpdateCharacter(ownerSession, character.Id, "Owner Unlink", null));
|
||||
Assert.Null(ownerUnlink.CampaignId);
|
||||
|
||||
var relinkByOwner = ServiceTestSupport.GetValue(service.UpdateCharacter(ownerSession, character.Id, "Relink", campaign.Id));
|
||||
Assert.Equal(campaign.Id, relinkByOwner.CampaignId);
|
||||
|
||||
var gmUnlink = ServiceTestSupport.GetValue(service.UpdateCharacter(gmSession, character.Id, "Gm Unlink", null));
|
||||
Assert.Null(gmUnlink.CampaignId);
|
||||
|
||||
var relinkByGm = ServiceTestSupport.GetValue(service.UpdateCharacter(gmSession, character.Id, "Relink Again", campaign.Id));
|
||||
Assert.Equal(campaign.Id, relinkByGm.CampaignId);
|
||||
|
||||
var adminTwo = service.GetUserBySession(adminTwoSession);
|
||||
Assert.NotNull(adminTwo);
|
||||
_ = ServiceTestSupport.GetValue(service.UpdateUserRoles(gmSession, adminTwo.Id, ["admin"]));
|
||||
|
||||
var adminUnlink = ServiceTestSupport.GetValue(service.UpdateCharacter(adminTwoSession, character.Id, "Admin Unlink", null));
|
||||
Assert.Null(adminUnlink.CampaignId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CharacterDelete_AllowsOnlyOwnerOrAdmin()
|
||||
{
|
||||
using var harness = ServiceTestSupport.CreateHarness();
|
||||
var service = harness.Service;
|
||||
|
||||
service.Register("admin", "Password123", "Admin");
|
||||
service.Register("gm", "Password123", "GM");
|
||||
service.Register("owner", "Password123", "Owner");
|
||||
service.Register("other", "Password123", "Other");
|
||||
|
||||
var adminSession = ServiceTestSupport.GetValue(service.Login("admin", "Password123")).SessionToken;
|
||||
var gmSession = ServiceTestSupport.GetValue(service.Login("gm", "Password123")).SessionToken;
|
||||
var ownerSession = ServiceTestSupport.GetValue(service.Login("owner", "Password123")).SessionToken;
|
||||
var otherSession = ServiceTestSupport.GetValue(service.Login("other", "Password123")).SessionToken;
|
||||
|
||||
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Main", "d6"));
|
||||
var ownerCharacter = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Owner Character", campaign.Id));
|
||||
var otherCharacter = ServiceTestSupport.GetValue(service.CreateCharacter(otherSession, "Other Character", campaign.Id));
|
||||
|
||||
var gmDeleteAttempt = service.DeleteCharacter(gmSession, ownerCharacter.Id);
|
||||
Assert.False(gmDeleteAttempt.Succeeded);
|
||||
|
||||
var otherDeleteAttempt = service.DeleteCharacter(otherSession, ownerCharacter.Id);
|
||||
Assert.False(otherDeleteAttempt.Succeeded);
|
||||
|
||||
var ownerDelete = ServiceTestSupport.GetValue(service.DeleteCharacter(ownerSession, ownerCharacter.Id));
|
||||
Assert.True(ownerDelete);
|
||||
|
||||
var adminDelete = ServiceTestSupport.GetValue(service.DeleteCharacter(adminSession, otherCharacter.Id));
|
||||
Assert.True(adminDelete);
|
||||
|
||||
var campaignAfterDeletes = ServiceTestSupport.GetValue(service.GetCampaign(gmSession, campaign.Id));
|
||||
Assert.Empty(campaignAfterDeletes.Characters);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RolemasterSkillDefinitions_CanonicalizeAndKeepLegacyNegativeModifierRules()
|
||||
{
|
||||
using var harness = ServiceTestSupport.CreateHarness();
|
||||
var service = harness.Service;
|
||||
|
||||
service.Register("gm-rm", "Password123", "GM");
|
||||
service.Register("owner-rm", "Password123", "Owner");
|
||||
|
||||
var gmSession = ServiceTestSupport.GetValue(service.Login("gm-rm", "Password123")).SessionToken;
|
||||
var ownerSession = ServiceTestSupport.GetValue(service.Login("owner-rm", "Password123")).SessionToken;
|
||||
|
||||
var rolemasterCampaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Shadow World", "rolemaster"));
|
||||
var dndCampaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Forgotten Realms", "dnd5e"));
|
||||
var rolemasterCharacter = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Harn", rolemasterCampaign.Id));
|
||||
var dndCharacter = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Mage", dndCampaign.Id));
|
||||
|
||||
var negativeDndSkill = service.CreateSkill(ownerSession, dndCharacter.Id, "Invalid", "1d20-1", 0, false);
|
||||
Assert.False(negativeDndSkill.Succeeded);
|
||||
|
||||
var invalidRolemasterOptions = service.CreateSkillGroup(ownerSession, rolemasterCharacter.Id, "Invalid", "2d10-15", 3, true);
|
||||
Assert.False(invalidRolemasterOptions.Succeeded);
|
||||
|
||||
var rolemasterGroup = ServiceTestSupport.GetValue(service.CreateSkillGroup(ownerSession, rolemasterCharacter.Id, "Awareness", "d100!+15", 0, false, 5));
|
||||
Assert.Equal("d100!+15", rolemasterGroup.DiceRollDefinition);
|
||||
Assert.Equal(0, rolemasterGroup.WildDice);
|
||||
Assert.False(rolemasterGroup.AllowFumble);
|
||||
Assert.Equal(5, rolemasterGroup.FumbleRange);
|
||||
|
||||
var percentileWithFumbleRange = service.CreateSkill(ownerSession, rolemasterCharacter.Id, "Bad Percentile", "1d100-20", 0, false, null, 5);
|
||||
Assert.False(percentileWithFumbleRange.Succeeded);
|
||||
|
||||
var percentileSkill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, rolemasterCharacter.Id, "Perception", "1d100-20", 0, false, rolemasterGroup.Id));
|
||||
Assert.Equal("d100-20", percentileSkill.DiceRollDefinition);
|
||||
Assert.Equal(0, percentileSkill.WildDice);
|
||||
Assert.False(percentileSkill.AllowFumble);
|
||||
Assert.Null(percentileSkill.FumbleRange);
|
||||
|
||||
var missingOpenEndedFumbleRange = service.UpdateSkill(ownerSession, percentileSkill.Id, "Perception", "1d100!+85", 0, false, rolemasterGroup.Id);
|
||||
Assert.False(missingOpenEndedFumbleRange.Succeeded);
|
||||
|
||||
var invalidOpenEndedFumbleRange = service.UpdateSkill(ownerSession, percentileSkill.Id, "Perception", "1d100!+85", 0, false, rolemasterGroup.Id, 96);
|
||||
Assert.False(invalidOpenEndedFumbleRange.Succeeded);
|
||||
|
||||
var openEndedSkill = ServiceTestSupport.GetValue(service.UpdateSkill(ownerSession, percentileSkill.Id, "Perception", "1d100!+85", 0, false, rolemasterGroup.Id, 5));
|
||||
Assert.Equal("d100!+85", openEndedSkill.DiceRollDefinition);
|
||||
Assert.Equal(0, openEndedSkill.WildDice);
|
||||
Assert.False(openEndedSkill.AllowFumble);
|
||||
Assert.Equal(5, openEndedSkill.FumbleRange);
|
||||
|
||||
var invalidRetrySkill = service.UpdateSkill(ownerSession, percentileSkill.Id, "Perception", "d100+15", 0, false, rolemasterGroup.Id, null, true);
|
||||
Assert.False(invalidRetrySkill.Succeeded);
|
||||
Assert.Equal("invalid_rolemaster_retry", invalidRetrySkill.Error!.Code);
|
||||
|
||||
var retrySkill = ServiceTestSupport.GetValue(service.UpdateSkill(ownerSession, percentileSkill.Id, "Perception", "d100!+85", 0, false, rolemasterGroup.Id, 5, true));
|
||||
Assert.True(retrySkill.RolemasterAutoRetry);
|
||||
}
|
||||
}
|
||||
@@ -65,10 +65,203 @@ public sealed class ServiceSkillRollTests
|
||||
Assert.Equal(2, ServiceTestSupport.GetValue(ownerLog).Count);
|
||||
Assert.Equal(2, ServiceTestSupport.GetValue(gmLog).Count);
|
||||
Assert.False(outsiderLog.Succeeded);
|
||||
Assert.All(ServiceTestSupport.GetValue(ownerLog), entry => Assert.NotEmpty(entry.Dice));
|
||||
Assert.All(ServiceTestSupport.GetValue(gmLog), entry => Assert.NotEmpty(entry.Dice));
|
||||
|
||||
var version = service.GetCampaignVersion(ownerSession, campaign.Id);
|
||||
var missingVersion = service.GetCampaignVersion(ownerSession, Guid.NewGuid());
|
||||
Assert.True(version.Succeeded);
|
||||
Assert.False(missingVersion.Succeeded);
|
||||
var state = service.GetCampaignStateSnapshot(ownerSession, campaign.Id);
|
||||
var missingState = service.GetCampaignStateSnapshot(ownerSession, Guid.NewGuid());
|
||||
Assert.True(state.Succeeded);
|
||||
Assert.False(missingState.Succeeded);
|
||||
Assert.True(ServiceTestSupport.GetValue(state).LogVersion > 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CampaignLogPage_ReturnsInitialWindowAndIncrementalAppend()
|
||||
{
|
||||
using var harness = ServiceTestSupport.CreateHarness(6, 5, 4, 3, 2, 6, 5, 4, 3, 2, 6, 5);
|
||||
var service = harness.Service;
|
||||
|
||||
service.Register("gm-page", "Password123", "GM");
|
||||
service.Register("owner-page", "Password123", "Owner");
|
||||
|
||||
var gmSession = ServiceTestSupport.GetValue(service.Login("gm-page", "Password123")).SessionToken;
|
||||
var ownerSession = ServiceTestSupport.GetValue(service.Login("owner-page", "Password123")).SessionToken;
|
||||
|
||||
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Paged", "d6"));
|
||||
var character = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Hero", campaign.Id));
|
||||
var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, character.Id, "Stealth", "2D+1", 1, true));
|
||||
|
||||
var rollIds = new List<Guid>();
|
||||
for (var i = 0; i < 5; i++)
|
||||
rollIds.Add(ServiceTestSupport.GetValue(service.RollSkill(ownerSession, skill.Id, "public")).RollId);
|
||||
|
||||
var initialPage = ServiceTestSupport.GetValue(service.GetCampaignLogPage(gmSession, campaign.Id, limit: 3));
|
||||
Assert.Equal(3, initialPage.Entries.Length);
|
||||
Assert.Equal(rollIds[2], initialPage.Entries[0].RollId);
|
||||
Assert.Equal(rollIds[^1], initialPage.Entries[^1].RollId);
|
||||
Assert.Equal(rollIds[^1], initialPage.Cursor);
|
||||
Assert.True(initialPage.HasMore);
|
||||
Assert.False(initialPage.ResetRequired);
|
||||
Assert.All(initialPage.Entries, entry =>
|
||||
{
|
||||
Assert.False(string.IsNullOrWhiteSpace(entry.SummaryText));
|
||||
Assert.False(string.IsNullOrWhiteSpace(entry.RollerLabel));
|
||||
});
|
||||
|
||||
var latestRoll = ServiceTestSupport.GetValue(service.RollSkill(ownerSession, skill.Id, "public"));
|
||||
var incrementalPage = ServiceTestSupport.GetValue(service.GetCampaignLogPage(gmSession, campaign.Id, initialPage.Cursor, 3));
|
||||
|
||||
Assert.Single(incrementalPage.Entries);
|
||||
Assert.Equal(latestRoll.RollId, incrementalPage.Entries[0].RollId);
|
||||
Assert.Equal(latestRoll.RollId, incrementalPage.Cursor);
|
||||
Assert.False(incrementalPage.HasMore);
|
||||
Assert.False(incrementalPage.ResetRequired);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CampaignLogPage_RequestsResetWhenIncrementalGapExceedsLimit()
|
||||
{
|
||||
using var harness = ServiceTestSupport.CreateHarness(6, 5, 4, 3, 2, 6, 5, 4, 3, 2, 6, 5);
|
||||
var service = harness.Service;
|
||||
|
||||
service.Register("gm-gap", "Password123", "GM");
|
||||
service.Register("owner-gap", "Password123", "Owner");
|
||||
|
||||
var gmSession = ServiceTestSupport.GetValue(service.Login("gm-gap", "Password123")).SessionToken;
|
||||
var ownerSession = ServiceTestSupport.GetValue(service.Login("owner-gap", "Password123")).SessionToken;
|
||||
|
||||
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Gap", "d6"));
|
||||
var character = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Hero", campaign.Id));
|
||||
var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, character.Id, "Stealth", "2D+1", 1, true));
|
||||
|
||||
var rollIds = new List<Guid>();
|
||||
for (var i = 0; i < 6; i++)
|
||||
rollIds.Add(ServiceTestSupport.GetValue(service.RollSkill(ownerSession, skill.Id, "public")).RollId);
|
||||
|
||||
var gapPage = ServiceTestSupport.GetValue(service.GetCampaignLogPage(gmSession, campaign.Id, rollIds[0], 3));
|
||||
|
||||
Assert.True(gapPage.ResetRequired);
|
||||
Assert.True(gapPage.HasMore);
|
||||
Assert.Equal(3, gapPage.Entries.Length);
|
||||
Assert.Equal(rollIds[3], gapPage.Entries[0].RollId);
|
||||
Assert.Equal(rollIds[^1], gapPage.Entries[^1].RollId);
|
||||
Assert.Equal(rollIds[^1], gapPage.Cursor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CampaignLogPage_BuildsD6AndDndSpecialEventBadges()
|
||||
{
|
||||
using var harness = ServiceTestSupport.CreateHarness(6, 4, 6, 6, 2, 20, 1);
|
||||
var service = harness.Service;
|
||||
|
||||
service.Register("gm-special", "Password123", "GM");
|
||||
service.Register("owner-special", "Password123", "Owner");
|
||||
|
||||
var gmSession = ServiceTestSupport.GetValue(service.Login("gm-special", "Password123")).SessionToken;
|
||||
var ownerSession = ServiceTestSupport.GetValue(service.Login("owner-special", "Password123")).SessionToken;
|
||||
|
||||
var d6Campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "D6 Special", "d6"));
|
||||
var d6Character = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Wild Hero", d6Campaign.Id));
|
||||
var d6Skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, d6Character.Id, "Stealth", "2D+1", 1, true));
|
||||
|
||||
_ = ServiceTestSupport.GetValue(service.RollSkill(ownerSession, d6Skill.Id, "public"));
|
||||
var d6Entry = Assert.Single(ServiceTestSupport.GetValue(service.GetCampaignLogPage(gmSession, d6Campaign.Id, limit: 5)).Entries);
|
||||
var d6Badges = Assert.IsType<string[]>(d6Entry.EventBadges);
|
||||
Assert.Equal("w6", Assert.Single(d6Badges));
|
||||
Assert.Equal("6, 4, 6, 6, 2", d6Entry.SummaryText);
|
||||
|
||||
var dndCampaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Dnd Special", "dnd5e"));
|
||||
var dndCharacter = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Natural Hero", dndCampaign.Id));
|
||||
var dndSkill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, dndCharacter.Id, "Attack", "1d20+5", 0, false));
|
||||
|
||||
_ = ServiceTestSupport.GetValue(service.RollSkill(ownerSession, dndSkill.Id, "public"));
|
||||
_ = ServiceTestSupport.GetValue(service.RollSkill(ownerSession, dndSkill.Id, "public"));
|
||||
var dndEntries = ServiceTestSupport.GetValue(service.GetCampaignLogPage(gmSession, dndCampaign.Id, limit: 5)).Entries;
|
||||
|
||||
var firstDndBadges = Assert.IsType<string[]>(dndEntries[0].EventBadges);
|
||||
Assert.Equal("n20", Assert.Single(firstDndBadges));
|
||||
Assert.Equal("20", dndEntries[0].SummaryText);
|
||||
var secondDndBadges = Assert.IsType<string[]>(dndEntries[1].EventBadges);
|
||||
Assert.Equal("n1", Assert.Single(secondDndBadges));
|
||||
Assert.Equal("1", dndEntries[1].SummaryText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RollDetail_ReturnsVisibleDetailAndHidesPrivateRoll()
|
||||
{
|
||||
using var harness = ServiceTestSupport.CreateHarness(6, 5, 4, 3, 2, 6);
|
||||
var service = harness.Service;
|
||||
|
||||
service.Register("gm-detail", "Password123", "GM");
|
||||
service.Register("owner-detail", "Password123", "Owner");
|
||||
service.Register("observer-detail", "Password123", "Observer");
|
||||
service.Register("outsider-detail", "Password123", "Outsider");
|
||||
|
||||
var gmSession = ServiceTestSupport.GetValue(service.Login("gm-detail", "Password123")).SessionToken;
|
||||
var ownerSession = ServiceTestSupport.GetValue(service.Login("owner-detail", "Password123")).SessionToken;
|
||||
var observerSession = ServiceTestSupport.GetValue(service.Login("observer-detail", "Password123")).SessionToken;
|
||||
var outsiderSession = ServiceTestSupport.GetValue(service.Login("outsider-detail", "Password123")).SessionToken;
|
||||
|
||||
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Detail", "d6"));
|
||||
var ownerCharacter = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Hero", campaign.Id));
|
||||
_ = ServiceTestSupport.GetValue(service.CreateCharacter(observerSession, "Watcher", campaign.Id));
|
||||
var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, ownerCharacter.Id, "Stealth", "2D+1", 1, true));
|
||||
|
||||
var privateRoll = ServiceTestSupport.GetValue(service.RollSkill(ownerSession, skill.Id, "private"));
|
||||
var publicRoll = ServiceTestSupport.GetValue(service.RollSkill(ownerSession, skill.Id, "public"));
|
||||
|
||||
var gmDetail = ServiceTestSupport.GetValue(service.GetRollDetail(gmSession, privateRoll.RollId));
|
||||
var ownerDetail = ServiceTestSupport.GetValue(service.GetRollDetail(ownerSession, privateRoll.RollId));
|
||||
var observerPublicDetail = ServiceTestSupport.GetValue(service.GetRollDetail(observerSession, publicRoll.RollId));
|
||||
var observerPrivateDetail = service.GetRollDetail(observerSession, privateRoll.RollId);
|
||||
var outsiderPublicDetail = service.GetRollDetail(outsiderSession, publicRoll.RollId);
|
||||
|
||||
Assert.NotEmpty(gmDetail.Dice);
|
||||
Assert.Equal(privateRoll.RollId, gmDetail.RollId);
|
||||
Assert.Equal(privateRoll.Breakdown, ownerDetail.Breakdown);
|
||||
Assert.Equal(publicRoll.RollId, observerPublicDetail.RollId);
|
||||
Assert.False(observerPrivateDetail.Succeeded);
|
||||
Assert.Equal("roll_not_found", observerPrivateDetail.Error!.Code);
|
||||
Assert.False(outsiderPublicDetail.Succeeded);
|
||||
Assert.Equal("roll_not_found", outsiderPublicDetail.Error!.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CustomRoll_UsesCampaignRuleset_AndAppearsAsCustomRollInLog()
|
||||
{
|
||||
using var harness = ServiceTestSupport.CreateHarness(20);
|
||||
var service = harness.Service;
|
||||
|
||||
service.Register("gm-custom", "Password123", "GM");
|
||||
service.Register("owner-custom", "Password123", "Owner");
|
||||
service.Register("other-custom", "Password123", "Other");
|
||||
|
||||
var gmSession = ServiceTestSupport.GetValue(service.Login("gm-custom", "Password123")).SessionToken;
|
||||
var ownerSession = ServiceTestSupport.GetValue(service.Login("owner-custom", "Password123")).SessionToken;
|
||||
var otherSession = ServiceTestSupport.GetValue(service.Login("other-custom", "Password123")).SessionToken;
|
||||
|
||||
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Custom", "dnd5e"));
|
||||
var character = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Hero", campaign.Id));
|
||||
|
||||
var invalidExpression = service.RollCustom(ownerSession, character.Id, "bad", "public");
|
||||
Assert.False(invalidExpression.Succeeded);
|
||||
Assert.Equal("invalid_expression", invalidExpression.Error!.Code);
|
||||
|
||||
var forbiddenRoll = service.RollCustom(otherSession, character.Id, "1d20+5", "public");
|
||||
Assert.False(forbiddenRoll.Succeeded);
|
||||
Assert.Equal("forbidden", forbiddenRoll.Error!.Code);
|
||||
|
||||
var customRoll = ServiceTestSupport.GetValue(service.RollCustom(ownerSession, character.Id, "1d20+5", "private"));
|
||||
Assert.Equal(Guid.Empty, customRoll.SkillId);
|
||||
Assert.StartsWith("1d20+5 => ", customRoll.Breakdown, StringComparison.Ordinal);
|
||||
|
||||
var logPage = ServiceTestSupport.GetValue(service.GetCampaignLogPage(gmSession, campaign.Id, limit: 5));
|
||||
var entry = Assert.Single(logPage.Entries);
|
||||
Assert.Equal("Custom roll", entry.SkillName);
|
||||
Assert.Equal("Private (GM view)", entry.VisibilityLabel);
|
||||
Assert.Contains("n20", Assert.IsType<string[]>(entry.EventBadges));
|
||||
|
||||
var log = ServiceTestSupport.GetValue(service.GetCampaignLog(ownerSession, campaign.Id));
|
||||
Assert.Equal("Custom roll", Assert.Single(log).SkillName);
|
||||
}
|
||||
}
|
||||
99
RpgRoller.Tests/Services/ServiceStateInfrastructureTests.cs
Normal file
99
RpgRoller.Tests/Services/ServiceStateInfrastructureTests.cs
Normal file
@@ -0,0 +1,99 @@
|
||||
namespace RpgRoller.Tests;
|
||||
|
||||
public sealed class ServiceStateInfrastructureTests
|
||||
{
|
||||
[Fact]
|
||||
public void GameStateStore_StartsWithEmptyMutableCollections()
|
||||
{
|
||||
var store = new GameStateStore();
|
||||
|
||||
Assert.NotNull(store.Gate);
|
||||
Assert.Empty(store.UsersById);
|
||||
Assert.Empty(store.UserIdsByUsername);
|
||||
Assert.Empty(store.SessionsByToken);
|
||||
Assert.Empty(store.CampaignsById);
|
||||
Assert.Empty(store.CampaignStateById);
|
||||
Assert.Empty(store.CharactersById);
|
||||
Assert.Empty(store.SkillGroupsById);
|
||||
Assert.Empty(store.SkillsById);
|
||||
Assert.Empty(store.RollLog);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GameStateCloneFactory_ProducesDetachedCopies()
|
||||
{
|
||||
var user = new UserAccount
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Username = "user",
|
||||
UsernameNormalized = "USER",
|
||||
PasswordHash = "hash",
|
||||
DisplayName = "User",
|
||||
Roles = "admin",
|
||||
ActiveCharacterId = Guid.NewGuid()
|
||||
};
|
||||
var session = new UserSession
|
||||
{
|
||||
Token = "token",
|
||||
UserId = user.Id,
|
||||
CreatedAtUtc = DateTimeOffset.UtcNow
|
||||
};
|
||||
var campaign = new Campaign
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
GmUserId = user.Id,
|
||||
Name = "Main",
|
||||
Ruleset = RulesetKind.D6,
|
||||
Version = 3
|
||||
};
|
||||
var character = new Character
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OwnerUserId = user.Id,
|
||||
CampaignId = campaign.Id,
|
||||
Name = "Hero"
|
||||
};
|
||||
var skillGroup = new SkillGroup
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CharacterId = character.Id,
|
||||
Name = "Group",
|
||||
DiceRollDefinition = "2D+1",
|
||||
WildDice = 1,
|
||||
AllowFumble = true,
|
||||
FumbleRange = null
|
||||
};
|
||||
var skill = new Skill
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CharacterId = character.Id,
|
||||
SkillGroupId = skillGroup.Id,
|
||||
Name = "Skill",
|
||||
DiceRollDefinition = "2D+2",
|
||||
WildDice = 1,
|
||||
AllowFumble = true,
|
||||
FumbleRange = null
|
||||
};
|
||||
var logEntry = new RollLogEntry
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CampaignId = campaign.Id,
|
||||
CharacterId = character.Id,
|
||||
SkillId = skill.Id,
|
||||
RollerUserId = user.Id,
|
||||
Visibility = RollVisibility.Public,
|
||||
Result = 12,
|
||||
Breakdown = "6 + 6 = 12",
|
||||
Dice = "[]",
|
||||
TimestampUtc = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
Assert.NotSame(user, GameStateCloneFactory.CloneUser(user));
|
||||
Assert.NotSame(session, GameStateCloneFactory.CloneSession(session));
|
||||
Assert.NotSame(campaign, GameStateCloneFactory.CloneCampaign(campaign));
|
||||
Assert.NotSame(character, GameStateCloneFactory.CloneCharacter(character));
|
||||
Assert.NotSame(skillGroup, GameStateCloneFactory.CloneSkillGroup(skillGroup));
|
||||
Assert.NotSame(skill, GameStateCloneFactory.CloneSkill(skill));
|
||||
Assert.NotSame(logEntry, GameStateCloneFactory.CloneRollLogEntry(logEntry));
|
||||
}
|
||||
}
|
||||
86
RpgRoller.Tests/Services/WorkspaceQueryServiceTests.cs
Normal file
86
RpgRoller.Tests/Services/WorkspaceQueryServiceTests.cs
Normal file
@@ -0,0 +1,86 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.JSInterop;
|
||||
using RpgRoller.Components;
|
||||
|
||||
namespace RpgRoller.Tests;
|
||||
|
||||
public sealed class WorkspaceQueryServiceTests
|
||||
{
|
||||
private sealed class StubJsRuntime(Func<string, object?[]?, Type, object?> handler) : IJSRuntime
|
||||
{
|
||||
public ValueTask<TValue> InvokeAsync<TValue>(string identifier, object?[]? args)
|
||||
{
|
||||
return ValueTask.FromResult((TValue)handler(identifier, args, typeof(TValue))!);
|
||||
}
|
||||
|
||||
public ValueTask<TValue> InvokeAsync<TValue>(string identifier, CancellationToken cancellationToken,
|
||||
object?[]? args)
|
||||
{
|
||||
return InvokeAsync<TValue>(identifier, args);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCampaignsAsync_UsesCampaignsApiEndpoint()
|
||||
{
|
||||
var queryService = new WorkspaceQueryService(new RpgRollerApiClient(
|
||||
new StubJsRuntime((identifier, args, returnType) =>
|
||||
{
|
||||
Assert.Equal("rpgRollerApi.request", identifier);
|
||||
Assert.Equal("GET", args![0]);
|
||||
Assert.Equal("/api/campaigns", args[1]);
|
||||
Assert.Null(args[2]);
|
||||
|
||||
return CreateJsApiResponse(args: new
|
||||
{
|
||||
ok = true,
|
||||
status = 200,
|
||||
data = new[]
|
||||
{
|
||||
new CampaignSummary(Guid.NewGuid(), "Alpha", "d6", new CampaignGmSummary(Guid.NewGuid(), "GM"),
|
||||
1)
|
||||
}
|
||||
}, returnType);
|
||||
})));
|
||||
|
||||
var campaigns = await queryService.GetCampaignsAsync();
|
||||
|
||||
var campaign = Assert.Single(campaigns);
|
||||
Assert.Equal("Alpha", campaign.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetMeAsync_MapsUnauthorizedApiResponseToApiRequestException()
|
||||
{
|
||||
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 exception = await Assert.ThrowsAsync<ApiRequestException>(queryService.GetMeAsync);
|
||||
|
||||
Assert.Equal(401, exception.StatusCode);
|
||||
Assert.Equal("You must be logged in.", exception.Message);
|
||||
Assert.Equal("unauthorized", exception.ErrorCode);
|
||||
}
|
||||
|
||||
private static object CreateJsApiResponse(object args, Type returnType)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(args);
|
||||
return JsonSerializer.Deserialize(json, returnType, JsonOptions)!;
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
}
|
||||
115
RpgRoller.Tests/Services/WorkspaceStateTests.cs
Normal file
115
RpgRoller.Tests/Services/WorkspaceStateTests.cs
Normal file
@@ -0,0 +1,115 @@
|
||||
using RpgRoller.Components.Pages;
|
||||
|
||||
namespace RpgRoller.Tests;
|
||||
|
||||
public sealed class WorkspaceStateTests
|
||||
{
|
||||
[Fact]
|
||||
public void OwnerLabel_ResolvesCurrentUserGmAndFallbacks()
|
||||
{
|
||||
var gmId = Guid.NewGuid();
|
||||
var userId = Guid.NewGuid();
|
||||
var otherOwnerId = Guid.NewGuid();
|
||||
var state = new WorkspaceState
|
||||
{
|
||||
User = new(userId, "user", "User", []),
|
||||
SelectedCampaign = new(Guid.NewGuid(), "Alpha", "d6", new(gmId, "GM"), [
|
||||
new(Guid.NewGuid(), "Scout", otherOwnerId, Guid.NewGuid(), "Other Owner")
|
||||
])
|
||||
};
|
||||
|
||||
Assert.Equal("You", state.OwnerLabel(userId));
|
||||
Assert.Equal("GM (GM)", state.OwnerLabel(gmId));
|
||||
Assert.Equal("Other Owner", state.OwnerLabel(otherOwnerId));
|
||||
Assert.Equal("Unknown owner", state.OwnerLabel(Guid.NewGuid()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
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"), []) };
|
||||
|
||||
Assert.Equal("d100!+15, wild 1, fumble on", state.SkillDefinitionLabel(skill));
|
||||
|
||||
state.SelectedCampaign = new(Guid.NewGuid(), "Alpha", "rolemaster", new(Guid.NewGuid(), "GM"), []);
|
||||
Assert.Equal("Open-ended percentile: d100!+15, fumble <= 5, auto retry", state.SkillDefinitionLabel(skill));
|
||||
|
||||
state.SelectedCampaign = new(Guid.NewGuid(), "Alpha", "dnd5e", new(Guid.NewGuid(), "GM"), []);
|
||||
Assert.Equal("d100!+15", state.SkillDefinitionLabel(skill));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlaySelections_ForNonGm_FilterToOwnedCharactersAndPreferSelectedThenActive()
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
var ownedCharacter = new CharacterSummary(Guid.NewGuid(), "Owned", userId, Guid.NewGuid(), "User");
|
||||
var secondOwnedCharacter = new CharacterSummary(Guid.NewGuid(), "Owned Two", userId, Guid.NewGuid(), "User");
|
||||
var otherCharacter = new CharacterSummary(Guid.NewGuid(), "Other", Guid.NewGuid(), Guid.NewGuid(), "Other");
|
||||
var state = new WorkspaceState
|
||||
{
|
||||
User = new(userId, "user", "User", []),
|
||||
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)],
|
||||
SelectedCharacterSkillGroups = [new(Guid.NewGuid(), "Combat", "2D+1", 1, true, null)]
|
||||
};
|
||||
|
||||
Assert.Equal(2, state.PlaySelectedCampaign!.Characters.Length);
|
||||
Assert.Equal(secondOwnedCharacter.Id, state.PlaySelectedCharacterId);
|
||||
Assert.Single(state.PlaySelectedCharacterSkills);
|
||||
Assert.Single(state.PlaySelectedCharacterSkillGroups);
|
||||
|
||||
state.SelectedCharacterId = Guid.NewGuid();
|
||||
Assert.Equal(ownedCharacter.Id, state.PlaySelectedCharacterId);
|
||||
|
||||
state.ActiveCharacterId = Guid.NewGuid();
|
||||
Assert.Equal(ownedCharacter.Id, state.PlaySelectedCharacterId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlaySelections_ForGm_ExposeEntireCampaignAndKeepNonOwnedSelection()
|
||||
{
|
||||
var gmId = Guid.NewGuid();
|
||||
var otherCharacter = new CharacterSummary(Guid.NewGuid(), "Other", Guid.NewGuid(), Guid.NewGuid(), "Other");
|
||||
var ownedCharacter = new CharacterSummary(Guid.NewGuid(), "Owned", gmId, Guid.NewGuid(), "GM");
|
||||
var state = new WorkspaceState
|
||||
{
|
||||
User = new(gmId, "gm", "GM", []),
|
||||
SelectedCampaign = new(Guid.NewGuid(), "Alpha", "d6", new(gmId, "GM"),
|
||||
[ownedCharacter, otherCharacter]),
|
||||
SelectedCharacterId = otherCharacter.Id
|
||||
};
|
||||
|
||||
Assert.Equal(2, state.PlaySelectedCampaign!.Characters.Length);
|
||||
Assert.Equal(otherCharacter.Id, state.PlaySelectedCharacterId);
|
||||
Assert.Equal(otherCharacter.Id, state.PlaySelectedCharacter!.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
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"), []),
|
||||
ConnectionState = "reconnecting"
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
state.ConnectionState = "connected";
|
||||
|
||||
Assert.Equal("Connected", state.ConnectionStateLabel);
|
||||
Assert.Equal("ok", state.ConnectionStateCssClass);
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,36 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using RpgRoller.Contracts;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using RpgRoller.Data;
|
||||
using RpgRoller.Services;
|
||||
using RpgRoller.Hosting;
|
||||
|
||||
namespace RpgRoller.Tests;
|
||||
|
||||
public abstract class ApiTestBase : IClassFixture<WebApplicationFactory<Program>>
|
||||
public abstract class ApiTestBase(WebApplicationFactory<Program> factory) : IClassFixture<WebApplicationFactory<Program>>
|
||||
{
|
||||
private readonly WebApplicationFactory<Program> m_BaseFactory;
|
||||
|
||||
protected ApiTestBase(WebApplicationFactory<Program> factory)
|
||||
private sealed class FixedDiceRoller(IEnumerable<int> values) : IDiceRoller
|
||||
{
|
||||
m_BaseFactory = factory;
|
||||
public int Roll(int sides)
|
||||
{
|
||||
var next = m_Values.Count > 0 ? m_Values.Dequeue() : 1;
|
||||
return Math.Clamp(next, 1, sides);
|
||||
}
|
||||
|
||||
private readonly Queue<int> m_Values = new(values);
|
||||
}
|
||||
|
||||
protected WebApplicationFactory<Program> CreateFactory(params int[] rollValues)
|
||||
{
|
||||
return m_BaseFactory.WithWebHostBuilder(builder =>
|
||||
return factory.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureLogging(logging =>
|
||||
{
|
||||
logging.AddFilter("Microsoft.AspNetCore", LogLevel.Warning);
|
||||
logging.AddFilter("Microsoft.EntityFrameworkCore", LogLevel.Warning);
|
||||
logging.AddFilter("Microsoft.Hosting", LogLevel.Warning);
|
||||
});
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.RemoveAll<IDiceRoller>();
|
||||
@@ -30,18 +39,18 @@ public abstract class ApiTestBase : IClassFixture<WebApplicationFactory<Program>
|
||||
services.RemoveAll<DbContextOptions<RpgRollerDbContext>>();
|
||||
services.RemoveAll<IDbContextFactory<RpgRollerDbContext>>();
|
||||
services.RemoveAll<RpgRollerDbContext>();
|
||||
services.RemoveAll<SqliteDatabaseFile>();
|
||||
|
||||
var dbPath = Path.Combine(Path.GetTempPath(), $"rpgroller-tests-{Guid.NewGuid():N}.db");
|
||||
services.AddSingleton(new SqliteDatabaseFile(dbPath));
|
||||
services.AddDbContextFactory<RpgRollerDbContext>(options => options.UseSqlite($"Data Source={dbPath}"));
|
||||
}));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
protected static async Task<UserSummary> RegisterAsync(HttpClient client, string username, string password, string displayName)
|
||||
{
|
||||
return await PostAsync<RegisterRequest, UserSummary>(
|
||||
client,
|
||||
"/api/auth/register",
|
||||
new RegisterRequest(username, password, displayName));
|
||||
return await PostAsync<RegisterRequest, UserSummary>(client, "/api/auth/register", new(username, password, displayName));
|
||||
}
|
||||
|
||||
protected static async Task LoginAsync(HttpClient client, string username, string password)
|
||||
@@ -76,20 +85,4 @@ public abstract class ApiTestBase : IClassFixture<WebApplicationFactory<Program>
|
||||
Assert.NotNull(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
private sealed class FixedDiceRoller : IDiceRoller
|
||||
{
|
||||
private readonly Queue<int> m_Values;
|
||||
|
||||
public FixedDiceRoller(IEnumerable<int> values)
|
||||
{
|
||||
m_Values = new Queue<int>(values);
|
||||
}
|
||||
|
||||
public int Roll(int sides)
|
||||
{
|
||||
var next = m_Values.Count > 0 ? m_Values.Dequeue() : 1;
|
||||
return Math.Clamp(next, 1, sides);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,76 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using RpgRoller.Data;
|
||||
using RpgRoller.Domain;
|
||||
using RpgRoller.Services;
|
||||
|
||||
namespace RpgRoller.Tests;
|
||||
|
||||
internal static class ServiceTestSupport
|
||||
{
|
||||
internal sealed class ServiceHarness : IDisposable
|
||||
{
|
||||
internal ServiceHarness(GameService service, SqliteDbContextFactory factory, string dbPath)
|
||||
{
|
||||
Service = service;
|
||||
m_Factory = factory;
|
||||
DbPath = dbPath;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
m_Factory.Dispose();
|
||||
}
|
||||
|
||||
public RpgRollerDbContext CreateDbContext()
|
||||
{
|
||||
return m_Factory.CreateDbContext();
|
||||
}
|
||||
|
||||
public GameService Service { get; }
|
||||
public string DbPath { get; }
|
||||
private readonly SqliteDbContextFactory m_Factory;
|
||||
}
|
||||
|
||||
internal sealed class RehashingPasswordHasher : IPasswordHasher<UserAccount>
|
||||
{
|
||||
public string HashPassword(UserAccount user, string password)
|
||||
{
|
||||
HashCalls += 1;
|
||||
return $"hash:{HashCalls}:{password}";
|
||||
}
|
||||
|
||||
public PasswordVerificationResult VerifyHashedPassword(UserAccount user, string hashedPassword, string providedPassword)
|
||||
{
|
||||
return providedPassword == "Password123" ? PasswordVerificationResult.SuccessRehashNeeded : PasswordVerificationResult.Failed;
|
||||
}
|
||||
|
||||
public int HashCalls { get; private set; }
|
||||
}
|
||||
|
||||
private sealed class FixedDiceRoller(IEnumerable<int> values) : IDiceRoller
|
||||
{
|
||||
public int Roll(int sides)
|
||||
{
|
||||
var next = m_Values.Count > 0 ? m_Values.Dequeue() : 1;
|
||||
return Math.Clamp(next, 1, sides);
|
||||
}
|
||||
|
||||
private readonly Queue<int> m_Values = new(values);
|
||||
}
|
||||
|
||||
internal sealed class SqliteDbContextFactory(string dbPath) : IDbContextFactory<RpgRollerDbContext>, IDisposable
|
||||
{
|
||||
public RpgRollerDbContext CreateDbContext()
|
||||
{
|
||||
return new(m_Options);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
|
||||
private readonly DbContextOptions<RpgRollerDbContext> m_Options = new DbContextOptionsBuilder<RpgRollerDbContext>().UseSqlite($"Data Source={dbPath}").Options;
|
||||
}
|
||||
|
||||
internal static ServiceHarness CreateHarness(params int[] rollValues)
|
||||
{
|
||||
return CreateHarness(new PasswordHasher<UserAccount>(), rollValues);
|
||||
@@ -26,9 +89,7 @@ internal static class ServiceTestSupport
|
||||
|
||||
internal static ServiceHarness CreateHarnessFromPath(string dbPath, IPasswordHasher<UserAccount> passwordHasher, params int[] rollValues)
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<RpgRollerDbContext>()
|
||||
.UseSqlite($"Data Source={dbPath}")
|
||||
.Options;
|
||||
var options = new DbContextOptionsBuilder<RpgRollerDbContext>().UseSqlite($"Data Source={dbPath}").Options;
|
||||
|
||||
using (var db = new RpgRollerDbContext(options))
|
||||
{
|
||||
@@ -37,7 +98,7 @@ internal static class ServiceTestSupport
|
||||
|
||||
var factory = new SqliteDbContextFactory(dbPath);
|
||||
var service = new GameService(factory, passwordHasher, new FixedDiceRoller(rollValues));
|
||||
return new ServiceHarness(service, factory, dbPath);
|
||||
return new(service, factory, dbPath);
|
||||
}
|
||||
|
||||
internal static T GetValue<T>(ServiceResult<T> result)
|
||||
@@ -46,84 +107,4 @@ internal static class ServiceTestSupport
|
||||
Assert.NotNull(result.Value);
|
||||
return result.Value!;
|
||||
}
|
||||
|
||||
internal sealed class ServiceHarness : IDisposable
|
||||
{
|
||||
private readonly SqliteDbContextFactory m_Factory;
|
||||
|
||||
internal ServiceHarness(GameService service, SqliteDbContextFactory factory, string dbPath)
|
||||
{
|
||||
Service = service;
|
||||
m_Factory = factory;
|
||||
DbPath = dbPath;
|
||||
}
|
||||
|
||||
public GameService Service { get; }
|
||||
public string DbPath { get; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
m_Factory.Dispose();
|
||||
}
|
||||
|
||||
public RpgRollerDbContext CreateDbContext()
|
||||
{
|
||||
return m_Factory.CreateDbContext();
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class RehashingPasswordHasher : IPasswordHasher<UserAccount>
|
||||
{
|
||||
public int HashCalls { get; private set; }
|
||||
|
||||
public string HashPassword(UserAccount user, string password)
|
||||
{
|
||||
HashCalls += 1;
|
||||
return $"hash:{HashCalls}:{password}";
|
||||
}
|
||||
|
||||
public PasswordVerificationResult VerifyHashedPassword(UserAccount user, string hashedPassword, string providedPassword)
|
||||
{
|
||||
return providedPassword == "Password123"
|
||||
? PasswordVerificationResult.SuccessRehashNeeded
|
||||
: PasswordVerificationResult.Failed;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FixedDiceRoller : IDiceRoller
|
||||
{
|
||||
private readonly Queue<int> m_Values;
|
||||
|
||||
public FixedDiceRoller(IEnumerable<int> values)
|
||||
{
|
||||
m_Values = new Queue<int>(values);
|
||||
}
|
||||
|
||||
public int Roll(int sides)
|
||||
{
|
||||
var next = m_Values.Count > 0 ? m_Values.Dequeue() : 1;
|
||||
return Math.Clamp(next, 1, sides);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class SqliteDbContextFactory : IDbContextFactory<RpgRollerDbContext>, IDisposable
|
||||
{
|
||||
private readonly DbContextOptions<RpgRollerDbContext> m_Options;
|
||||
|
||||
public SqliteDbContextFactory(string dbPath)
|
||||
{
|
||||
m_Options = new DbContextOptionsBuilder<RpgRollerDbContext>()
|
||||
.UseSqlite($"Data Source={dbPath}")
|
||||
.Options;
|
||||
}
|
||||
|
||||
public RpgRollerDbContext CreateDbContext()
|
||||
{
|
||||
return new RpgRollerDbContext(m_Options);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
173
RpgRoller.Tests/WorkspaceCampaignCoordinatorTests.cs
Normal file
173
RpgRoller.Tests/WorkspaceCampaignCoordinatorTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
<Solution>
|
||||
</Solution>
|
||||
50
RpgRoller/Api/AdminEndpoints.cs
Normal file
50
RpgRoller/Api/AdminEndpoints.cs
Normal file
@@ -0,0 +1,50 @@
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using RpgRoller.Contracts;
|
||||
using RpgRoller.Domain;
|
||||
using RpgRoller.Hosting;
|
||||
using RpgRoller.Services;
|
||||
|
||||
namespace RpgRoller.Api;
|
||||
|
||||
internal static class AdminEndpoints
|
||||
{
|
||||
public static RouteGroupBuilder MapAdminEndpoints(this RouteGroupBuilder group)
|
||||
{
|
||||
group.MapGet("/admin/users", (HttpContext context, IGameService game) =>
|
||||
{
|
||||
var result = game.GetUsers(context.GetRequiredSessionToken());
|
||||
return ApiResultMapper.ToApiResult(result);
|
||||
});
|
||||
|
||||
group.MapPut("/admin/users/{userId:guid}/roles", (Guid userId, UpdateUserRolesRequest request, HttpContext context, IGameService game) =>
|
||||
{
|
||||
var result = game.UpdateUserRoles(context.GetRequiredSessionToken(), userId, request.Roles);
|
||||
return ApiResultMapper.ToApiResult(result);
|
||||
});
|
||||
|
||||
group.MapDelete("/admin/users/{userId:guid}", (Guid userId, HttpContext context, IGameService game) =>
|
||||
{
|
||||
var result = game.DeleteUser(context.GetRequiredSessionToken(), userId);
|
||||
return ApiResultMapper.ToApiResult(result);
|
||||
});
|
||||
|
||||
group.MapGet("/admin/database", Results<FileStreamHttpResult, BadRequest<ApiError>, UnauthorizedHttpResult> (HttpContext context, IGameService game, SqliteDatabaseFile databaseFile) =>
|
||||
{
|
||||
var sessionToken = context.GetRequiredSessionToken();
|
||||
var user = game.GetUserBySession(sessionToken);
|
||||
if (user is null)
|
||||
return TypedResults.Unauthorized();
|
||||
|
||||
if (!user.Roles.Contains(UserRoles.Admin, StringComparer.OrdinalIgnoreCase))
|
||||
return ApiResultMapper.ToBadRequest(new("forbidden", "Admin role is required."));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(databaseFile.Path) || !File.Exists(databaseFile.Path))
|
||||
return ApiResultMapper.ToBadRequest(new("database_unavailable", "SQLite database file is not available."));
|
||||
|
||||
var stream = new FileStream(databaseFile.Path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||
return TypedResults.File(stream, "application/octet-stream", Path.GetFileName(databaseFile.Path));
|
||||
});
|
||||
|
||||
return group;
|
||||
}
|
||||
}
|
||||
@@ -8,13 +8,14 @@ public static class ApiEndpointRegistration
|
||||
api.MapSystemEndpoints();
|
||||
api.MapAuthEndpoints();
|
||||
|
||||
var authenticatedApi = api.MapGroup(string.Empty)
|
||||
.AddEndpointFilter<RequireSessionTokenFilter>();
|
||||
var authenticatedApi = api.MapGroup(string.Empty).AddEndpointFilter<RequireSessionTokenFilter>();
|
||||
|
||||
authenticatedApi.MapMeEndpoints();
|
||||
authenticatedApi.MapCampaignEndpoints();
|
||||
authenticatedApi.MapCharacterEndpoints();
|
||||
authenticatedApi.MapAdminEndpoints();
|
||||
authenticatedApi.MapSkillEndpoints();
|
||||
authenticatedApi.MapRollEndpoints();
|
||||
authenticatedApi.MapStateEventEndpoints();
|
||||
}
|
||||
}
|
||||
@@ -9,20 +9,16 @@ internal static class ApiResultMapper
|
||||
public static Results<Ok<T>, BadRequest<ApiError>, UnauthorizedHttpResult> ToApiResult<T>(ServiceResult<T> result)
|
||||
{
|
||||
if (result.Succeeded)
|
||||
{
|
||||
return TypedResults.Ok(result.Value!);
|
||||
}
|
||||
|
||||
if (result.Error!.Code == "unauthorized")
|
||||
{
|
||||
return TypedResults.Unauthorized();
|
||||
}
|
||||
|
||||
return TypedResults.BadRequest(new ApiError(result.Error.Message));
|
||||
return TypedResults.BadRequest(new ApiError(result.Error.Message, result.Error.Code));
|
||||
}
|
||||
|
||||
public static BadRequest<ApiError> ToBadRequest(ServiceError error)
|
||||
{
|
||||
return TypedResults.BadRequest(new ApiError(error.Message));
|
||||
return TypedResults.BadRequest(new ApiError(error.Message, error.Code));
|
||||
}
|
||||
}
|
||||
@@ -12,9 +12,7 @@ internal static class AuthEndpoints
|
||||
{
|
||||
var result = game.Register(request.Username, request.Password, request.DisplayName);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
return ApiResultMapper.ToBadRequest(result.Error!);
|
||||
}
|
||||
|
||||
return TypedResults.Ok(result.Value!);
|
||||
});
|
||||
@@ -23,11 +21,9 @@ internal static class AuthEndpoints
|
||||
{
|
||||
var result = game.Login(request.Username, request.Password);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
return ApiResultMapper.ToBadRequest(result.Error!);
|
||||
}
|
||||
|
||||
context.Response.Cookies.Append(SessionCookie.Name, result.Value.SessionToken, new CookieOptions
|
||||
context.Response.Cookies.Append(SessionCookie.Name, result.Value.SessionToken, new()
|
||||
{
|
||||
HttpOnly = true,
|
||||
SameSite = SameSiteMode.Strict,
|
||||
@@ -41,9 +37,7 @@ internal static class AuthEndpoints
|
||||
group.MapPost("/auth/logout", (HttpContext context, IGameService game) =>
|
||||
{
|
||||
if (context.TryReadSessionTokenFromCookie(out var sessionToken))
|
||||
{
|
||||
game.Logout(sessionToken);
|
||||
}
|
||||
|
||||
context.Response.Cookies.Delete(SessionCookie.Name);
|
||||
return TypedResults.NoContent();
|
||||
|
||||
@@ -19,6 +19,12 @@ internal static class CampaignEndpoints
|
||||
return ApiResultMapper.ToApiResult(result);
|
||||
});
|
||||
|
||||
group.MapGet("/campaigns/options", (HttpContext context, IGameService game) =>
|
||||
{
|
||||
var result = game.GetCharacterCampaignOptions(context.GetRequiredSessionToken());
|
||||
return ApiResultMapper.ToApiResult(result);
|
||||
});
|
||||
|
||||
group.MapGet("/campaigns/{campaignId:guid}", (Guid campaignId, HttpContext context, IGameService game) =>
|
||||
{
|
||||
var result = game.GetCampaign(context.GetRequiredSessionToken(), campaignId);
|
||||
@@ -31,6 +37,18 @@ internal static class CampaignEndpoints
|
||||
return ApiResultMapper.ToApiResult(result);
|
||||
});
|
||||
|
||||
group.MapGet("/campaigns/{campaignId:guid}/log/page", (Guid campaignId, Guid? afterRollId, int? limit, HttpContext context, IGameService game) =>
|
||||
{
|
||||
var result = game.GetCampaignLogPage(context.GetRequiredSessionToken(), campaignId, afterRollId, limit);
|
||||
return ApiResultMapper.ToApiResult(result);
|
||||
});
|
||||
|
||||
group.MapDelete("/campaigns/{campaignId:guid}", (Guid campaignId, HttpContext context, IGameService game) =>
|
||||
{
|
||||
var result = game.DeleteCampaign(context.GetRequiredSessionToken(), campaignId);
|
||||
return ApiResultMapper.ToApiResult(result);
|
||||
});
|
||||
|
||||
return group;
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,13 @@ internal static class CharacterEndpoints
|
||||
|
||||
group.MapPut("/characters/{characterId:guid}", (Guid characterId, UpdateCharacterRequest request, HttpContext context, IGameService game) =>
|
||||
{
|
||||
var result = game.UpdateCharacter(context.GetRequiredSessionToken(), characterId, request.Name, request.CampaignId);
|
||||
var result = game.UpdateCharacter(context.GetRequiredSessionToken(), characterId, request.Name, request.CampaignId, request.OwnerUsername);
|
||||
return ApiResultMapper.ToApiResult(result);
|
||||
});
|
||||
|
||||
group.MapDelete("/characters/{characterId:guid}", (Guid characterId, HttpContext context, IGameService game) =>
|
||||
{
|
||||
var result = game.DeleteCharacter(context.GetRequiredSessionToken(), characterId);
|
||||
return ApiResultMapper.ToApiResult(result);
|
||||
});
|
||||
|
||||
@@ -25,9 +31,21 @@ internal static class CharacterEndpoints
|
||||
return ApiResultMapper.ToApiResult(result);
|
||||
});
|
||||
|
||||
group.MapGet("/characters/current-campaign", (HttpContext context, IGameService game) =>
|
||||
group.MapGet("/characters/{characterId:guid}/sheet", (Guid characterId, HttpContext context, IGameService game) =>
|
||||
{
|
||||
var result = game.GetCurrentCampaignCharacters(context.GetRequiredSessionToken());
|
||||
var result = game.GetCharacterSheet(context.GetRequiredSessionToken(), characterId);
|
||||
return ApiResultMapper.ToApiResult(result);
|
||||
});
|
||||
|
||||
group.MapGet("/users/usernames", (HttpContext context, IGameService game) =>
|
||||
{
|
||||
var result = game.GetUsernames(context.GetRequiredSessionToken());
|
||||
return ApiResultMapper.ToApiResult(result);
|
||||
});
|
||||
|
||||
group.MapGet("/characters", (HttpContext context, IGameService game) =>
|
||||
{
|
||||
var result = game.GetOwnCharacters(context.GetRequiredSessionToken());
|
||||
return ApiResultMapper.ToApiResult(result);
|
||||
});
|
||||
|
||||
|
||||
22
RpgRoller/Api/FrontendEntryEndpoints.cs
Normal file
22
RpgRoller/Api/FrontendEntryEndpoints.cs
Normal 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!);
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,7 @@ internal sealed class RequireSessionTokenFilter : IEndpointFilter
|
||||
public ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
|
||||
{
|
||||
if (!context.HttpContext.TryReadSessionTokenFromCookie(out var sessionToken))
|
||||
{
|
||||
return ValueTask.FromResult<object?>(TypedResults.Unauthorized());
|
||||
}
|
||||
|
||||
context.HttpContext.StoreSessionToken(sessionToken);
|
||||
return next(context);
|
||||
|
||||
17
RpgRoller/Api/RollEndpoints.cs
Normal file
17
RpgRoller/Api/RollEndpoints.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
using RpgRoller.Services;
|
||||
|
||||
namespace RpgRoller.Api;
|
||||
|
||||
internal static class RollEndpoints
|
||||
{
|
||||
public static RouteGroupBuilder MapRollEndpoints(this RouteGroupBuilder group)
|
||||
{
|
||||
group.MapGet("/rolls/{rollId:guid}", (Guid rollId, HttpContext context, IGameService game) =>
|
||||
{
|
||||
var result = game.GetRollDetail(context.GetRequiredSessionToken(), rollId);
|
||||
return ApiResultMapper.ToApiResult(result);
|
||||
});
|
||||
|
||||
return group;
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,6 @@ namespace RpgRoller.Api;
|
||||
|
||||
internal static class SessionTokenHttpContextExtensions
|
||||
{
|
||||
private const string SessionTokenItemKey = "__rpgroller.session-token";
|
||||
|
||||
public static bool TryReadSessionTokenFromCookie(this HttpContext context, out string sessionToken)
|
||||
{
|
||||
sessionToken = context.Request.Cookies[SessionCookie.Name] ?? string.Empty;
|
||||
@@ -17,13 +15,11 @@ internal static class SessionTokenHttpContextExtensions
|
||||
|
||||
public static string GetRequiredSessionToken(this HttpContext context)
|
||||
{
|
||||
if (context.Items.TryGetValue(SessionTokenItemKey, out var token)
|
||||
&& token is string sessionToken
|
||||
&& !string.IsNullOrWhiteSpace(sessionToken))
|
||||
{
|
||||
if (context.Items.TryGetValue(SessionTokenItemKey, out var token) && token is string sessionToken && !string.IsNullOrWhiteSpace(sessionToken))
|
||||
return sessionToken;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Session token is not available in this request.");
|
||||
}
|
||||
|
||||
private const string SessionTokenItemKey = "__rpgroller.session-token";
|
||||
}
|
||||
@@ -9,19 +9,49 @@ internal static class SkillEndpoints
|
||||
{
|
||||
group.MapPost("/characters/{characterId:guid}/skills", (Guid characterId, CreateSkillRequest request, HttpContext context, IGameService game) =>
|
||||
{
|
||||
var result = game.CreateSkill(context.GetRequiredSessionToken(), characterId, request.Name, request.DiceRollDefinition, request.WildDice, request.AllowFumble);
|
||||
var result = game.CreateSkill(context.GetRequiredSessionToken(), characterId, request.Name, request.DiceRollDefinition, request.WildDice, request.AllowFumble, request.SkillGroupId, request.FumbleRange, request.RolemasterAutoRetry);
|
||||
return ApiResultMapper.ToApiResult(result);
|
||||
});
|
||||
|
||||
group.MapPut("/skills/{skillId:guid}", (Guid skillId, UpdateSkillRequest request, HttpContext context, IGameService game) =>
|
||||
{
|
||||
var result = game.UpdateSkill(context.GetRequiredSessionToken(), skillId, request.Name, request.DiceRollDefinition, request.WildDice, request.AllowFumble);
|
||||
var result = game.UpdateSkill(context.GetRequiredSessionToken(), skillId, request.Name, request.DiceRollDefinition, request.WildDice, request.AllowFumble, request.SkillGroupId, request.FumbleRange, request.RolemasterAutoRetry);
|
||||
return ApiResultMapper.ToApiResult(result);
|
||||
});
|
||||
|
||||
group.MapDelete("/skills/{skillId:guid}", (Guid skillId, HttpContext context, IGameService game) =>
|
||||
{
|
||||
var result = game.DeleteSkill(context.GetRequiredSessionToken(), skillId);
|
||||
return ApiResultMapper.ToApiResult(result);
|
||||
});
|
||||
|
||||
group.MapPost("/characters/{characterId:guid}/skill-groups", (Guid characterId, CreateSkillGroupRequest request, HttpContext context, IGameService game) =>
|
||||
{
|
||||
var result = game.CreateSkillGroup(context.GetRequiredSessionToken(), characterId, request.Name, request.DiceRollDefinition, request.WildDice, request.AllowFumble, request.FumbleRange);
|
||||
return ApiResultMapper.ToApiResult(result);
|
||||
});
|
||||
|
||||
group.MapPut("/skill-groups/{skillGroupId:guid}", (Guid skillGroupId, UpdateSkillGroupRequest request, HttpContext context, IGameService game) =>
|
||||
{
|
||||
var result = game.UpdateSkillGroup(context.GetRequiredSessionToken(), skillGroupId, request.Name, request.DiceRollDefinition, request.WildDice, request.AllowFumble, request.FumbleRange);
|
||||
return ApiResultMapper.ToApiResult(result);
|
||||
});
|
||||
|
||||
group.MapDelete("/skill-groups/{skillGroupId:guid}", (Guid skillGroupId, HttpContext context, IGameService game) =>
|
||||
{
|
||||
var result = game.DeleteSkillGroup(context.GetRequiredSessionToken(), skillGroupId);
|
||||
return ApiResultMapper.ToApiResult(result);
|
||||
});
|
||||
|
||||
group.MapPost("/skills/{skillId:guid}/roll", (Guid skillId, RollSkillRequest request, HttpContext context, IGameService game) =>
|
||||
{
|
||||
var result = game.RollSkill(context.GetRequiredSessionToken(), skillId, request.Visibility);
|
||||
var result = game.RollSkill(context.GetRequiredSessionToken(), skillId, request.Visibility, request.SituationalModifier);
|
||||
return ApiResultMapper.ToApiResult(result);
|
||||
});
|
||||
|
||||
group.MapPost("/characters/{characterId:guid}/custom-rolls", (Guid characterId, CustomRollRequest request, HttpContext context, IGameService game) =>
|
||||
{
|
||||
var result = game.RollCustom(context.GetRequiredSessionToken(), characterId, request.Expression, request.Visibility);
|
||||
return ApiResultMapper.ToApiResult(result);
|
||||
});
|
||||
|
||||
|
||||
@@ -7,26 +7,19 @@ internal static class StateEventEndpoints
|
||||
{
|
||||
public static RouteGroupBuilder MapStateEventEndpoints(this RouteGroupBuilder group)
|
||||
{
|
||||
group.MapGet("/events/state", async Task<IResult> (
|
||||
Guid campaignId,
|
||||
HttpContext context,
|
||||
IGameService game) =>
|
||||
group.MapGet("/events/state", async Task<IResult> (Guid campaignId, HttpContext context, IGameService game) =>
|
||||
{
|
||||
var sessionToken = context.GetRequiredSessionToken();
|
||||
var versionResult = game.GetCampaignVersion(sessionToken, campaignId);
|
||||
if (!versionResult.Succeeded)
|
||||
{
|
||||
return versionResult.Error!.Code == "unauthorized"
|
||||
? TypedResults.Unauthorized()
|
||||
: TypedResults.BadRequest(new ApiError(versionResult.Error.Message));
|
||||
}
|
||||
var stateResult = game.GetCampaignStateSnapshot(sessionToken, campaignId);
|
||||
if (!stateResult.Succeeded)
|
||||
return stateResult.Error!.Code == "unauthorized" ? TypedResults.Unauthorized() : TypedResults.BadRequest(new ApiError(stateResult.Error.Message, stateResult.Error.Code));
|
||||
|
||||
context.Response.Headers.CacheControl = "no-cache";
|
||||
context.Response.Headers.Connection = "keep-alive";
|
||||
context.Response.ContentType = "text/event-stream";
|
||||
|
||||
var lastVersion = versionResult.Value;
|
||||
await context.Response.WriteAsync($"event: state\ndata: {{\"campaignId\":\"{campaignId}\",\"version\":{lastVersion}}}\n\n");
|
||||
var lastState = stateResult.Value!;
|
||||
await WriteStateEventAsync(context.Response, lastState);
|
||||
await context.Response.Body.FlushAsync();
|
||||
|
||||
try
|
||||
@@ -35,21 +28,18 @@ internal static class StateEventEndpoints
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(1), context.RequestAborted);
|
||||
|
||||
var currentVersionResult = game.GetCampaignVersion(sessionToken, campaignId);
|
||||
if (!currentVersionResult.Succeeded)
|
||||
{
|
||||
var currentStateResult = game.GetCampaignStateSnapshot(sessionToken, campaignId);
|
||||
if (!currentStateResult.Succeeded)
|
||||
break;
|
||||
}
|
||||
|
||||
if (currentVersionResult.Value != lastVersion)
|
||||
var currentState = currentStateResult.Value!;
|
||||
if (currentState.TotalVersion != lastState.TotalVersion)
|
||||
{
|
||||
lastVersion = currentVersionResult.Value;
|
||||
await context.Response.WriteAsync($"event: state\ndata: {{\"campaignId\":\"{campaignId}\",\"version\":{lastVersion}}}\n\n");
|
||||
lastState = currentState;
|
||||
await WriteStateEventAsync(context.Response, currentState);
|
||||
}
|
||||
else
|
||||
{
|
||||
await context.Response.WriteAsync(": heartbeat\n\n");
|
||||
}
|
||||
|
||||
await context.Response.Body.FlushAsync();
|
||||
}
|
||||
@@ -63,4 +53,11 @@ internal static class StateEventEndpoints
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
private static Task WriteStateEventAsync(HttpResponse response, CampaignStateSnapshot snapshot)
|
||||
{
|
||||
var characterVersions = string.Join(",", snapshot.CharacterVersions.Select(version => $"{{\"characterId\":\"{version.CharacterId}\",\"version\":{version.Version}}}"));
|
||||
|
||||
return response.WriteAsync($"event: state\ndata: {{\"campaignId\":\"{snapshot.CampaignId}\",\"totalVersion\":{snapshot.TotalVersion},\"rosterVersion\":{snapshot.RosterVersion},\"logVersion\":{snapshot.LogVersion},\"characterVersions\":[{characterVersions}]}}\n\n");
|
||||
}
|
||||
}
|
||||
BIN
RpgRoller/App_Data/rpgroller.development.db
Normal file
BIN
RpgRoller/App_Data/rpgroller.development.db
Normal file
Binary file not shown.
@@ -1,19 +1,86 @@
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using RpgRoller.Components.Pages.HomeControls
|
||||
@attribute [ExcludeFromCodeCoverage]
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<base href="/" />
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<base href="@BaseHref"/>
|
||||
<title>RpgRoller</title>
|
||||
<link rel="stylesheet" href="/styles.css" />
|
||||
<HeadOutlet @rendermode="InteractiveServer" />
|
||||
<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">
|
||||
@if (UseInteractiveApp)
|
||||
{
|
||||
<HeadOutlet/>
|
||||
}
|
||||
</head>
|
||||
<body>
|
||||
<Routes @rendermode="InteractiveServer" />
|
||||
<script src="/js/rpgroller-api.js"></script>
|
||||
<script src="_framework/blazor.web.js"></script>
|
||||
@if (UseStaticAuthPage)
|
||||
{
|
||||
<StaticAuthPage StatusMessage="@AuthStatusMessage" StatusIsError="@AuthStatusIsError"/>
|
||||
}
|
||||
else
|
||||
{
|
||||
<Routes/>
|
||||
}
|
||||
<script src="js/rpgroller-api.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; }
|
||||
|
||||
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
|
||||
{
|
||||
get
|
||||
{
|
||||
var pathBase = HttpContext?.Request.PathBase.Value;
|
||||
if (string.IsNullOrWhiteSpace(pathBase))
|
||||
return "/";
|
||||
|
||||
return pathBase.EndsWith('/') ? pathBase : $"{pathBase}/";
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
12
RpgRoller/Components/Pages/AdminPage.razor
Normal file
12
RpgRoller/Components/Pages/AdminPage.razor
Normal 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>
|
||||
8
RpgRoller/Components/Pages/AdminPage.razor.cs
Normal file
8
RpgRoller/Components/Pages/AdminPage.razor.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace RpgRoller.Components.Pages;
|
||||
|
||||
[ExcludeFromCodeCoverage]
|
||||
public partial class AdminPage
|
||||
{
|
||||
}
|
||||
70
RpgRoller/Components/Pages/AdminWorkspaceContent.razor
Normal file
70
RpgRoller/Components/Pages/AdminWorkspaceContent.razor
Normal 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;
|
||||
}
|
||||
29
RpgRoller/Components/Pages/AuthenticatedPageBase.cs
Normal file
29
RpgRoller/Components/Pages/AuthenticatedPageBase.cs
Normal 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!;
|
||||
}
|
||||
12
RpgRoller/Components/Pages/CampaignsPage.razor
Normal file
12
RpgRoller/Components/Pages/CampaignsPage.razor
Normal 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>
|
||||
8
RpgRoller/Components/Pages/CampaignsPage.razor.cs
Normal file
8
RpgRoller/Components/Pages/CampaignsPage.razor.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace RpgRoller.Components.Pages;
|
||||
|
||||
[ExcludeFromCodeCoverage]
|
||||
public partial class CampaignsPage
|
||||
{
|
||||
}
|
||||
31
RpgRoller/Components/Pages/CampaignsWorkspaceContent.razor
Normal file
31
RpgRoller/Components/Pages/CampaignsWorkspaceContent.razor
Normal file
@@ -0,0 +1,31 @@
|
||||
@using Microsoft.AspNetCore.Components
|
||||
@using RpgRoller.Components.Pages.HomeControls
|
||||
|
||||
<CampaignManagementPanel
|
||||
Campaigns="Workspace.State.Campaigns"
|
||||
SelectedCampaignId="Workspace.State.SelectedCampaignId"
|
||||
SelectedCampaign="Workspace.State.SelectedCampaign"
|
||||
Rulesets="Workspace.State.Rulesets"
|
||||
IsMutating="Workspace.State.IsMutating"
|
||||
OwnerLabel="Workspace.State.OwnerLabel"
|
||||
CanEditCharacter="Workspace.Campaigns.CanEditCharacter"
|
||||
CanDeleteCharacter="Workspace.Campaigns.CanDeleteCharacter"
|
||||
CanDeleteCampaign="Workspace.State.CanDeleteSelectedCampaign"
|
||||
CampaignSelectionChanged="OnCampaignSelectionChangedAsync"
|
||||
CampaignCreated="Workspace.Campaigns.OnCampaignCreatedAsync"
|
||||
DeleteCampaignRequested="Workspace.Campaigns.DeleteSelectedCampaignAsync"
|
||||
CreateCharacterRequested="Workspace.Campaigns.OpenCreateCharacterModal"
|
||||
EditCharacterRequested="Workspace.Campaigns.OpenEditCharacterModal"
|
||||
DeleteCharacterRequested="Workspace.Campaigns.DeleteCharacterAsync"/>
|
||||
|
||||
<CharacterManagementModals Workspace="Workspace"/>
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired] public WorkspacePageContext Workspace { get; set; } = null!;
|
||||
|
||||
private async Task OnCampaignSelectionChangedAsync(ChangeEventArgs args)
|
||||
{
|
||||
await Workspace.Campaigns.OnCampaignSelectionChangedAsync(args);
|
||||
await Workspace.RequestRefreshAsync();
|
||||
}
|
||||
}
|
||||
40
RpgRoller/Components/Pages/CharacterManagementModals.razor
Normal file
40
RpgRoller/Components/Pages/CharacterManagementModals.razor
Normal 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!;
|
||||
}
|
||||
74
RpgRoller/Components/Pages/Home.Models.cs
Normal file
74
RpgRoller/Components/Pages/Home.Models.cs
Normal file
@@ -0,0 +1,74 @@
|
||||
namespace RpgRoller.Components.Pages;
|
||||
|
||||
public sealed class FormState<TModel> where TModel : new()
|
||||
{
|
||||
public void ResetValidation()
|
||||
{
|
||||
Errors.Clear();
|
||||
ErrorMessage = null;
|
||||
}
|
||||
|
||||
public TModel Model { get; } = new();
|
||||
public Dictionary<string, string> Errors { get; } = [];
|
||||
public string? ErrorMessage { get; set; }
|
||||
}
|
||||
|
||||
public sealed class RegisterFormModel
|
||||
{
|
||||
public string Username { get; set; } = string.Empty;
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
public string Password { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class LoginFormModel
|
||||
{
|
||||
public string Username { get; set; } = string.Empty;
|
||||
public string Password { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class CampaignFormModel
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string RulesetId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class CharacterFormModel
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string CampaignId { get; set; } = string.Empty;
|
||||
public string OwnerUsername { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class SkillFormModel
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string RulesetId { get; set; } = string.Empty;
|
||||
public string DiceRollDefinition { get; set; } = string.Empty;
|
||||
public string SkillGroupId { get; set; } = string.Empty;
|
||||
public int WildDice { get; set; }
|
||||
public bool AllowFumble { get; set; }
|
||||
public int? FumbleRange { get; set; }
|
||||
public bool RolemasterAutoRetry { get; set; }
|
||||
}
|
||||
|
||||
public sealed class SkillGroupFormModel
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string RulesetId { get; set; } = string.Empty;
|
||||
public string DiceRollDefinition { get; set; } = string.Empty;
|
||||
public int WildDice { get; set; }
|
||||
public bool AllowFumble { get; set; }
|
||||
public int? FumbleRange { get; set; }
|
||||
}
|
||||
|
||||
public sealed class CustomRollFormModel
|
||||
{
|
||||
public string Expression { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public enum HomeViewMode
|
||||
{
|
||||
Loading,
|
||||
Anonymous,
|
||||
Workspace
|
||||
}
|
||||
@@ -1,502 +0,0 @@
|
||||
@page "/"
|
||||
@implements IAsyncDisposable
|
||||
|
||||
<div class="rr-app">
|
||||
<p class="sr-only" aria-live="polite">@LiveAnnouncement</p>
|
||||
|
||||
@if (!IsInitialized)
|
||||
{
|
||||
<main class="loading-shell" aria-busy="true" aria-live="polite">
|
||||
<h1>RpgRoller</h1>
|
||||
<p>Connecting...</p>
|
||||
</main>
|
||||
}
|
||||
else
|
||||
{
|
||||
@if (HasHealthIssue)
|
||||
{
|
||||
<section class="health-banner" role="alert">
|
||||
<div>
|
||||
<strong>API currently unavailable.</strong>
|
||||
<p>@HealthIssueMessage</p>
|
||||
</div>
|
||||
<button type="button" @onclick="RetryAfterHealthIssueAsync">Retry</button>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (User is null)
|
||||
{
|
||||
<main class="auth-shell">
|
||||
<h1>RpgRoller</h1>
|
||||
<p class="auth-subtitle">Register or log in to join a campaign session.</p>
|
||||
@if (!string.IsNullOrWhiteSpace(StatusMessage))
|
||||
{
|
||||
<p class="status-message @(StatusIsError ? "error" : "success")">@StatusMessage</p>
|
||||
}
|
||||
<div class="auth-grid">
|
||||
<section class="card auth-card">
|
||||
<h2>Register</h2>
|
||||
@if (!string.IsNullOrWhiteSpace(RegisterFormError))
|
||||
{
|
||||
<p class="form-error">@RegisterFormError</p>
|
||||
}
|
||||
<form class="form-grid" @onsubmit="RegisterAsync" @onsubmit:preventDefault>
|
||||
<label for="register-username">Username</label>
|
||||
<input id="register-username" @bind="RegisterForm.Username" @bind:event="oninput" autocomplete="username" />
|
||||
@if (RegisterErrors.TryGetValue("username", out var registerUsernameError))
|
||||
{
|
||||
<p class="field-error">@registerUsernameError</p>
|
||||
}
|
||||
<label for="register-display-name">Display name</label>
|
||||
<input id="register-display-name" @bind="RegisterForm.DisplayName" @bind:event="oninput" autocomplete="name" />
|
||||
@if (RegisterErrors.TryGetValue("displayName", out var registerDisplayNameError))
|
||||
{
|
||||
<p class="field-error">@registerDisplayNameError</p>
|
||||
}
|
||||
<label for="register-password">Password</label>
|
||||
<input id="register-password" type="password" @bind="RegisterForm.Password" @bind:event="oninput" autocomplete="new-password" />
|
||||
@if (RegisterErrors.TryGetValue("password", out var registerPasswordError))
|
||||
{
|
||||
<p class="field-error">@registerPasswordError</p>
|
||||
}
|
||||
<button type="submit" disabled="@IsMutating">@(IsMutating ? "Registering..." : "Register")</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card auth-card">
|
||||
<h2>Login</h2>
|
||||
@if (!string.IsNullOrWhiteSpace(LoginFormError))
|
||||
{
|
||||
<p class="form-error">@LoginFormError</p>
|
||||
}
|
||||
<form class="form-grid" @onsubmit="LoginAsync" @onsubmit:preventDefault>
|
||||
<label for="login-username">Username</label>
|
||||
<input id="login-username" @bind="LoginForm.Username" @bind:event="oninput" autocomplete="username" />
|
||||
@if (LoginErrors.TryGetValue("username", out var loginUsernameError))
|
||||
{
|
||||
<p class="field-error">@loginUsernameError</p>
|
||||
}
|
||||
<label for="login-password">Password</label>
|
||||
<input id="login-password" type="password" @bind="LoginForm.Password" @bind:event="oninput" autocomplete="current-password" />
|
||||
@if (LoginErrors.TryGetValue("password", out var loginPasswordError))
|
||||
{
|
||||
<p class="field-error">@loginPasswordError</p>
|
||||
}
|
||||
<button type="submit" disabled="@IsMutating">@(IsMutating ? "Logging in..." : "Login")</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="workspace-shell">
|
||||
<header class="workspace-header">
|
||||
<div class="header-group brand">
|
||||
<h1>RpgRoller</h1>
|
||||
<p>Tabletop utility cockpit</p>
|
||||
</div>
|
||||
<div class="header-group context">
|
||||
<p><strong>@User.DisplayName</strong> <span class="muted">(@User.Username)</span></p>
|
||||
<p>Campaign: <strong>@(SelectedCampaignName ?? "No campaign selected")</strong></p>
|
||||
<p>Active: <strong>@(ActiveCharacterName ?? "None selected")</strong></p>
|
||||
</div>
|
||||
<div class="header-group controls">
|
||||
<p class="connection @ConnectionStateCssClass">@ConnectionStateLabel</p>
|
||||
<div class="switch-group" role="tablist" aria-label="Screen selector">
|
||||
<button type="button" class="switch @(CurrentScreen == "play" ? "active" : string.Empty)" @onclick="SwitchToPlayAsync">Play</button>
|
||||
<button type="button" class="switch @(CurrentScreen == "management" ? "active" : string.Empty)" @onclick="SwitchToManagementAsync">Campaign Management</button>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button type="button" @onclick="ManualRefreshAsync" disabled="@IsMutating">Refresh</button>
|
||||
<button type="button" class="ghost" @onclick="LogoutAsync" disabled="@IsMutating">Logout</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(StatusMessage))
|
||||
{
|
||||
<p class="status-message @(StatusIsError ? "error" : "success")">@StatusMessage</p>
|
||||
}
|
||||
|
||||
@if (CurrentScreen == "play")
|
||||
{
|
||||
<main class="play-screen @(MobilePanel == "log" ? "mobile-log" : "mobile-character")">
|
||||
<section class="card character-panel">
|
||||
<div class="section-head"><h2>Character Context</h2></div>
|
||||
@if (IsCampaignDataLoading)
|
||||
{
|
||||
<div class="skeleton-stack"><div class="skeleton-line"></div><div class="skeleton-line short"></div><div class="skeleton-line"></div></div>
|
||||
}
|
||||
else if (SelectedCampaign is null)
|
||||
{
|
||||
<p class="empty">No campaign selected. Choose one in Campaign Management.</p>
|
||||
}
|
||||
else if (SelectedCampaign.Characters.Count == 0)
|
||||
{
|
||||
<p class="empty">No characters in this campaign yet.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="character-picker" role="tablist" aria-label="Character picker">
|
||||
@foreach (var character in SelectedCampaign.Characters)
|
||||
{
|
||||
var isSelectedCharacter = SelectedCharacterId == character.Id;
|
||||
<button type="button" class="icon-tab @(isSelectedCharacter ? "active" : string.Empty)" aria-label="@character.Name" @onclick="() => SelectCharacter(character.Id)">
|
||||
<span class="icon-tab-glyph">@InitialsFor(character.Name)</span>
|
||||
<span class="icon-tab-text">@character.Name</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@if (SelectedCharacter is not null)
|
||||
{
|
||||
<article class="character-sheet">
|
||||
<h3>@SelectedCharacter.Name</h3>
|
||||
<p>Owner: @OwnerLabel(SelectedCharacter.OwnerUserId)</p>
|
||||
<p>Campaign: @SelectedCampaign.Name</p>
|
||||
@if (SelectedCharacter.Id == ActiveCharacterId)
|
||||
{
|
||||
<span class="badge active">Active</span>
|
||||
}
|
||||
<div class="inline-actions">
|
||||
<button type="button" disabled="@(IsMutating || !CanEditCharacter(SelectedCharacter))" @onclick="() => OpenEditCharacterModal(SelectedCharacter)">Edit Character</button>
|
||||
<button type="button" disabled="@(IsMutating || !CanActivateCharacter(SelectedCharacter))" @onclick="() => ActivateCharacterAsync(SelectedCharacter.Id)">Activate Character</button>
|
||||
</div>
|
||||
</article>
|
||||
<article class="skills-section">
|
||||
<div class="section-head">
|
||||
<h3>Skills</h3>
|
||||
<div class="inline-actions">
|
||||
<button type="button" disabled="@(IsMutating || !CanEditCharacter(SelectedCharacter))" @onclick="OpenCreateSkillModal">Create Skill</button>
|
||||
<button type="button" disabled="@(IsMutating || SelectedSkill is null || !CanEditSkill(SelectedSkill))" @onclick="OpenEditSkillModal">Edit Skill</button>
|
||||
</div>
|
||||
</div>
|
||||
@if (SelectedCharacterSkills.Count == 0)
|
||||
{
|
||||
<p class="empty">No skills for this character yet.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="skill-list">
|
||||
@foreach (var skill in SelectedCharacterSkills)
|
||||
{
|
||||
var isSelectedSkill = SelectedSkillId == skill.Id;
|
||||
<button type="button" class="skill-item @(isSelectedSkill ? "active" : string.Empty)" @onclick="() => SelectSkill(skill.Id)">
|
||||
<strong>@skill.Name</strong><span>@SkillDefinitionLabel(skill)</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<form class="roll-panel" @onsubmit="RollSelectedSkillAsync" @onsubmit:preventDefault>
|
||||
<label for="roll-visibility">Visibility</label>
|
||||
<select id="roll-visibility" @bind="RollVisibility">
|
||||
<option value="public">Public</option>
|
||||
<option value="private">Private</option>
|
||||
</select>
|
||||
<button type="submit" disabled="@(IsMutating || SelectedSkill is null || !CanRollSkill(SelectedSkill))">Roll Skill</button>
|
||||
</form>
|
||||
</article>
|
||||
}
|
||||
}
|
||||
<article class="last-roll">
|
||||
<h3>Last Roll</h3>
|
||||
@if (LastRoll is null)
|
||||
{
|
||||
<p class="empty">No roll yet.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="roll-total">@LastRoll.Result</p>
|
||||
@if (LastRoll.Dice.Count > 0)
|
||||
{
|
||||
<div class="roll-dice-strip" aria-label="Rolled dice">
|
||||
@foreach (var die in LastRoll.Dice)
|
||||
{
|
||||
<span class="@RollDieCssClass(die)" title="@RollDieTitle(die)">@RollDieGlyph(die.Roll)</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<p>@LastRoll.Breakdown</p>
|
||||
<p><span class="badge @(LastRoll.Visibility == "private" ? "private-self" : "public")">@LastRoll.Visibility</span> <time title="@LastRoll.TimestampUtc.ToString("O")">@LastRoll.TimestampUtc.ToLocalTime().ToString("g")</time></p>
|
||||
}
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<aside class="card log-panel">
|
||||
<div class="section-head"><h2>Campaign Log</h2></div>
|
||||
@if (IsCampaignDataLoading)
|
||||
{
|
||||
<div class="skeleton-stack"><div class="skeleton-line"></div><div class="skeleton-line"></div><div class="skeleton-line short"></div></div>
|
||||
}
|
||||
else if (CampaignLog.Count == 0)
|
||||
{
|
||||
<p class="empty">No log entries yet.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<ul class="log-list">
|
||||
@foreach (var entry in CampaignLog)
|
||||
{
|
||||
<li class="log-entry @LogEntryCssClass(entry)">
|
||||
<p><strong>@RollerLabel(entry)</strong> rolled <strong>@SkillLabel(entry.SkillId)</strong> with <strong>@CharacterLabel(entry.CharacterId)</strong></p>
|
||||
<p>@entry.Breakdown</p>
|
||||
<p class="log-meta"><span class="badge @VisibilityBadgeCssClass(entry)">@VisibilityLabel(entry)</span> <time title="@entry.TimestampUtc.ToString("O")">@entry.TimestampUtc.ToLocalTime().ToString("g")</time></p>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</aside>
|
||||
</main>
|
||||
<nav class="mobile-bottom-nav" aria-label="Play panel selector">
|
||||
<button type="button" class="switch @(MobilePanel == "character" ? "active" : string.Empty)" @onclick="SetMobilePanelCharacterAsync">Character</button>
|
||||
<button type="button" class="switch @(MobilePanel == "log" ? "active" : string.Empty)" @onclick="SetMobilePanelLogAsync">Log</button>
|
||||
</nav>
|
||||
}
|
||||
else
|
||||
{
|
||||
<main class="management-screen">
|
||||
<section class="card">
|
||||
<h2>Campaign Selector</h2>
|
||||
@if (Campaigns.Count == 0)
|
||||
{
|
||||
<p class="empty">No campaigns yet.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<label for="campaign-select">Campaign</label>
|
||||
<select id="campaign-select" @onchange="OnCampaignSelectionChangedAsync">
|
||||
@foreach (var campaign in Campaigns)
|
||||
{
|
||||
<option value="@campaign.Id" selected="@(campaign.Id == SelectedCampaignId)">@campaign.Name (@campaign.RulesetId)</option>
|
||||
}
|
||||
</select>
|
||||
}
|
||||
<p class="muted">Current campaign in this tab: <strong>@(SelectedCampaignName ?? "None selected")</strong></p>
|
||||
</section>
|
||||
<section class="card">
|
||||
<h2>Create Campaign</h2>
|
||||
@if (!string.IsNullOrWhiteSpace(CampaignFormError))
|
||||
{
|
||||
<p class="form-error">@CampaignFormError</p>
|
||||
}
|
||||
<form class="form-grid" @onsubmit="CreateCampaignAsync" @onsubmit:preventDefault>
|
||||
<label for="campaign-name">Campaign name</label>
|
||||
<input id="campaign-name" @bind="CampaignForm.Name" @bind:event="oninput" />
|
||||
@if (CampaignErrors.TryGetValue("name", out var campaignNameError))
|
||||
{
|
||||
<p class="field-error">@campaignNameError</p>
|
||||
}
|
||||
<label for="campaign-ruleset">Ruleset</label>
|
||||
<select id="campaign-ruleset" @bind="CampaignForm.RulesetId">
|
||||
<option value="">Select ruleset</option>
|
||||
@foreach (var ruleset in Rulesets)
|
||||
{
|
||||
<option value="@ruleset.Id">@ruleset.Name</option>
|
||||
}
|
||||
</select>
|
||||
@if (CampaignErrors.TryGetValue("rulesetId", out var campaignRulesetError))
|
||||
{
|
||||
<p class="field-error">@campaignRulesetError</p>
|
||||
}
|
||||
<button type="submit" disabled="@IsMutating">@(IsMutating ? "Creating..." : "Create Campaign")</button>
|
||||
</form>
|
||||
</section>
|
||||
<section class="card">
|
||||
<h2>Campaign Details</h2>
|
||||
@if (SelectedCampaign is null)
|
||||
{
|
||||
<p class="empty">No campaign selected.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p>Name: <strong>@SelectedCampaign.Name</strong></p>
|
||||
<p>Ruleset: <strong>@SelectedCampaign.RulesetId</strong></p>
|
||||
<p>GM: <strong>@SelectedCampaign.Gm.DisplayName</strong> <span class="muted">(@SelectedCampaign.Gm.Username)</span></p>
|
||||
<p>Characters visible: <strong>@SelectedCampaign.Characters.Count</strong></p>
|
||||
}
|
||||
</section>
|
||||
<section class="card">
|
||||
<div class="section-head">
|
||||
<h2>Character Management</h2>
|
||||
<button type="button" disabled="@(IsMutating || SelectedCampaign is null)" @onclick="OpenCreateCharacterModal">Create Character</button>
|
||||
</div>
|
||||
@if (SelectedCampaign is null)
|
||||
{
|
||||
<p class="empty">Select a campaign first.</p>
|
||||
}
|
||||
else if (SelectedCampaign.Characters.Count == 0)
|
||||
{
|
||||
<p class="empty">No characters in this campaign yet.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<ul class="management-list">
|
||||
@foreach (var character in SelectedCampaign.Characters)
|
||||
{
|
||||
<li>
|
||||
<div><strong>@character.Name</strong><p class="muted">Owner: @OwnerLabel(character.OwnerUserId)</p></div>
|
||||
<div class="inline-actions">
|
||||
<button type="button" disabled="@(IsMutating || !CanEditCharacter(character))" @onclick="() => OpenEditCharacterModal(character)">Edit</button>
|
||||
<button type="button" disabled="@(IsMutating || !CanActivateCharacter(character))" @onclick="() => ActivateCharacterAsync(character.Id)">Activate</button>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</section>
|
||||
</main>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (ShowCreateCharacterModal)
|
||||
{
|
||||
<div class="modal-overlay" role="presentation">
|
||||
<section class="modal-card" role="dialog" aria-modal="true" aria-label="Create character">
|
||||
<h2>Create Character</h2>
|
||||
@if (!string.IsNullOrWhiteSpace(CharacterFormError))
|
||||
{
|
||||
<p class="form-error">@CharacterFormError</p>
|
||||
}
|
||||
<form class="form-grid" @onsubmit="CreateCharacterAsync" @onsubmit:preventDefault>
|
||||
<label for="character-create-name">Character name</label>
|
||||
<input id="character-create-name" @bind="CharacterForm.Name" @bind:event="oninput" />
|
||||
@if (CharacterErrors.TryGetValue("name", out var createCharacterNameError))
|
||||
{
|
||||
<p class="field-error">@createCharacterNameError</p>
|
||||
}
|
||||
<label for="character-create-campaign">Campaign</label>
|
||||
<select id="character-create-campaign" @bind="CharacterForm.CampaignId">
|
||||
<option value="">Select campaign</option>
|
||||
@foreach (var campaign in Campaigns)
|
||||
{
|
||||
<option value="@campaign.Id">@campaign.Name</option>
|
||||
}
|
||||
</select>
|
||||
<div class="inline-actions">
|
||||
<button type="submit" disabled="@IsMutating">Create Character</button>
|
||||
<button type="button" class="ghost" @onclick="CloseCharacterModals">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (ShowEditCharacterModal)
|
||||
{
|
||||
<div class="modal-overlay" role="presentation">
|
||||
<section class="modal-card" role="dialog" aria-modal="true" aria-label="Edit character">
|
||||
<h2>Edit Character</h2>
|
||||
@if (!string.IsNullOrWhiteSpace(EditCharacterFormError))
|
||||
{
|
||||
<p class="form-error">@EditCharacterFormError</p>
|
||||
}
|
||||
<form class="form-grid" @onsubmit="UpdateCharacterAsync" @onsubmit:preventDefault>
|
||||
<label for="character-edit-name">Character name</label>
|
||||
<input id="character-edit-name" @bind="EditCharacterForm.Name" @bind:event="oninput" />
|
||||
@if (EditCharacterErrors.TryGetValue("name", out var editCharacterNameError))
|
||||
{
|
||||
<p class="field-error">@editCharacterNameError</p>
|
||||
}
|
||||
<label for="character-edit-campaign">Campaign</label>
|
||||
<select id="character-edit-campaign" @bind="EditCharacterForm.CampaignId">
|
||||
<option value="">Select campaign</option>
|
||||
@foreach (var campaign in Campaigns)
|
||||
{
|
||||
<option value="@campaign.Id">@campaign.Name</option>
|
||||
}
|
||||
</select>
|
||||
<div class="inline-actions">
|
||||
<button type="submit" disabled="@IsMutating">Save Character</button>
|
||||
<button type="button" class="ghost" @onclick="CloseCharacterModals">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (ShowCreateSkillModal)
|
||||
{
|
||||
<div class="modal-overlay" role="presentation">
|
||||
<section class="modal-card" role="dialog" aria-modal="true" aria-label="Create skill">
|
||||
<h2>Create Skill</h2>
|
||||
@if (!string.IsNullOrWhiteSpace(SkillFormError))
|
||||
{
|
||||
<p class="form-error">@SkillFormError</p>
|
||||
}
|
||||
<form class="form-grid" @onsubmit="CreateSkillAsync" @onsubmit:preventDefault>
|
||||
<label for="skill-create-name">Skill name</label>
|
||||
<input id="skill-create-name" @bind="SkillForm.Name" @bind:event="oninput" />
|
||||
@if (SkillErrors.TryGetValue("name", out var createSkillNameError))
|
||||
{
|
||||
<p class="field-error">@createSkillNameError</p>
|
||||
}
|
||||
<label for="skill-create-expression">Expression</label>
|
||||
<input id="skill-create-expression" @bind="SkillForm.DiceRollDefinition" @bind:event="oninput" />
|
||||
@if (SkillErrors.TryGetValue("diceRollDefinition", out var createSkillExpressionError))
|
||||
{
|
||||
<p class="field-error">@createSkillExpressionError</p>
|
||||
}
|
||||
@if (IsSelectedCampaignD6)
|
||||
{
|
||||
<label for="skill-create-wild-dice">Wild dice</label>
|
||||
<input id="skill-create-wild-dice" type="number" min="1" step="1" @bind="SkillForm.WildDice" />
|
||||
@if (SkillErrors.TryGetValue("wildDice", out var createSkillWildDiceError))
|
||||
{
|
||||
<p class="field-error">@createSkillWildDiceError</p>
|
||||
}
|
||||
<label for="skill-create-allow-fumble">Allow fumble</label>
|
||||
<input id="skill-create-allow-fumble" type="checkbox" @bind="SkillForm.AllowFumble" />
|
||||
}
|
||||
<div class="inline-actions">
|
||||
<button type="submit" disabled="@IsMutating">Create Skill</button>
|
||||
<button type="button" class="ghost" @onclick="CloseSkillModals">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (ShowEditSkillModal)
|
||||
{
|
||||
<div class="modal-overlay" role="presentation">
|
||||
<section class="modal-card" role="dialog" aria-modal="true" aria-label="Edit skill">
|
||||
<h2>Edit Skill</h2>
|
||||
@if (!string.IsNullOrWhiteSpace(EditSkillFormError))
|
||||
{
|
||||
<p class="form-error">@EditSkillFormError</p>
|
||||
}
|
||||
<form class="form-grid" @onsubmit="UpdateSkillAsync" @onsubmit:preventDefault>
|
||||
<label for="skill-edit-name">Skill name</label>
|
||||
<input id="skill-edit-name" @bind="EditSkillForm.Name" @bind:event="oninput" />
|
||||
@if (EditSkillErrors.TryGetValue("name", out var editSkillNameError))
|
||||
{
|
||||
<p class="field-error">@editSkillNameError</p>
|
||||
}
|
||||
<label for="skill-edit-expression">Expression</label>
|
||||
<input id="skill-edit-expression" @bind="EditSkillForm.DiceRollDefinition" @bind:event="oninput" />
|
||||
@if (EditSkillErrors.TryGetValue("diceRollDefinition", out var editSkillExpressionError))
|
||||
{
|
||||
<p class="field-error">@editSkillExpressionError</p>
|
||||
}
|
||||
@if (IsSelectedCampaignD6)
|
||||
{
|
||||
<label for="skill-edit-wild-dice">Wild dice</label>
|
||||
<input id="skill-edit-wild-dice" type="number" min="1" step="1" @bind="EditSkillForm.WildDice" />
|
||||
@if (EditSkillErrors.TryGetValue("wildDice", out var editSkillWildDiceError))
|
||||
{
|
||||
<p class="field-error">@editSkillWildDiceError</p>
|
||||
}
|
||||
<label for="skill-edit-allow-fumble">Allow fumble</label>
|
||||
<input id="skill-edit-allow-fumble" type="checkbox" @bind="EditSkillForm.AllowFumble" />
|
||||
}
|
||||
<div class="inline-actions">
|
||||
<button type="submit" disabled="@IsMutating">Save Skill</button>
|
||||
<button type="button" class="ghost" @onclick="CloseSkillModals">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
71
RpgRoller/Components/Pages/HomeControls/AdminHome.razor
Normal file
71
RpgRoller/Components/Pages/HomeControls/AdminHome.razor
Normal file
@@ -0,0 +1,71 @@
|
||||
<div class="rr-app">
|
||||
<div class="workspace-shell">
|
||||
<AppHeader
|
||||
User="CurrentUser"
|
||||
ShowCampaign="false"
|
||||
ShowConnectionState="true"
|
||||
ConnectionStateLabel="@(!IsLoading && CurrentUser is not null ? "Connected" : "Offline fallback")"
|
||||
ConnectionStateCssClass="@(!IsLoading && CurrentUser is not null ? "ok" : "offline")"
|
||||
IsMenuOpen="IsScreenMenuOpen"
|
||||
MenuButtonId="admin-screen-menu-button"
|
||||
MenuId="admin-screen-menu"
|
||||
MenuItems="HeaderMenuItems"
|
||||
ToggleMenuRequested="ToggleScreenMenu"
|
||||
LogoutRequested="LogoutAsync"/>
|
||||
|
||||
<main class="management-screen">
|
||||
<section class="card">
|
||||
<div class="section-head">
|
||||
<h2>User Management</h2>
|
||||
</div>
|
||||
@if (!string.IsNullOrWhiteSpace(StatusMessage))
|
||||
{
|
||||
<p class="@(StatusIsError ? "form-error" : "muted")">@StatusMessage</p>
|
||||
}
|
||||
@if (IsLoading)
|
||||
{
|
||||
<p class="empty">Loading users...</p>
|
||||
}
|
||||
else if (!IsCurrentUserAdmin)
|
||||
{
|
||||
<p class="empty">Admin role is required to manage users.</p>
|
||||
}
|
||||
else if (Users.Count == 0)
|
||||
{
|
||||
<p class="empty">No users found.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<ul class="management-list">
|
||||
@foreach (var user in Users)
|
||||
{
|
||||
<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="@(IsMutating || user.Id == CurrentUser?.Id)"
|
||||
@onclick="() => 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="@(IsMutating || user.Id == CurrentUser?.Id)"
|
||||
@onclick="() => DeleteUserAsync(user)">
|
||||
<span aria-hidden="true" class="emoji">🗑️</span>
|
||||
<span class="sr-only">Delete user @user.Username</span>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
208
RpgRoller/Components/Pages/HomeControls/AdminHome.razor.cs
Normal file
208
RpgRoller/Components/Pages/HomeControls/AdminHome.razor.cs
Normal file
@@ -0,0 +1,208 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.JSInterop;
|
||||
using RpgRoller.Contracts;
|
||||
using RpgRoller.Domain;
|
||||
|
||||
namespace RpgRoller.Components.Pages.HomeControls;
|
||||
|
||||
[ExcludeFromCodeCoverage]
|
||||
public partial class AdminHome
|
||||
{
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (!firstRender)
|
||||
return;
|
||||
|
||||
await InitializeAsync();
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private async Task InitializeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var me = await ApiClient.RequestAsync<MeResponse>("GET", "/api/me");
|
||||
CurrentUser = me.User;
|
||||
IsCurrentUserAdmin = HasAdminRole(me.User);
|
||||
if (!IsCurrentUserAdmin)
|
||||
return;
|
||||
|
||||
Users = (await ApiClient.RequestAsync<IReadOnlyList<AdminUserSummary>>("GET", "/api/admin/users")).OrderBy(user => user.Username, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
}
|
||||
catch (ApiRequestException ex) when (ex.StatusCode == 401)
|
||||
{
|
||||
await LoggedOut.InvokeAsync("Session expired. Please log in again.");
|
||||
}
|
||||
catch (ApiRequestException ex)
|
||||
{
|
||||
SetStatus(ex.Message, true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private Task OpenPlayAsync()
|
||||
{
|
||||
return OpenWorkspaceAsync("play");
|
||||
}
|
||||
|
||||
private Task OpenCampaignManagementAsync()
|
||||
{
|
||||
return OpenWorkspaceAsync("management");
|
||||
}
|
||||
|
||||
private Task OpenAdminAsync()
|
||||
{
|
||||
IsScreenMenuOpen = false;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task LogoutAsync()
|
||||
{
|
||||
if (IsMutating)
|
||||
return;
|
||||
|
||||
IsMutating = true;
|
||||
try
|
||||
{
|
||||
await ApiClient.RequestWithoutPayloadAsync("POST", "/api/auth/logout");
|
||||
}
|
||||
catch (ApiRequestException)
|
||||
{
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsMutating = false;
|
||||
}
|
||||
|
||||
await LoggedOut.InvokeAsync("Logged out.");
|
||||
}
|
||||
|
||||
private async Task ToggleAdminRoleAsync(AdminUserSummary user)
|
||||
{
|
||||
if (IsMutating || CurrentUser is null || user.Id == CurrentUser.Id)
|
||||
return;
|
||||
|
||||
IsMutating = true;
|
||||
try
|
||||
{
|
||||
IReadOnlyList<string> roles = HasAdminRole(user) ? Array.Empty<string>() : [UserRoles.Admin];
|
||||
_ = await ApiClient.RequestAsync<AdminUserSummary>("PUT", $"/api/admin/users/{user.Id}/roles", new UpdateUserRolesRequest(roles));
|
||||
|
||||
await ReloadUsersAsync();
|
||||
SetStatus("User roles updated.", false);
|
||||
}
|
||||
catch (ApiRequestException ex)
|
||||
{
|
||||
SetStatus(ex.Message, true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsMutating = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteUserAsync(AdminUserSummary user)
|
||||
{
|
||||
if (IsMutating || CurrentUser is null || user.Id == CurrentUser.Id)
|
||||
return;
|
||||
|
||||
var confirmed = await JS.InvokeAsync<bool>("confirm", $"Delete user '{user.Username}'?");
|
||||
if (!confirmed)
|
||||
return;
|
||||
|
||||
IsMutating = true;
|
||||
try
|
||||
{
|
||||
_ = await ApiClient.RequestAsync<bool>("DELETE", $"/api/admin/users/{user.Id}");
|
||||
await ReloadUsersAsync();
|
||||
SetStatus("User deleted.", false);
|
||||
}
|
||||
catch (ApiRequestException ex)
|
||||
{
|
||||
SetStatus(ex.Message, true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsMutating = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReloadUsersAsync()
|
||||
{
|
||||
Users = (await ApiClient.RequestAsync<IReadOnlyList<AdminUserSummary>>("GET", "/api/admin/users")).OrderBy(user => user.Username, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
}
|
||||
|
||||
private static bool HasAdminRole(UserSummary user)
|
||||
{
|
||||
return user.Roles.Contains(UserRoles.Admin, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool HasAdminRole(AdminUserSummary user)
|
||||
{
|
||||
return user.Roles.Contains(UserRoles.Admin, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private void SetStatus(string message, bool isError)
|
||||
{
|
||||
StatusMessage = message;
|
||||
StatusIsError = isError;
|
||||
}
|
||||
|
||||
private void ToggleScreenMenu()
|
||||
{
|
||||
IsScreenMenuOpen = !IsScreenMenuOpen;
|
||||
}
|
||||
|
||||
private async Task OpenWorkspaceAsync(string screen)
|
||||
{
|
||||
IsScreenMenuOpen = false;
|
||||
await WorkspaceRequested.InvokeAsync(screen);
|
||||
}
|
||||
|
||||
[Inject]
|
||||
private RpgRollerApiClient ApiClient { get; set; } = null!;
|
||||
|
||||
[Inject]
|
||||
private IJSRuntime JS { get; set; } = null!;
|
||||
|
||||
private bool IsLoading { get; set; } = true;
|
||||
private bool IsMutating { get; set; }
|
||||
private bool IsScreenMenuOpen { get; set; }
|
||||
private bool IsCurrentUserAdmin { get; set; }
|
||||
private UserSummary? CurrentUser { get; set; }
|
||||
private List<AdminUserSummary> Users { get; set; } = [];
|
||||
private string? StatusMessage { get; set; }
|
||||
private bool StatusIsError { get; set; }
|
||||
|
||||
private IReadOnlyList<AppHeaderMenuItem> HeaderMenuItems =>
|
||||
[
|
||||
new()
|
||||
{
|
||||
Label = "Play",
|
||||
IsActive = false,
|
||||
OnSelected = OpenPlayAsync
|
||||
},
|
||||
new()
|
||||
{
|
||||
Label = "Campaign Management",
|
||||
IsActive = false,
|
||||
OnSelected = OpenCampaignManagementAsync
|
||||
},
|
||||
new()
|
||||
{
|
||||
Label = "Admin",
|
||||
IsActive = true,
|
||||
OnSelected = OpenAdminAsync
|
||||
}
|
||||
];
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<string?> LoggedOut { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<string> WorkspaceRequested { get; set; }
|
||||
}
|
||||
57
RpgRoller/Components/Pages/HomeControls/AppHeader.razor
Normal file
57
RpgRoller/Components/Pages/HomeControls/AppHeader.razor
Normal file
@@ -0,0 +1,57 @@
|
||||
<header class="workspace-header">
|
||||
<div class="header-row">
|
||||
<h1>@Title</h1>
|
||||
@if (User is null)
|
||||
{
|
||||
<p class="header-identity">
|
||||
<strong>Loading user...</strong>
|
||||
</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="header-identity">
|
||||
<strong>@User.DisplayName</strong> <span class="muted">(@User.Username)</span>
|
||||
</p>
|
||||
}
|
||||
@if (ShowCampaign)
|
||||
{
|
||||
<p class="header-campaign">Campaign: <strong>@(CampaignName ?? "No campaign selected")</strong></p>
|
||||
}
|
||||
<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>
|
||||
@if (MenuItems.Count > 0)
|
||||
{
|
||||
<div class="header-menu-wrap">
|
||||
<button
|
||||
id="@MenuButtonId"
|
||||
type="button"
|
||||
class="menu-toggle"
|
||||
aria-haspopup="true"
|
||||
aria-expanded="@IsMenuOpen"
|
||||
aria-controls="@MenuId"
|
||||
@onclick="ToggleMenuRequested">
|
||||
<span aria-hidden="true">☰</span>
|
||||
</button>
|
||||
@if (IsMenuOpen)
|
||||
{
|
||||
<div id="@MenuId" class="screen-menu" role="menu" aria-labelledby="@MenuButtonId">
|
||||
@foreach (var item in MenuItems)
|
||||
{
|
||||
<button type="button"
|
||||
class="menu-item @(item.IsActive ? "active" : string.Empty)"
|
||||
role="menuitem"
|
||||
@onclick="() => SelectMenuItemAsync(item)">
|
||||
@item.Label
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</header>
|
||||
47
RpgRoller/Components/Pages/HomeControls/AppHeader.razor.cs
Normal file
47
RpgRoller/Components/Pages/HomeControls/AppHeader.razor.cs
Normal file
@@ -0,0 +1,47 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using RpgRoller.Contracts;
|
||||
|
||||
namespace RpgRoller.Components.Pages.HomeControls;
|
||||
|
||||
[ExcludeFromCodeCoverage]
|
||||
public partial class AppHeader
|
||||
{
|
||||
private Task SelectMenuItemAsync(AppHeaderMenuItem item)
|
||||
{
|
||||
return item.OnSelected?.Invoke() ?? Task.CompletedTask;
|
||||
}
|
||||
|
||||
[Parameter] public string Title { get; set; } = "RpgRoller";
|
||||
|
||||
[Parameter] public UserSummary? User { get; set; }
|
||||
|
||||
[Parameter] public bool ShowCampaign { get; set; }
|
||||
|
||||
[Parameter] public string? CampaignName { get; set; }
|
||||
|
||||
[Parameter] public bool ShowConnectionState { get; set; } = true;
|
||||
|
||||
[Parameter] public string ConnectionStateLabel { get; set; } = "Offline fallback";
|
||||
|
||||
[Parameter] public string ConnectionStateCssClass { get; set; } = "offline";
|
||||
|
||||
[Parameter] public bool IsMenuOpen { get; set; }
|
||||
|
||||
[Parameter] public string MenuButtonId { get; set; } = "screen-menu-button";
|
||||
|
||||
[Parameter] public string MenuId { get; set; } = "screen-menu";
|
||||
|
||||
[Parameter] public IReadOnlyList<AppHeaderMenuItem> MenuItems { get; set; } = [];
|
||||
|
||||
[Parameter] public EventCallback ToggleMenuRequested { get; set; }
|
||||
|
||||
[Parameter] public EventCallback LogoutRequested { get; set; }
|
||||
}
|
||||
|
||||
public sealed class AppHeaderMenuItem
|
||||
{
|
||||
public string Label { get; init; } = string.Empty;
|
||||
public bool IsActive { get; init; }
|
||||
public Func<Task>? OnSelected { get; init; }
|
||||
}
|
||||
66
RpgRoller/Components/Pages/HomeControls/AuthSection.razor
Normal file
66
RpgRoller/Components/Pages/HomeControls/AuthSection.razor
Normal file
@@ -0,0 +1,66 @@
|
||||
<main class="auth-shell">
|
||||
<h1>RpgRoller</h1>
|
||||
<p class="auth-subtitle">Register or log in to join a campaign session.</p>
|
||||
@if (!string.IsNullOrWhiteSpace(StatusMessage))
|
||||
{
|
||||
<p class="status-message @(StatusIsError ? "error" : "success")">@StatusMessage</p>
|
||||
}
|
||||
<div class="auth-grid">
|
||||
<section class="card auth-card">
|
||||
<h2>Register</h2>
|
||||
@if (!string.IsNullOrWhiteSpace(RegisterState.ErrorMessage))
|
||||
{
|
||||
<p class="form-error">@RegisterState.ErrorMessage</p>
|
||||
}
|
||||
<form class="form-grid" @onsubmit="SubmitRegisterAsync" @onsubmit:preventDefault>
|
||||
<label for="register-username">Username</label>
|
||||
<input id="register-username" @bind="RegisterState.Model.Username" @bind:event="oninput"
|
||||
autocomplete="username"/>
|
||||
@if (RegisterState.Errors.TryGetValue("username", out var registerUsernameError))
|
||||
{
|
||||
<p class="field-error">@registerUsernameError</p>
|
||||
}
|
||||
<label for="register-display-name">Display name</label>
|
||||
<input id="register-display-name" @bind="RegisterState.Model.DisplayName" @bind:event="oninput"
|
||||
autocomplete="name"/>
|
||||
@if (RegisterState.Errors.TryGetValue("displayName", out var registerDisplayNameError))
|
||||
{
|
||||
<p class="field-error">@registerDisplayNameError</p>
|
||||
}
|
||||
<label for="register-password">Password</label>
|
||||
<input id="register-password" type="password" @bind="RegisterState.Model.Password" @bind:event="oninput"
|
||||
autocomplete="new-password"/>
|
||||
@if (RegisterState.Errors.TryGetValue("password", out var registerPasswordError))
|
||||
{
|
||||
<p class="field-error">@registerPasswordError</p>
|
||||
}
|
||||
<button type="submit" disabled="@IsSubmitting">@(IsSubmitting ? "Registering..." : "Register")</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card auth-card">
|
||||
<h2>Login</h2>
|
||||
@if (!string.IsNullOrWhiteSpace(LoginState.ErrorMessage))
|
||||
{
|
||||
<p class="form-error">@LoginState.ErrorMessage</p>
|
||||
}
|
||||
<form class="form-grid" @onsubmit="SubmitLoginAsync" @onsubmit:preventDefault>
|
||||
<label for="login-username">Username</label>
|
||||
<input id="login-username" @bind="LoginState.Model.Username" @bind:event="oninput"
|
||||
autocomplete="username"/>
|
||||
@if (LoginState.Errors.TryGetValue("username", out var loginUsernameError))
|
||||
{
|
||||
<p class="field-error">@loginUsernameError</p>
|
||||
}
|
||||
<label for="login-password">Password</label>
|
||||
<input id="login-password" type="password" @bind="LoginState.Model.Password" @bind:event="oninput"
|
||||
autocomplete="current-password"/>
|
||||
@if (LoginState.Errors.TryGetValue("password", out var loginPasswordError))
|
||||
{
|
||||
<p class="field-error">@loginPasswordError</p>
|
||||
}
|
||||
<button type="submit" disabled="@IsSubmitting">@(IsSubmitting ? "Logging in..." : "Login")</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
101
RpgRoller/Components/Pages/HomeControls/AuthSection.razor.cs
Normal file
101
RpgRoller/Components/Pages/HomeControls/AuthSection.razor.cs
Normal file
@@ -0,0 +1,101 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using RpgRoller.Contracts;
|
||||
|
||||
namespace RpgRoller.Components.Pages.HomeControls;
|
||||
|
||||
[ExcludeFromCodeCoverage]
|
||||
public partial class AuthSection
|
||||
{
|
||||
private async Task SubmitRegisterAsync()
|
||||
{
|
||||
RegisterState.ResetValidation();
|
||||
|
||||
var model = RegisterState.Model;
|
||||
if (string.IsNullOrWhiteSpace(model.Username))
|
||||
RegisterState.Errors["username"] = "Username is required.";
|
||||
|
||||
if (string.IsNullOrWhiteSpace(model.DisplayName))
|
||||
RegisterState.Errors["displayName"] = "Display name is required.";
|
||||
|
||||
if (string.IsNullOrWhiteSpace(model.Password) || model.Password.Length < 8)
|
||||
RegisterState.Errors["password"] = "Password must be at least 8 characters.";
|
||||
|
||||
if (RegisterState.Errors.Count > 0)
|
||||
{
|
||||
RegisterState.ErrorMessage = "Resolve validation issues before submitting.";
|
||||
return;
|
||||
}
|
||||
|
||||
IsSubmitting = true;
|
||||
try
|
||||
{
|
||||
_ = await ApiClient.RequestAsync<UserSummary>("POST", "/api/auth/register", new RegisterRequest(model.Username.Trim(), model.Password, model.DisplayName.Trim()));
|
||||
|
||||
model.Password = string.Empty;
|
||||
RegisterState.ErrorMessage = "Registration successful. You can log in now.";
|
||||
}
|
||||
catch (ApiRequestException ex)
|
||||
{
|
||||
if (ex.Message.Contains("already taken", StringComparison.OrdinalIgnoreCase))
|
||||
RegisterState.Errors["username"] = "Username is already taken. Choose another one.";
|
||||
else
|
||||
RegisterState.ErrorMessage = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsSubmitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SubmitLoginAsync()
|
||||
{
|
||||
LoginState.ResetValidation();
|
||||
|
||||
var model = LoginState.Model;
|
||||
if (string.IsNullOrWhiteSpace(model.Username))
|
||||
LoginState.Errors["username"] = "Username is required.";
|
||||
|
||||
if (string.IsNullOrWhiteSpace(model.Password))
|
||||
LoginState.Errors["password"] = "Password is required.";
|
||||
|
||||
if (LoginState.Errors.Count > 0)
|
||||
{
|
||||
LoginState.ErrorMessage = "Resolve validation issues before submitting.";
|
||||
return;
|
||||
}
|
||||
|
||||
IsSubmitting = true;
|
||||
try
|
||||
{
|
||||
_ = await ApiClient.RequestAsync<UserSummary>("POST", "/api/auth/login", new LoginRequest(model.Username.Trim(), model.Password));
|
||||
|
||||
model.Password = string.Empty;
|
||||
await LoggedIn.InvokeAsync();
|
||||
}
|
||||
catch (ApiRequestException ex)
|
||||
{
|
||||
LoginState.ErrorMessage = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsSubmitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
[Inject]
|
||||
private RpgRollerApiClient ApiClient { get; set; } = null!;
|
||||
|
||||
private FormState<RegisterFormModel> RegisterState { get; } = new();
|
||||
private FormState<LoginFormModel> LoginState { get; } = new();
|
||||
private bool IsSubmitting { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public string? StatusMessage { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public bool StatusIsError { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback LoggedIn { get; set; }
|
||||
}
|
||||
111
RpgRoller/Components/Pages/HomeControls/CampaignLogPanel.razor
Normal file
111
RpgRoller/Components/Pages/HomeControls/CampaignLogPanel.razor
Normal file
@@ -0,0 +1,111 @@
|
||||
<aside @ref="LogPanelRef" class="card log-panel">
|
||||
<div class="section-head">
|
||||
<h2>Campaign Log</h2>
|
||||
</div>
|
||||
<div @ref="LogFeedRef" class="log-panel-feed">
|
||||
@if (IsCampaignDataLoading)
|
||||
{
|
||||
<div class="skeleton-stack">
|
||||
<div class="skeleton-line"></div>
|
||||
<div class="skeleton-line"></div>
|
||||
<div class="skeleton-line short"></div>
|
||||
</div>
|
||||
}
|
||||
else if (CampaignLog.Count == 0)
|
||||
{
|
||||
<p class="empty">No log entries yet.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<ul class="log-list">
|
||||
@foreach (var entry in CampaignLog)
|
||||
{
|
||||
var isExpanded = ExpandedRollId == entry.RollId;
|
||||
<li class="log-entry @LogEntryCssClass(entry, isExpanded, FreshRollId == entry.RollId)">
|
||||
<button type="button"
|
||||
class="log-entry-toggle"
|
||||
aria-expanded="@isExpanded"
|
||||
@onclick="() => ToggleRollDetailRequested.InvokeAsync(entry.RollId)">
|
||||
<span class="log-entry-main">
|
||||
<span class="log-entry-copy">
|
||||
<span class="log-entry-actor">@entry.RollerLabel</span>
|
||||
<span class="log-entry-action">rolled</span>
|
||||
<span class="log-entry-skill">@entry.SkillName</span>
|
||||
<span class="log-entry-action">with</span>
|
||||
<span class="log-entry-character">@entry.CharacterName</span>
|
||||
</span>
|
||||
<span class="roll-total inline">@entry.Result</span>
|
||||
</span>
|
||||
@if (HasSummary(entry))
|
||||
{
|
||||
<span class="log-summary-row">
|
||||
@foreach (var badge in GetEventBadges(entry))
|
||||
{
|
||||
<span class="log-event-badge @badge.Tone">@badge.Label</span>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(entry.SummaryText))
|
||||
{
|
||||
<span class="log-summary-text">@entry.SummaryText</span>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
<span class="log-meta">
|
||||
<span class="badge @entry.VisibilityStyle">@entry.VisibilityLabel</span>
|
||||
<time
|
||||
title="@entry.TimestampUtc.ToString("O")">
|
||||
@entry.TimestampUtc.ToLocalTime().ToString("g")
|
||||
</time>
|
||||
</span>
|
||||
</button>
|
||||
@if (isExpanded)
|
||||
{
|
||||
<div class="log-detail">
|
||||
@if (IsRollDetailLoading(entry.RollId))
|
||||
{
|
||||
<p class="muted">Loading roll detail...</p>
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(GetRollDetailError(entry.RollId)))
|
||||
{
|
||||
<p class="field-error">@GetRollDetailError(entry.RollId)</p>
|
||||
}
|
||||
else if (ResolveRollDetail(entry.RollId) is { } detail)
|
||||
{
|
||||
<RollDiceStrip Dice="detail.Dice" AriaLabel="Log roll dice"/>
|
||||
<p>@detail.Breakdown</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
|
||||
<section class="custom-roll-panel" aria-label="Custom roll panel">
|
||||
<form class="custom-roll-composer" @onsubmit="SubmitCustomRollAsync" @onsubmit:preventDefault>
|
||||
<div class="custom-roll-composer-head">
|
||||
<label for="custom-roll-expression" class="custom-roll-label">Custom roll</label>
|
||||
<span class="muted">@CustomRollStatusText</span>
|
||||
</div>
|
||||
<div class="custom-roll-composer-row">
|
||||
<input id="custom-roll-expression"
|
||||
@key="CustomRollInputVersion"
|
||||
@ref="CustomRollInputRef"
|
||||
class="@CustomRollInputCssClass"
|
||||
@bind="CustomRollExpression"
|
||||
@bind:event="oninput"
|
||||
placeholder="@CustomRollPlaceholder"
|
||||
title="@CustomRollInputTitle"
|
||||
aria-invalid="@HasCustomRollError"
|
||||
aria-describedby="@CustomRollInputDescribedBy"
|
||||
disabled="@IsCustomRollDisabled"/>
|
||||
<button type="submit" disabled="@IsCustomRollDisabled">Roll</button>
|
||||
</div>
|
||||
<p class="field-help">@CustomRollHelpText</p>
|
||||
@if (HasCustomRollError)
|
||||
{
|
||||
<p id="@CustomRollErrorElementId" class="field-error" role="alert">@CustomRollErrorMessage</p>
|
||||
}
|
||||
</form>
|
||||
</section>
|
||||
</aside>
|
||||
@@ -0,0 +1,233 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.JSInterop;
|
||||
using RpgRoller.Contracts;
|
||||
|
||||
namespace RpgRoller.Components.Pages.HomeControls;
|
||||
|
||||
[ExcludeFromCodeCoverage]
|
||||
public partial class CampaignLogPanel
|
||||
{
|
||||
private sealed record EventBadgeView(string Label, string Tone);
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
var currentLastRollId = CampaignLog.LastOrDefault()?.RollId;
|
||||
if (IsCampaignDataLoading || CampaignLog.Count == 0)
|
||||
{
|
||||
LastRenderedLogCount = CampaignLog.Count;
|
||||
LastRenderedLogRollId = currentLastRollId;
|
||||
return;
|
||||
}
|
||||
|
||||
if (firstRender || CampaignLog.Count > LastRenderedLogCount || currentLastRollId != LastRenderedLogRollId)
|
||||
{
|
||||
try
|
||||
{
|
||||
await JS.InvokeVoidAsync("rpgRollerApi.scrollElementToBottom", LogFeedRef);
|
||||
}
|
||||
catch (JSDisconnectedException)
|
||||
{
|
||||
}
|
||||
catch (InvalidOperationException ex) when (ex.Message.Contains("statically rendered",
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
LastRenderedLogCount = CampaignLog.Count;
|
||||
LastRenderedLogRollId = currentLastRollId;
|
||||
}
|
||||
|
||||
private async Task SubmitCustomRollAsync()
|
||||
{
|
||||
CustomRollState.ResetValidation();
|
||||
|
||||
var expression = CustomRollState.Model.Expression.Trim();
|
||||
if (string.IsNullOrWhiteSpace(expression))
|
||||
{
|
||||
SetCustomRollError("Enter a roll expression first.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!SelectedCharacterId.HasValue)
|
||||
{
|
||||
SetCustomRollError("Select a character to make a custom roll.");
|
||||
return;
|
||||
}
|
||||
|
||||
IsSubmittingCustomRoll = true;
|
||||
try
|
||||
{
|
||||
var roll = await ApiClient.RequestAsync<RollResult>("POST",
|
||||
$"/api/characters/{SelectedCharacterId.Value}/custom-rolls", new
|
||||
{
|
||||
expression,
|
||||
visibility = NormalizedRollVisibility
|
||||
});
|
||||
|
||||
CustomRollState.Model.Expression = string.Empty;
|
||||
CustomRollState.ResetValidation();
|
||||
CustomRollInputVersion += 1;
|
||||
await CustomRollCreated.InvokeAsync(roll);
|
||||
await JS.InvokeVoidAsync("rpgRollerApi.clearInputValue", CustomRollInputRef);
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
catch (ApiRequestException ex) when
|
||||
(string.Equals(ex.ErrorCode, "invalid_expression", StringComparison.Ordinal))
|
||||
{
|
||||
SetCustomRollError(ex.Message);
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
catch (ApiRequestException ex)
|
||||
{
|
||||
await ErrorOccurred.InvokeAsync(ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsSubmittingCustomRoll = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void SetCustomRollError(string message)
|
||||
{
|
||||
CustomRollState.Errors["expression"] = message;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<EventBadgeView> GetEventBadges(CampaignLogListEntry entry)
|
||||
{
|
||||
return (entry.EventBadges ?? []).Select(ToEventBadgeView).Where(badge => badge is not null)
|
||||
.Cast<EventBadgeView>().ToArray();
|
||||
}
|
||||
|
||||
private static bool HasSummary(CampaignLogListEntry entry)
|
||||
{
|
||||
return (entry.EventBadges?.Length ?? 0) > 0 || !string.IsNullOrWhiteSpace(entry.SummaryText);
|
||||
}
|
||||
|
||||
private static EventBadgeView? ToEventBadgeView(string code)
|
||||
{
|
||||
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"),
|
||||
"r100" => new("100", "rare"),
|
||||
"r66" => new("66", "rare"),
|
||||
"rs5" => new("Retry +5", "rare"),
|
||||
"rs10" => new("Retry +10", "rare"),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static string LogEntryCssClass(CampaignLogListEntry entry, bool isExpanded, bool isFresh)
|
||||
{
|
||||
var classes = new List<string> { entry.VisibilityStyle };
|
||||
if (isExpanded)
|
||||
classes.Add("expanded");
|
||||
|
||||
if (isFresh)
|
||||
classes.Add("fresh");
|
||||
|
||||
return string.Join(" ", classes);
|
||||
}
|
||||
|
||||
[Inject] private IJSRuntime JS { get; set; } = null!;
|
||||
|
||||
[Inject] private RpgRollerApiClient ApiClient { get; set; } = null!;
|
||||
|
||||
private ElementReference LogPanelRef { get; set; }
|
||||
private ElementReference LogFeedRef { get; set; }
|
||||
private ElementReference CustomRollInputRef { get; set; }
|
||||
private int LastRenderedLogCount { get; set; }
|
||||
private Guid? LastRenderedLogRollId { get; set; }
|
||||
private int CustomRollInputVersion { get; set; }
|
||||
private FormState<CustomRollFormModel> CustomRollState { get; } = new();
|
||||
private bool IsSubmittingCustomRoll { get; set; }
|
||||
|
||||
[Parameter] public bool IsCampaignDataLoading { get; set; }
|
||||
|
||||
[Parameter] public IReadOnlyList<CampaignLogListEntry> CampaignLog { get; set; } = [];
|
||||
|
||||
[Parameter] public Guid? ExpandedRollId { get; set; }
|
||||
|
||||
[Parameter] public Guid? FreshRollId { get; set; }
|
||||
|
||||
[Parameter] public EventCallback<Guid> ToggleRollDetailRequested { get; set; }
|
||||
|
||||
[Parameter] public Func<Guid, CampaignRollDetail?> ResolveRollDetail { get; set; } = _ => null;
|
||||
|
||||
[Parameter] public Func<Guid, bool> IsRollDetailLoading { get; set; } = _ => false;
|
||||
|
||||
[Parameter] public Func<Guid, string?> GetRollDetailError { get; set; } = _ => null;
|
||||
|
||||
[Parameter] public Guid? SelectedCharacterId { get; set; }
|
||||
|
||||
[Parameter] public string? SelectedCharacterName { get; set; }
|
||||
|
||||
[Parameter] public string SelectedCampaignRulesetId { get; set; } = string.Empty;
|
||||
|
||||
[Parameter] public string RollVisibility { get; set; } = "public";
|
||||
|
||||
[Parameter] public Func<string>? ResolveRollVisibility { get; set; }
|
||||
|
||||
[Parameter] public bool IsMutating { 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 string CustomRollInputCssClass => HasCustomRollError ? "custom-roll-input error" : "custom-roll-input";
|
||||
private string? CustomRollInputTitle => HasCustomRollError ? CustomRollErrorMessage : null;
|
||||
private string CustomRollErrorElementId => "custom-roll-expression-error";
|
||||
private string? CustomRollInputDescribedBy => HasCustomRollError ? CustomRollErrorElementId : null;
|
||||
|
||||
private string CustomRollPlaceholder => SelectedCampaignRulesetId.ToLowerInvariant() switch
|
||||
{
|
||||
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"
|
||||
};
|
||||
|
||||
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."
|
||||
};
|
||||
|
||||
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
|
||||
{
|
||||
get => CustomRollState.Model.Expression;
|
||||
set
|
||||
{
|
||||
CustomRollState.Model.Expression = value;
|
||||
if (HasCustomRollError)
|
||||
CustomRollState.ResetValidation();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
<main class="management-screen">
|
||||
<section class="card">
|
||||
<div class="section-head">
|
||||
<h2>Campaign</h2>
|
||||
</div>
|
||||
@if (Campaigns.Count == 0)
|
||||
{
|
||||
<p class="empty">No campaigns yet.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<label for="campaign-select">Current campaign</label>
|
||||
<select id="campaign-select" @onchange="CampaignSelectionChanged">
|
||||
@foreach (var campaign in Campaigns)
|
||||
{
|
||||
<option value="@campaign.Id" selected="@(campaign.Id == SelectedCampaignId)">@campaign.Name (@campaign.RulesetId), GM: @campaign.Gm.DisplayName, @campaign.CharacterCount characters</option>
|
||||
}
|
||||
</select>
|
||||
}
|
||||
|
||||
<button type="button"
|
||||
class="add-row-button"
|
||||
disabled="@(IsMutating || IsCreatingCampaign)"
|
||||
@onclick="OpenCreateCampaignModal">
|
||||
<span class="add-row-icon" aria-hidden="true">+</span>
|
||||
<span>Add campaign</span>
|
||||
</button>
|
||||
|
||||
<button type="button"
|
||||
class="ghost"
|
||||
disabled="@(IsMutating || IsCreatingCampaign || !CanDeleteCampaign)"
|
||||
@onclick="DeleteCampaignRequested">
|
||||
Delete current campaign
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="section-head">
|
||||
<h2>Character Management</h2>
|
||||
</div>
|
||||
@if (SelectedCampaign is null)
|
||||
{
|
||||
<p class="empty">Select a campaign first.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
@if (SelectedCampaign.Characters.Length == 0)
|
||||
{
|
||||
<p class="empty">No characters in this campaign yet.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<ul class="management-list">
|
||||
@foreach (var character in SelectedCampaign.Characters)
|
||||
{
|
||||
<li>
|
||||
<div>
|
||||
<strong>@character.Name</strong>
|
||||
<p class="muted">Owner: @OwnerLabel(character.OwnerUserId)</p>
|
||||
</div>
|
||||
<div class="management-actions">
|
||||
<button type="button"
|
||||
class="chip-button"
|
||||
title="Edit character"
|
||||
disabled="@(IsMutating || IsCreatingCampaign || !CanEditCharacter(character))"
|
||||
@onclick="() => EditCharacterRequested.InvokeAsync(character)">
|
||||
<span aria-hidden="true" class="emoji">✏️</span>
|
||||
<span class="sr-only">Edit @character.Name</span>
|
||||
</button>
|
||||
<button type="button"
|
||||
class="chip-button"
|
||||
title="Delete character"
|
||||
disabled="@(IsMutating || IsCreatingCampaign || !CanDeleteCharacter(character))"
|
||||
@onclick="() => DeleteCharacterRequested.InvokeAsync(character)">
|
||||
<span aria-hidden="true" class="emoji">🗑️</span>
|
||||
<span class="sr-only">Delete @character.Name</span>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
|
||||
<button type="button"
|
||||
class="add-row-button"
|
||||
disabled="@(IsMutating || IsCreatingCampaign)"
|
||||
@onclick="CreateCharacterRequested">
|
||||
<span class="add-row-icon" aria-hidden="true">+</span>
|
||||
<span>Add character</span>
|
||||
</button>
|
||||
}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@if (ShowCreateCampaignModal)
|
||||
{
|
||||
<div class="modal-overlay" role="presentation">
|
||||
<section class="modal-card" role="dialog" aria-modal="true" aria-label="Create Campaign">
|
||||
<h2>Create Campaign</h2>
|
||||
@if (!string.IsNullOrWhiteSpace(CampaignState.ErrorMessage))
|
||||
{
|
||||
<p class="form-error">@CampaignState.ErrorMessage</p>
|
||||
}
|
||||
<form class="form-grid" @onsubmit="SubmitCreateCampaignAsync" @onsubmit:preventDefault>
|
||||
<label for="campaign-name">Campaign name</label>
|
||||
<input id="campaign-name" @bind="CampaignState.Model.Name" @bind:event="oninput"/>
|
||||
@if (CampaignState.Errors.TryGetValue("name", out var campaignNameError))
|
||||
{
|
||||
<p class="field-error">@campaignNameError</p>
|
||||
}
|
||||
|
||||
<label for="campaign-ruleset">Ruleset</label>
|
||||
<select id="campaign-ruleset" @bind="CampaignState.Model.RulesetId">
|
||||
<option value="">Select ruleset</option>
|
||||
@foreach (var ruleset in Rulesets)
|
||||
{
|
||||
<option value="@ruleset.Id">@ruleset.Name</option>
|
||||
}
|
||||
</select>
|
||||
@if (CampaignState.Errors.TryGetValue("rulesetId", out var campaignRulesetError))
|
||||
{
|
||||
<p class="field-error">@campaignRulesetError</p>
|
||||
}
|
||||
|
||||
<div class="inline-actions">
|
||||
<button type="submit" disabled="@(IsMutating || IsCreatingCampaign)">@(IsCreatingCampaign ? "Creating..." : "Create Campaign")</button>
|
||||
<button type="button" class="ghost" disabled="@(IsMutating || IsCreatingCampaign)" @onclick="CloseCreateCampaignModal">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using RpgRoller.Contracts;
|
||||
|
||||
namespace RpgRoller.Components.Pages.HomeControls;
|
||||
|
||||
[ExcludeFromCodeCoverage]
|
||||
public partial class CampaignManagementPanel
|
||||
{
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(CampaignState.Model.RulesetId) && Rulesets.Count > 0)
|
||||
CampaignState.Model.RulesetId = Rulesets[0].Id;
|
||||
}
|
||||
|
||||
private void OpenCreateCampaignModal()
|
||||
{
|
||||
CampaignState.Model.Name = string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(CampaignState.Model.RulesetId) && Rulesets.Count > 0)
|
||||
CampaignState.Model.RulesetId = Rulesets[0].Id;
|
||||
|
||||
CampaignState.ResetValidation();
|
||||
ShowCreateCampaignModal = true;
|
||||
}
|
||||
|
||||
private void CloseCreateCampaignModal()
|
||||
{
|
||||
CampaignState.ResetValidation();
|
||||
ShowCreateCampaignModal = false;
|
||||
}
|
||||
|
||||
private async Task SubmitCreateCampaignAsync()
|
||||
{
|
||||
CampaignState.ResetValidation();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(CampaignState.Model.Name))
|
||||
CampaignState.Errors["name"] = "Campaign name is required.";
|
||||
|
||||
if (string.IsNullOrWhiteSpace(CampaignState.Model.RulesetId))
|
||||
CampaignState.Errors["rulesetId"] = "Ruleset is required.";
|
||||
|
||||
if (CampaignState.Errors.Count > 0)
|
||||
{
|
||||
CampaignState.ErrorMessage = "Resolve validation issues before submitting.";
|
||||
return;
|
||||
}
|
||||
|
||||
IsCreatingCampaign = true;
|
||||
try
|
||||
{
|
||||
var campaign = await ApiClient.RequestAsync<CampaignSummary>("POST", "/api/campaigns", new CreateCampaignRequest(CampaignState.Model.Name.Trim(), CampaignState.Model.RulesetId));
|
||||
|
||||
CampaignState.Model.Name = string.Empty;
|
||||
ShowCreateCampaignModal = false;
|
||||
await CampaignCreated.InvokeAsync(campaign.Id);
|
||||
}
|
||||
catch (ApiRequestException ex)
|
||||
{
|
||||
CampaignState.ErrorMessage = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsCreatingCampaign = false;
|
||||
}
|
||||
}
|
||||
|
||||
[Inject]
|
||||
private RpgRollerApiClient ApiClient { get; set; } = null!;
|
||||
|
||||
private FormState<CampaignFormModel> CampaignState { get; } = new();
|
||||
private bool IsCreatingCampaign { get; set; }
|
||||
private bool ShowCreateCampaignModal { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public IReadOnlyList<CampaignSummary> Campaigns { get; set; } = [];
|
||||
|
||||
[Parameter]
|
||||
public Guid? SelectedCampaignId { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public CampaignRoster? SelectedCampaign { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public IReadOnlyList<RulesetDefinition> Rulesets { get; set; } = [];
|
||||
|
||||
[Parameter]
|
||||
public bool IsMutating { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public Func<Guid, string> OwnerLabel { get; set; } = _ => string.Empty;
|
||||
|
||||
[Parameter]
|
||||
public Func<CharacterSummary, bool> CanEditCharacter { get; set; } = _ => false;
|
||||
|
||||
[Parameter]
|
||||
public Func<CharacterSummary, bool> CanDeleteCharacter { get; set; } = _ => false;
|
||||
|
||||
[Parameter]
|
||||
public bool CanDeleteCampaign { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<ChangeEventArgs> CampaignSelectionChanged { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<Guid> CampaignCreated { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback DeleteCampaignRequested { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback CreateCharacterRequested { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<CharacterSummary> EditCharacterRequested { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<CharacterSummary> DeleteCharacterRequested { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
@if (Visible)
|
||||
{
|
||||
<div class="modal-overlay" role="presentation">
|
||||
<section class="modal-card" role="dialog" aria-modal="true" aria-label="@Title">
|
||||
<h2>@Title</h2>
|
||||
@if (!string.IsNullOrWhiteSpace(FormState.ErrorMessage))
|
||||
{
|
||||
<p class="form-error">@FormState.ErrorMessage</p>
|
||||
}
|
||||
<form class="form-grid" @onsubmit="SubmitAsync" @onsubmit:preventDefault>
|
||||
<label for="@NameInputId">Character name</label>
|
||||
<input id="@NameInputId" @bind="FormState.Model.Name" @bind:event="oninput"/>
|
||||
@if (FormState.Errors.TryGetValue("name", out var nameError))
|
||||
{
|
||||
<p class="field-error">@nameError</p>
|
||||
}
|
||||
<label for="@CampaignInputId">Campaign</label>
|
||||
<select id="@CampaignInputId" @bind="FormState.Model.CampaignId">
|
||||
<option value="">@(EditingCharacterId.HasValue ? "No campaign" : "Select campaign")</option>
|
||||
@foreach (var campaign in CampaignOptions)
|
||||
{
|
||||
<option value="@campaign.Id">@campaign.Name</option>
|
||||
}
|
||||
</select>
|
||||
@if (FormState.Errors.TryGetValue("campaignId", out var campaignError))
|
||||
{
|
||||
<p class="field-error">@campaignError</p>
|
||||
}
|
||||
@if (AllowOwnerEdit)
|
||||
{
|
||||
<label for="@OwnerUsernameInputId">Owner username</label>
|
||||
<select id="@OwnerUsernameInputId" @bind="FormState.Model.OwnerUsername">
|
||||
<option value="">Keep current owner</option>
|
||||
@foreach (var username in AvailableUsernames.OrderBy(username => username, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
<option value="@username">@username</option>
|
||||
}
|
||||
</select>
|
||||
@if (FormState.Errors.TryGetValue("ownerUsername", out var ownerUsernameError))
|
||||
{
|
||||
<p class="field-error">@ownerUsernameError</p>
|
||||
}
|
||||
}
|
||||
<div class="inline-actions">
|
||||
<button type="submit" disabled="@(IsMutating || IsSubmitting)">@SubmitLabel</button>
|
||||
<button type="button" class="ghost" @onclick="CancelRequested">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using RpgRoller.Contracts;
|
||||
|
||||
namespace RpgRoller.Components.Pages.HomeControls;
|
||||
|
||||
[ExcludeFromCodeCoverage]
|
||||
public partial class CharacterFormModal
|
||||
{
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (!Visible || FormVersion == AppliedFormVersion)
|
||||
return;
|
||||
|
||||
FormState.Model.Name = InitialModel.Name;
|
||||
FormState.Model.CampaignId = InitialModel.CampaignId;
|
||||
FormState.Model.OwnerUsername = InitialModel.OwnerUsername;
|
||||
FormState.ResetValidation();
|
||||
AppliedFormVersion = FormVersion;
|
||||
}
|
||||
|
||||
private async Task SubmitAsync()
|
||||
{
|
||||
FormState.ResetValidation();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(FormState.Model.Name))
|
||||
FormState.Errors["name"] = "Character name is required.";
|
||||
|
||||
Guid? campaignId = null;
|
||||
if (!string.IsNullOrWhiteSpace(FormState.Model.CampaignId))
|
||||
{
|
||||
if (!Guid.TryParse(FormState.Model.CampaignId, out var parsedCampaignId))
|
||||
FormState.Errors["campaignId"] = "Campaign selection is invalid.";
|
||||
else
|
||||
campaignId = parsedCampaignId;
|
||||
}
|
||||
|
||||
if (!EditingCharacterId.HasValue && !campaignId.HasValue)
|
||||
FormState.Errors["campaignId"] = "Campaign is required.";
|
||||
|
||||
if (FormState.Errors.Count > 0)
|
||||
{
|
||||
FormState.ErrorMessage = "Resolve validation issues before submitting.";
|
||||
return;
|
||||
}
|
||||
|
||||
IsSubmitting = true;
|
||||
try
|
||||
{
|
||||
CharacterSummary character;
|
||||
if (EditingCharacterId.HasValue)
|
||||
{
|
||||
var ownerUsername = AllowOwnerEdit && !string.IsNullOrWhiteSpace(FormState.Model.OwnerUsername) ? FormState.Model.OwnerUsername.Trim() : null;
|
||||
character = await ApiClient.RequestAsync<CharacterSummary>("PUT", $"/api/characters/{EditingCharacterId.Value}", new UpdateCharacterRequest(FormState.Model.Name.Trim(), campaignId, ownerUsername));
|
||||
}
|
||||
else
|
||||
character = await ApiClient.RequestAsync<CharacterSummary>("POST", "/api/characters", new CreateCharacterRequest(FormState.Model.Name.Trim(), campaignId!.Value));
|
||||
|
||||
await CharacterSaved.InvokeAsync(character.CampaignId);
|
||||
}
|
||||
catch (ApiRequestException ex)
|
||||
{
|
||||
FormState.ErrorMessage = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsSubmitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
[Inject]
|
||||
private RpgRollerApiClient ApiClient { get; set; } = null!;
|
||||
|
||||
private FormState<CharacterFormModel> FormState { get; } = new();
|
||||
private int AppliedFormVersion { get; set; } = -1;
|
||||
private bool IsSubmitting { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public bool Visible { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public string Title { get; set; } = "Character";
|
||||
|
||||
[Parameter]
|
||||
public string SubmitLabel { get; set; } = "Save";
|
||||
|
||||
[Parameter]
|
||||
public string NameInputId { get; set; } = "character-name";
|
||||
|
||||
[Parameter]
|
||||
public string CampaignInputId { get; set; } = "character-campaign";
|
||||
|
||||
[Parameter]
|
||||
public string OwnerUsernameInputId { get; set; } = "character-owner-username";
|
||||
|
||||
[Parameter]
|
||||
public CharacterFormModel InitialModel { get; set; } = new();
|
||||
|
||||
[Parameter]
|
||||
public int FormVersion { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public Guid? EditingCharacterId { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public IReadOnlyList<CampaignOption> CampaignOptions { get; set; } = [];
|
||||
|
||||
[Parameter]
|
||||
public bool IsMutating { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public bool AllowOwnerEdit { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public IReadOnlyList<string> AvailableUsernames { get; set; } = [];
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<Guid?> CharacterSaved { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback CancelRequested { get; set; }
|
||||
}
|
||||
252
RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor
Normal file
252
RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor
Normal file
@@ -0,0 +1,252 @@
|
||||
<section class="card character-panel">
|
||||
@if (IsCampaignDataLoading)
|
||||
{
|
||||
<div class="skeleton-stack">
|
||||
<div class="skeleton-line"></div>
|
||||
<div class="skeleton-line short"></div>
|
||||
<div class="skeleton-line"></div>
|
||||
</div>
|
||||
}
|
||||
else if (SelectedCampaign is null)
|
||||
{
|
||||
<p class="empty">No campaign selected. Choose one in Campaign Management.</p>
|
||||
}
|
||||
else if (SelectedCampaign.Characters.Length == 0)
|
||||
{
|
||||
<p class="empty">No characters in this campaign yet.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="character-picker" role="tablist" aria-label="Character picker">
|
||||
@foreach (var character in SelectedCampaign.Characters)
|
||||
{
|
||||
var isSelectedCharacter = SelectedCharacterId == character.Id;
|
||||
<button type="button" class="icon-tab @(isSelectedCharacter ? "active" : string.Empty)"
|
||||
aria-label="@character.Name" @onclick="() => CharacterSelected.InvokeAsync(character.Id)">
|
||||
<span class="icon-tab-glyph">@InitialsFor(character.Name)</span>
|
||||
<span class="icon-tab-text">@character.Name</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@if (SelectedCharacter is not null)
|
||||
{
|
||||
<article class="skills-section">
|
||||
<div class="section-head">
|
||||
<button
|
||||
type="button"
|
||||
class="chip-button"
|
||||
title="Edit character"
|
||||
disabled="@(IsMutating || !CanEditCharacter(SelectedCharacter))"
|
||||
@onclick="() => EditCharacterRequested.InvokeAsync(SelectedCharacter)">
|
||||
<span aria-hidden="true" class="emoji">✏️</span>
|
||||
<span class="sr-only">Edit character</span>
|
||||
</button>
|
||||
<h3 class="skills-heading">
|
||||
@SelectedCharacter.Name
|
||||
<span
|
||||
class="muted">
|
||||
| Owner: @OwnerLabel(SelectedCharacter.OwnerUserId) | Campaign: @SelectedCampaign.Name
|
||||
</span>
|
||||
</h3>
|
||||
<div class="skill-filter-wrap">
|
||||
<label class="sr-only" for="skill-filter-input">Filter skills</label>
|
||||
<input id="skill-filter-input"
|
||||
class="skill-filter-input"
|
||||
type="search"
|
||||
placeholder="Filter skills"
|
||||
@bind="SkillFilterText"
|
||||
@bind:event="oninput"/>
|
||||
</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">
|
||||
<option value="public">Public</option>
|
||||
<option value="private">Private</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@{
|
||||
var orderedSkillGroups = SelectedCharacterSkillGroups.OrderBy(group => group.Name).ToList();
|
||||
var filteredSkills = SelectedCharacterSkills.Where(SkillMatchesFilter).ToList();
|
||||
var hasSkillFilter = !string.IsNullOrWhiteSpace(SkillFilterText);
|
||||
var visibleSkillGroups = orderedSkillGroups.Where(group => !hasSkillFilter || filteredSkills.Any(skill => skill.SkillGroupId == group.Id)).ToList();
|
||||
var ungroupedSkills = filteredSkills.Where(skill => !skill.SkillGroupId.HasValue).ToList();
|
||||
}
|
||||
@if (!hasSkillFilter && SelectedCharacterSkills.Count == 0 && orderedSkillGroups.Count == 0)
|
||||
{
|
||||
<p class="empty">No skills for this character yet.</p>
|
||||
}
|
||||
@if (hasSkillFilter && filteredSkills.Count == 0)
|
||||
{
|
||||
<p class="empty">No skills match the current filter.</p>
|
||||
}
|
||||
@foreach (var group in visibleSkillGroups)
|
||||
{
|
||||
var groupSkills = filteredSkills.Where(skill => skill.SkillGroupId == group.Id).ToList();
|
||||
<SkillGroupBlock
|
||||
Title="@group.Name"
|
||||
SkillGroupId="group.Id"
|
||||
Skills="groupSkills"
|
||||
IsMutating="IsMutating"
|
||||
CanEditGroup="CanEditCharacter(SelectedCharacter)"
|
||||
CanCreateSkill="CanEditCharacter(SelectedCharacter)"
|
||||
HasSkillFilter="hasSkillFilter"
|
||||
EmptyMessage="No skills in this group yet."
|
||||
ShowGroupActions="true"
|
||||
CanEditSkill="CanEditSkill"
|
||||
SkillDefinitionLabel="SkillDefinitionLabel"
|
||||
AddSkillRequested="OnAddSkillRequestedAsync"
|
||||
EditSkillRequested="OnEditSkillRequestedAsync"
|
||||
RollSkillRequested="RollSkillAsync"
|
||||
DeleteSkillRequested="DeleteSkillAsync"
|
||||
EditGroupRequested="OnEditSkillGroupRequestedAsync"
|
||||
DeleteGroupRequested="DeleteSkillGroupAsync"/>
|
||||
}
|
||||
@if (!hasSkillFilter || ungroupedSkills.Count > 0)
|
||||
{
|
||||
<SkillGroupBlock
|
||||
Title="Ungrouped"
|
||||
SkillGroupId="@((Guid?)null)"
|
||||
Skills="ungroupedSkills"
|
||||
IsMutating="IsMutating"
|
||||
CanEditGroup="false"
|
||||
CanCreateSkill="CanEditCharacter(SelectedCharacter)"
|
||||
HasSkillFilter="hasSkillFilter"
|
||||
EmptyMessage="No ungrouped skills."
|
||||
ShowGroupActions="false"
|
||||
CanEditSkill="CanEditSkill"
|
||||
SkillDefinitionLabel="SkillDefinitionLabel"
|
||||
AddSkillRequested="OnAddSkillRequestedAsync"
|
||||
EditSkillRequested="OnEditSkillRequestedAsync"
|
||||
RollSkillRequested="RollSkillAsync"
|
||||
DeleteSkillRequested="DeleteSkillAsync"
|
||||
EditGroupRequested="OnEditSkillGroupRequestedAsync"
|
||||
DeleteGroupRequested="DeleteSkillGroupAsync"/>
|
||||
}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="add-row-button"
|
||||
disabled="@(IsMutating || !CanEditCharacter(SelectedCharacter))"
|
||||
@onclick="OpenCreateSkillGroupModal">
|
||||
<span class="skill-create-icon" aria-hidden="true">+</span>
|
||||
<span>Add group</span>
|
||||
</button>
|
||||
</article>
|
||||
}
|
||||
|
||||
<div class="character-panel-fill" aria-hidden="true"></div>
|
||||
}
|
||||
</section>
|
||||
|
||||
@if (ShowCreateSkillGroupModal || ShowEditSkillGroupModal)
|
||||
{
|
||||
<div class="modal-overlay" role="presentation">
|
||||
<section class="modal-card" role="dialog" aria-modal="true" aria-label="Skill Group">
|
||||
<h2>@(ShowEditSkillGroupModal ? "Edit Skill Group" : "Create Skill Group")</h2>
|
||||
@if (!string.IsNullOrWhiteSpace(SkillGroupState.ErrorMessage))
|
||||
{
|
||||
<p class="form-error">@SkillGroupState.ErrorMessage</p>
|
||||
}
|
||||
<form class="form-grid" @onsubmit="@(ShowEditSkillGroupModal ? SubmitUpdateSkillGroupAsync : SubmitCreateSkillGroupAsync)" @onsubmit:preventDefault>
|
||||
<label for="skill-group-name">Group name</label>
|
||||
<input id="skill-group-name" @bind="SkillGroupState.Model.Name" @bind:event="oninput"/>
|
||||
@if (SkillGroupState.Errors.TryGetValue("name", out var groupNameError))
|
||||
{
|
||||
<p class="field-error">@groupNameError</p>
|
||||
}
|
||||
|
||||
<label for="skill-group-expression">Prototype expression</label>
|
||||
<input id="skill-group-expression" value="@SkillGroupState.Model.DiceRollDefinition" @oninput="OnSkillGroupExpressionChanged"/>
|
||||
<p class="field-help">@SkillGroupExpressionHelpText</p>
|
||||
@if (SkillGroupState.Errors.TryGetValue("diceRollDefinition", out var expressionError))
|
||||
{
|
||||
<p class="field-error">@expressionError</p>
|
||||
}
|
||||
|
||||
@if (IsD6Ruleset)
|
||||
{
|
||||
<label for="skill-group-wild-dice">Prototype wild dice</label>
|
||||
<input id="skill-group-wild-dice" type="number" min="1" step="1" @bind="SkillGroupState.Model.WildDice"/>
|
||||
@if (SkillGroupState.Errors.TryGetValue("wildDice", out var wildDiceError))
|
||||
{
|
||||
<p class="field-error">@wildDiceError</p>
|
||||
}
|
||||
|
||||
<label for="skill-group-allow-fumble">Prototype allow fumble</label>
|
||||
<input id="skill-group-allow-fumble" type="checkbox" @bind="SkillGroupState.Model.AllowFumble"/>
|
||||
}
|
||||
else if (IsRolemasterRuleset)
|
||||
{
|
||||
@if (IsSkillGroupRolemasterOpenEnded)
|
||||
{
|
||||
<label for="skill-group-fumble-range">Prototype fumble range</label>
|
||||
<input id="skill-group-fumble-range" type="number" min="0" max="95" step="1" @bind="SkillGroupState.Model.FumbleRange"/>
|
||||
<p class="field-help">Used only for open-ended percentile skills created from this group.</p>
|
||||
@if (SkillGroupState.Errors.TryGetValue("fumbleRange", out var fumbleRangeError))
|
||||
{
|
||||
<p class="field-error">@fumbleRangeError</p>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@if (SkillGroupState.Errors.TryGetValue("character", out var characterError))
|
||||
{
|
||||
<p class="field-error">@characterError</p>
|
||||
}
|
||||
@if (SkillGroupState.Errors.TryGetValue("group", out var groupError))
|
||||
{
|
||||
<p class="field-error">@groupError</p>
|
||||
}
|
||||
|
||||
<div class="inline-actions">
|
||||
<button type="submit" disabled="@(IsMutating || IsSubmittingSkillGroup)">@(ShowEditSkillGroupModal ? "Save Group" : "Create Group")</button>
|
||||
<button type="button" class="ghost" disabled="@(IsMutating || IsSubmittingSkillGroup)" @onclick="CloseSkillGroupModals">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
|
||||
<SkillFormModal
|
||||
Visible="ShowCreateSkillModal"
|
||||
AutoFocusName="true"
|
||||
RulesetId="@SelectedCampaignRulesetId"
|
||||
Title="Create Skill"
|
||||
SubmitLabel="Create Skill"
|
||||
NameInputId="skill-create-name"
|
||||
ExpressionInputId="skill-create-expression"
|
||||
SkillGroupInputId="skill-create-group"
|
||||
WildDiceInputId="skill-create-wild-dice"
|
||||
AllowFumbleInputId="skill-create-allow-fumble"
|
||||
FumbleRangeInputId="skill-create-fumble-range"
|
||||
InitialModel="CreateSkillInitialModel"
|
||||
FormVersion="CreateSkillFormVersion"
|
||||
SelectedCharacterId="SelectedCharacterId"
|
||||
EditingSkillId="null"
|
||||
AvailableSkillGroups="SelectedCharacterSkillGroups"
|
||||
IsMutating="IsMutating"
|
||||
SkillSaved="OnSkillCreatedAsync"
|
||||
CancelRequested="CloseSkillModals"/>
|
||||
|
||||
<SkillFormModal
|
||||
Visible="ShowEditSkillModal"
|
||||
RulesetId="@SelectedCampaignRulesetId"
|
||||
Title="Edit Skill"
|
||||
SubmitLabel="Save Skill"
|
||||
NameInputId="skill-edit-name"
|
||||
ExpressionInputId="skill-edit-expression"
|
||||
SkillGroupInputId="skill-edit-group"
|
||||
WildDiceInputId="skill-edit-wild-dice"
|
||||
AllowFumbleInputId="skill-edit-allow-fumble"
|
||||
FumbleRangeInputId="skill-edit-fumble-range"
|
||||
InitialModel="EditSkillInitialModel"
|
||||
FormVersion="EditSkillFormVersion"
|
||||
SelectedCharacterId="SelectedCharacterId"
|
||||
EditingSkillId="EditingSkillId"
|
||||
AvailableSkillGroups="SelectedCharacterSkillGroups"
|
||||
IsMutating="IsMutating"
|
||||
SkillSaved="OnSkillUpdatedAsync"
|
||||
CancelRequested="CloseSkillModals"/>
|
||||
402
RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor.cs
Normal file
402
RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor.cs
Normal file
@@ -0,0 +1,402 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using RpgRoller.Contracts;
|
||||
|
||||
namespace RpgRoller.Components.Pages.HomeControls;
|
||||
|
||||
[ExcludeFromCodeCoverage]
|
||||
public partial class CharacterPanel
|
||||
{
|
||||
private void OpenCreateSkillModal(Guid? skillGroupId = null)
|
||||
{
|
||||
var selectedGroup = skillGroupId.HasValue
|
||||
? SelectedCharacterSkillGroups.FirstOrDefault(group => group.Id == skillGroupId.Value)
|
||||
: null;
|
||||
|
||||
CreateSkillInitialModel = new()
|
||||
{
|
||||
Name = string.Empty,
|
||||
RulesetId = SelectedCampaignRulesetId,
|
||||
DiceRollDefinition = selectedGroup?.DiceRollDefinition ?? string.Empty,
|
||||
SkillGroupId = selectedGroup?.Id.ToString() ?? string.Empty,
|
||||
WildDice = selectedGroup?.WildDice ?? (IsD6Ruleset ? 1 : 0),
|
||||
AllowFumble = selectedGroup?.AllowFumble ?? IsD6Ruleset,
|
||||
FumbleRange = selectedGroup?.FumbleRange,
|
||||
RolemasterAutoRetry = false
|
||||
};
|
||||
|
||||
if (IsRolemasterRuleset && string.IsNullOrWhiteSpace(CreateSkillInitialModel.DiceRollDefinition))
|
||||
CreateSkillInitialModel.DiceRollDefinition = "d100";
|
||||
|
||||
CreateSkillFormVersion++;
|
||||
ShowCreateSkillModal = true;
|
||||
}
|
||||
|
||||
private void OpenEditSkillModal(CharacterSheetSkill skill)
|
||||
{
|
||||
EditingSkillId = skill.Id;
|
||||
EditSkillInitialModel = new()
|
||||
{
|
||||
Name = skill.Name,
|
||||
RulesetId = SelectedCampaignRulesetId,
|
||||
DiceRollDefinition = skill.DiceRollDefinition,
|
||||
SkillGroupId = skill.SkillGroupId?.ToString() ?? string.Empty,
|
||||
WildDice = skill.WildDice,
|
||||
AllowFumble = skill.AllowFumble,
|
||||
FumbleRange = skill.FumbleRange,
|
||||
RolemasterAutoRetry = skill.RolemasterAutoRetry
|
||||
};
|
||||
|
||||
EditSkillFormVersion++;
|
||||
ShowEditSkillModal = true;
|
||||
}
|
||||
|
||||
private void CloseSkillModals()
|
||||
{
|
||||
ShowCreateSkillModal = false;
|
||||
ShowEditSkillModal = false;
|
||||
EditingSkillId = null;
|
||||
}
|
||||
|
||||
private async Task OnSkillCreatedAsync(Guid skillId)
|
||||
{
|
||||
CloseSkillModals();
|
||||
await SkillCreated.InvokeAsync(skillId);
|
||||
}
|
||||
|
||||
private async Task OnSkillUpdatedAsync(Guid skillId)
|
||||
{
|
||||
CloseSkillModals();
|
||||
await SkillUpdated.InvokeAsync(skillId);
|
||||
}
|
||||
|
||||
private async Task OnRollVisibilityChangedAsync(ChangeEventArgs args)
|
||||
{
|
||||
var selectedVisibility = args.Value?.ToString() ?? "public";
|
||||
await RollVisibilityChanged.InvokeAsync(selectedVisibility);
|
||||
}
|
||||
|
||||
private async Task RollSkillAsync(CharacterSheetSkill skill)
|
||||
{
|
||||
await RollRequested.InvokeAsync(skill);
|
||||
}
|
||||
|
||||
private Task OnAddSkillRequestedAsync(Guid? skillGroupId)
|
||||
{
|
||||
OpenCreateSkillModal(skillGroupId);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private Task OnEditSkillRequestedAsync(CharacterSheetSkill skill)
|
||||
{
|
||||
OpenEditSkillModal(skill);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private Task OnEditSkillGroupRequestedAsync(Guid skillGroupId)
|
||||
{
|
||||
var skillGroup = SelectedCharacterSkillGroups.FirstOrDefault(group => group.Id == skillGroupId);
|
||||
if (skillGroup is not null)
|
||||
OpenEditSkillGroupModal(skillGroup);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void OpenCreateSkillGroupModal()
|
||||
{
|
||||
SkillGroupState.Model.Name = string.Empty;
|
||||
SkillGroupState.Model.RulesetId = SelectedCampaignRulesetId;
|
||||
SkillGroupState.Model.DiceRollDefinition = string.Empty;
|
||||
SkillGroupState.Model.WildDice = IsD6Ruleset ? 1 : 0;
|
||||
SkillGroupState.Model.AllowFumble = IsD6Ruleset;
|
||||
SkillGroupState.Model.FumbleRange = null;
|
||||
if (IsRolemasterRuleset)
|
||||
SkillGroupState.Model.DiceRollDefinition = "d100";
|
||||
SkillGroupState.ResetValidation();
|
||||
ShowCreateSkillGroupModal = true;
|
||||
}
|
||||
|
||||
private void OpenEditSkillGroupModal(CharacterSheetSkillGroup skillGroup)
|
||||
{
|
||||
EditingSkillGroupId = skillGroup.Id;
|
||||
SkillGroupState.Model.Name = skillGroup.Name;
|
||||
SkillGroupState.Model.RulesetId = SelectedCampaignRulesetId;
|
||||
SkillGroupState.Model.DiceRollDefinition = skillGroup.DiceRollDefinition;
|
||||
SkillGroupState.Model.WildDice = skillGroup.WildDice;
|
||||
SkillGroupState.Model.AllowFumble = skillGroup.AllowFumble;
|
||||
SkillGroupState.Model.FumbleRange = skillGroup.FumbleRange;
|
||||
NormalizeSkillGroupFumbleRange();
|
||||
SkillGroupState.ResetValidation();
|
||||
ShowEditSkillGroupModal = true;
|
||||
}
|
||||
|
||||
private void CloseSkillGroupModals()
|
||||
{
|
||||
ShowCreateSkillGroupModal = false;
|
||||
ShowEditSkillGroupModal = false;
|
||||
EditingSkillGroupId = null;
|
||||
SkillGroupState.ResetValidation();
|
||||
}
|
||||
|
||||
private async Task SubmitCreateSkillGroupAsync()
|
||||
{
|
||||
SkillGroupState.ResetValidation();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(SkillGroupState.Model.Name))
|
||||
SkillGroupState.Errors["name"] = "Skill group name is required.";
|
||||
|
||||
if (string.IsNullOrWhiteSpace(SkillGroupState.Model.DiceRollDefinition))
|
||||
SkillGroupState.Errors["diceRollDefinition"] = "Expression is required.";
|
||||
|
||||
if (IsD6Ruleset && SkillGroupState.Model.WildDice < 1)
|
||||
SkillGroupState.Errors["wildDice"] = "D6 groups require at least one wild die.";
|
||||
|
||||
if (IsRolemasterRuleset)
|
||||
{
|
||||
if (IsSkillGroupRolemasterOpenEnded && !SkillGroupState.Model.FumbleRange.HasValue)
|
||||
SkillGroupState.Errors["fumbleRange"] = "Open-ended Rolemaster groups require a fumble range.";
|
||||
}
|
||||
else
|
||||
SkillGroupState.Model.FumbleRange = null;
|
||||
|
||||
if (!IsD6Ruleset)
|
||||
{
|
||||
SkillGroupState.Model.WildDice = 0;
|
||||
SkillGroupState.Model.AllowFumble = false;
|
||||
}
|
||||
|
||||
if (!SelectedCharacterId.HasValue)
|
||||
SkillGroupState.Errors["character"] = "Select a character first.";
|
||||
|
||||
if (SkillGroupState.Errors.Count > 0)
|
||||
{
|
||||
SkillGroupState.ErrorMessage = "Resolve validation issues before submitting.";
|
||||
return;
|
||||
}
|
||||
|
||||
IsSubmittingSkillGroup = true;
|
||||
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));
|
||||
CloseSkillGroupModals();
|
||||
await SkillGroupCreated.InvokeAsync(createdGroup.Id);
|
||||
}
|
||||
catch (ApiRequestException ex)
|
||||
{
|
||||
SkillGroupState.ErrorMessage = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsSubmittingSkillGroup = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SubmitUpdateSkillGroupAsync()
|
||||
{
|
||||
SkillGroupState.ResetValidation();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(SkillGroupState.Model.Name))
|
||||
SkillGroupState.Errors["name"] = "Skill group name is required.";
|
||||
|
||||
if (string.IsNullOrWhiteSpace(SkillGroupState.Model.DiceRollDefinition))
|
||||
SkillGroupState.Errors["diceRollDefinition"] = "Expression is required.";
|
||||
|
||||
if (IsD6Ruleset && SkillGroupState.Model.WildDice < 1)
|
||||
SkillGroupState.Errors["wildDice"] = "D6 groups require at least one wild die.";
|
||||
|
||||
if (IsRolemasterRuleset)
|
||||
{
|
||||
if (IsSkillGroupRolemasterOpenEnded && !SkillGroupState.Model.FumbleRange.HasValue)
|
||||
SkillGroupState.Errors["fumbleRange"] = "Open-ended Rolemaster groups require a fumble range.";
|
||||
}
|
||||
else
|
||||
SkillGroupState.Model.FumbleRange = null;
|
||||
|
||||
if (!IsD6Ruleset)
|
||||
{
|
||||
SkillGroupState.Model.WildDice = 0;
|
||||
SkillGroupState.Model.AllowFumble = false;
|
||||
}
|
||||
|
||||
if (!EditingSkillGroupId.HasValue)
|
||||
SkillGroupState.Errors["group"] = "Select a skill group first.";
|
||||
|
||||
if (SkillGroupState.Errors.Count > 0)
|
||||
{
|
||||
SkillGroupState.ErrorMessage = "Resolve validation issues before submitting.";
|
||||
return;
|
||||
}
|
||||
|
||||
IsSubmittingSkillGroup = true;
|
||||
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));
|
||||
CloseSkillGroupModals();
|
||||
await SkillGroupUpdated.InvokeAsync(updatedGroup.Id);
|
||||
}
|
||||
catch (ApiRequestException ex)
|
||||
{
|
||||
SkillGroupState.ErrorMessage = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsSubmittingSkillGroup = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteSkillAsync(Guid skillId)
|
||||
{
|
||||
try
|
||||
{
|
||||
await ApiClient.RequestAsync<bool>("DELETE", $"/api/skills/{skillId}");
|
||||
await SkillDeleted.InvokeAsync(skillId);
|
||||
}
|
||||
catch (ApiRequestException ex)
|
||||
{
|
||||
await ErrorOccurred.InvokeAsync(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteSkillGroupAsync(Guid skillGroupId)
|
||||
{
|
||||
try
|
||||
{
|
||||
await ApiClient.RequestAsync<bool>("DELETE", $"/api/skill-groups/{skillGroupId}");
|
||||
await SkillGroupDeleted.InvokeAsync(skillGroupId);
|
||||
}
|
||||
catch (ApiRequestException ex)
|
||||
{
|
||||
await ErrorOccurred.InvokeAsync(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private bool SkillMatchesFilter(CharacterSheetSkill skill)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(SkillFilterText))
|
||||
return true;
|
||||
|
||||
var filter = SkillFilterText.Trim();
|
||||
return skill.Name.Contains(filter, StringComparison.OrdinalIgnoreCase) ||
|
||||
skill.DiceRollDefinition.Contains(filter, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string InitialsFor(string value)
|
||||
{
|
||||
var words = value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (words.Length == 0)
|
||||
return "?";
|
||||
|
||||
if (words.Length == 1)
|
||||
return words[0].Length >= 2 ? words[0][..2].ToUpperInvariant() : words[0].ToUpperInvariant();
|
||||
|
||||
return string.Concat(words[0][0], words[1][0]).ToUpperInvariant();
|
||||
}
|
||||
|
||||
private void OnSkillGroupExpressionChanged(ChangeEventArgs args)
|
||||
{
|
||||
SkillGroupState.Model.DiceRollDefinition = args.Value?.ToString() ?? string.Empty;
|
||||
if (IsRolemasterRuleset)
|
||||
NormalizeSkillGroupFumbleRange();
|
||||
}
|
||||
|
||||
private void NormalizeSkillGroupFumbleRange()
|
||||
{
|
||||
if (!IsRolemasterRuleset)
|
||||
{
|
||||
SkillGroupState.Model.FumbleRange = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (IsSkillGroupRolemasterOpenEnded)
|
||||
{
|
||||
SkillGroupState.Model.FumbleRange ??= 5;
|
||||
return;
|
||||
}
|
||||
|
||||
SkillGroupState.Model.FumbleRange = null;
|
||||
}
|
||||
|
||||
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 ShowCreateSkillModal { get; set; }
|
||||
private bool ShowEditSkillModal { get; set; }
|
||||
private bool ShowCreateSkillGroupModal { get; set; }
|
||||
private bool ShowEditSkillGroupModal { get; set; }
|
||||
private Guid? EditingSkillId { get; set; }
|
||||
private Guid? EditingSkillGroupId { get; set; }
|
||||
private SkillFormModel CreateSkillInitialModel { get; set; } = new();
|
||||
private SkillFormModel EditSkillInitialModel { get; set; } = new();
|
||||
private FormState<SkillGroupFormModel> SkillGroupState { get; } = new();
|
||||
private int CreateSkillFormVersion { get; set; }
|
||||
private int EditSkillFormVersion { get; set; }
|
||||
private bool IsSubmittingSkillGroup { get; set; }
|
||||
private string SkillFilterText { get; set; } = string.Empty;
|
||||
|
||||
[Inject] private RpgRollerApiClient ApiClient { get; set; } = null!;
|
||||
|
||||
[Parameter] public bool IsCampaignDataLoading { get; set; }
|
||||
|
||||
[Parameter] public CampaignRoster? SelectedCampaign { get; set; }
|
||||
|
||||
[Parameter] public Guid? SelectedCharacterId { get; set; }
|
||||
|
||||
[Parameter] public CharacterSummary? SelectedCharacter { get; set; }
|
||||
|
||||
[Parameter] public bool IsMutating { get; set; }
|
||||
|
||||
[Parameter] public IReadOnlyList<CharacterSheetSkill> SelectedCharacterSkills { get; set; } = [];
|
||||
|
||||
[Parameter] public IReadOnlyList<CharacterSheetSkillGroup> SelectedCharacterSkillGroups { get; set; } = [];
|
||||
|
||||
[Parameter] public string SelectedCampaignRulesetId { get; set; } = string.Empty;
|
||||
|
||||
[Parameter] public string RollVisibility { get; set; } = "public";
|
||||
|
||||
[Parameter] public EventCallback<string> RollVisibilityChanged { get; set; }
|
||||
|
||||
[Parameter] public Func<Guid, string> OwnerLabel { 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<CharacterSheetSkill, bool> CanEditSkill { get; set; } = _ => false;
|
||||
|
||||
[Parameter] public EventCallback<Guid> CharacterSelected { get; set; }
|
||||
|
||||
[Parameter] public EventCallback<CharacterSummary> EditCharacterRequested { get; set; }
|
||||
|
||||
[Parameter] public EventCallback<Guid> SkillCreated { get; set; }
|
||||
|
||||
[Parameter] public EventCallback<Guid> SkillUpdated { get; set; }
|
||||
|
||||
[Parameter] public EventCallback<Guid> SkillGroupCreated { get; set; }
|
||||
|
||||
[Parameter] public EventCallback<Guid> SkillGroupUpdated { get; set; }
|
||||
|
||||
[Parameter] public EventCallback<Guid> SkillDeleted { get; set; }
|
||||
|
||||
[Parameter] public EventCallback<Guid> SkillGroupDeleted { get; set; }
|
||||
|
||||
[Parameter] public EventCallback<string> ErrorOccurred { get; set; }
|
||||
|
||||
[Parameter] public EventCallback<CharacterSheetSkill> RollRequested { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
@if (Visible)
|
||||
{
|
||||
<div class="modal-overlay" role="presentation" @onclick="HandleOverlayClickAsync">
|
||||
<section class="modal-card rolemaster-roll-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Rolemaster situational modifier"
|
||||
tabindex="-1"
|
||||
@onclick:stopPropagation="true"
|
||||
@onkeydown="HandleKeyDownAsync">
|
||||
<h2>Rolemaster skill roll</h2>
|
||||
<p class="muted">Roll <strong>@SkillName</strong> using <code>@Expression</code>.</p>
|
||||
@if (!string.IsNullOrWhiteSpace(ErrorMessage))
|
||||
{
|
||||
<p class="form-error">@ErrorMessage</p>
|
||||
}
|
||||
<form class="form-grid" @onsubmit="SubmitAsync" @onsubmit:preventDefault>
|
||||
<label for="@ModifierInputId">Situational modifier</label>
|
||||
<input id="@ModifierInputId"
|
||||
@ref="ModifierInputElement"
|
||||
value="@CurrentModifierText"
|
||||
@oninput="OnModifierInput"
|
||||
placeholder="Blank = 0"
|
||||
inputmode="numeric"
|
||||
autocomplete="off"/>
|
||||
<p class="field-help">Optional one-shot bonus or penalty. Examples: <code>20</code>, <code>-15</code>, or blank for <code>0</code>.</p>
|
||||
<div class="inline-actions">
|
||||
<button type="submit" disabled="@(IsMutating || IsSubmitting)">Roll</button>
|
||||
<button type="button" class="ghost" disabled="@(IsMutating || IsSubmitting)" @onclick="CancelRequested">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Web;
|
||||
|
||||
namespace RpgRoller.Components.Pages.HomeControls;
|
||||
|
||||
[ExcludeFromCodeCoverage]
|
||||
public partial class RolemasterSkillRollModal
|
||||
{
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
CurrentModifierText = ModifierText;
|
||||
if (!Visible || WasVisible)
|
||||
{
|
||||
WasVisible = Visible;
|
||||
return;
|
||||
}
|
||||
|
||||
PendingFocus = true;
|
||||
WasVisible = true;
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (!Visible || !PendingFocus)
|
||||
return;
|
||||
|
||||
PendingFocus = false;
|
||||
await ModifierInputElement.FocusAsync();
|
||||
}
|
||||
|
||||
private Task OnModifierInput(ChangeEventArgs args)
|
||||
{
|
||||
CurrentModifierText = args.Value?.ToString() ?? string.Empty;
|
||||
return ModifierTextChanged.InvokeAsync(CurrentModifierText);
|
||||
}
|
||||
|
||||
private Task SubmitAsync()
|
||||
{
|
||||
return ConfirmRequested.InvokeAsync(CurrentModifierText);
|
||||
}
|
||||
|
||||
private Task HandleOverlayClickAsync()
|
||||
{
|
||||
if (IsMutating || IsSubmitting)
|
||||
return Task.CompletedTask;
|
||||
|
||||
return CancelRequested.InvokeAsync();
|
||||
}
|
||||
|
||||
private Task HandleKeyDownAsync(KeyboardEventArgs args)
|
||||
{
|
||||
if ((IsMutating || IsSubmitting) || !string.Equals(args.Key, "Escape", StringComparison.Ordinal))
|
||||
return Task.CompletedTask;
|
||||
|
||||
return CancelRequested.InvokeAsync();
|
||||
}
|
||||
|
||||
private bool PendingFocus { get; set; }
|
||||
private bool WasVisible { get; set; }
|
||||
private string CurrentModifierText { get; set; } = string.Empty;
|
||||
private ElementReference ModifierInputElement { get; set; }
|
||||
|
||||
[Parameter] public bool Visible { get; set; }
|
||||
|
||||
[Parameter] public string SkillName { get; set; } = string.Empty;
|
||||
|
||||
[Parameter] public string Expression { get; set; } = string.Empty;
|
||||
|
||||
[Parameter] public string ModifierText { get; set; } = string.Empty;
|
||||
|
||||
[Parameter] public EventCallback<string> ModifierTextChanged { get; set; }
|
||||
|
||||
[Parameter] public string? ErrorMessage { get; set; }
|
||||
|
||||
[Parameter] public bool IsMutating { get; set; }
|
||||
|
||||
[Parameter] public bool IsSubmitting { get; set; }
|
||||
|
||||
[Parameter] public string ModifierInputId { get; set; } = "rolemaster-situational-modifier";
|
||||
|
||||
[Parameter] public EventCallback<string> ConfirmRequested { get; set; }
|
||||
|
||||
[Parameter] public EventCallback CancelRequested { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
@if (Dice.Count > 0)
|
||||
{
|
||||
<div class="roll-dice-strip" aria-label="@AriaLabel">
|
||||
@foreach (var die in Dice)
|
||||
{
|
||||
<span class="@RollDieCssClass(die)" title="@RollDieTitle(die)">@RollDieDisplay(die)</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
135
RpgRoller/Components/Pages/HomeControls/RollDiceStrip.razor.cs
Normal file
135
RpgRoller/Components/Pages/HomeControls/RollDiceStrip.razor.cs
Normal file
@@ -0,0 +1,135 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using RpgRoller.Contracts;
|
||||
|
||||
namespace RpgRoller.Components.Pages.HomeControls;
|
||||
|
||||
[ExcludeFromCodeCoverage]
|
||||
public partial class RollDiceStrip
|
||||
{
|
||||
private static string RollDieGlyph(int roll)
|
||||
{
|
||||
return roll switch
|
||||
{
|
||||
1 => "\u2680",
|
||||
2 => "\u2681",
|
||||
3 => "\u2682",
|
||||
4 => "\u2683",
|
||||
5 => "\u2684",
|
||||
6 => "\u2685",
|
||||
_ => roll.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
private static string RollDieDisplay(RollDieResult die)
|
||||
{
|
||||
if (string.Equals(die.Kind, RollDieKinds.RolemasterOpenEndedInitial, StringComparison.Ordinal) && !die.SignedContribution.HasValue)
|
||||
return $"({die.Roll:00})";
|
||||
|
||||
if (IsRolemasterDie(die))
|
||||
{
|
||||
return die.Kind switch
|
||||
{
|
||||
RollDieKinds.RolemasterOpenEndedHigh => $"+{die.Roll:00}",
|
||||
RollDieKinds.RolemasterOpenEndedLowSubtract => $"-{die.Roll:00}",
|
||||
_ => die.Roll.ToString("00")
|
||||
};
|
||||
}
|
||||
|
||||
return die.Kind switch
|
||||
{
|
||||
_ => RollDieGlyph(die.Roll)
|
||||
};
|
||||
}
|
||||
|
||||
private static string RollDieCssClass(RollDieResult die)
|
||||
{
|
||||
var classes = new List<string> { "die-chip" };
|
||||
if (die.Wild)
|
||||
classes.Add("wild");
|
||||
|
||||
if (die.Crit)
|
||||
classes.Add("crit");
|
||||
|
||||
if (die.Fumble)
|
||||
classes.Add("fumble");
|
||||
|
||||
if (die.Removed)
|
||||
classes.Add("removed");
|
||||
|
||||
if (die.Added)
|
||||
classes.Add("added");
|
||||
|
||||
switch (die.Kind)
|
||||
{
|
||||
case RollDieKinds.RolemasterStandard:
|
||||
classes.Add("rolemaster-standard");
|
||||
break;
|
||||
case RollDieKinds.RolemasterOpenEndedInitial:
|
||||
classes.Add("rolemaster-open-ended-initial");
|
||||
break;
|
||||
case RollDieKinds.RolemasterOpenEndedHigh:
|
||||
classes.Add("rolemaster-open-ended-high");
|
||||
break;
|
||||
case RollDieKinds.RolemasterOpenEndedLowSubtract:
|
||||
classes.Add("rolemaster-open-ended-low-subtract");
|
||||
break;
|
||||
}
|
||||
|
||||
return string.Join(" ", classes);
|
||||
}
|
||||
|
||||
private static bool IsRolemasterDie(RollDieResult die)
|
||||
{
|
||||
return die.Kind is RollDieKinds.RolemasterStandard or RollDieKinds.RolemasterOpenEndedInitial or RollDieKinds.RolemasterOpenEndedHigh or RollDieKinds.RolemasterOpenEndedLowSubtract;
|
||||
}
|
||||
|
||||
private static string RollDieTitle(RollDieResult die)
|
||||
{
|
||||
var labels = new List<string> { $"Roll {die.Roll}" };
|
||||
if (die.Sequence.HasValue)
|
||||
labels.Add($"step {die.Sequence.Value}");
|
||||
|
||||
if (die.Attempt.HasValue)
|
||||
labels.Add(die.Attempt.Value == 1 ? "attempt 1" : $"retry attempt {die.Attempt.Value}");
|
||||
|
||||
if (die.Wild)
|
||||
labels.Add("wild");
|
||||
|
||||
if (die.Crit)
|
||||
labels.Add("critical");
|
||||
|
||||
if (die.Fumble)
|
||||
labels.Add("fumble");
|
||||
|
||||
if (die.Removed)
|
||||
labels.Add("removed");
|
||||
|
||||
if (die.Added)
|
||||
labels.Add("added");
|
||||
|
||||
switch (die.Kind)
|
||||
{
|
||||
case RollDieKinds.RolemasterStandard:
|
||||
labels.Add("Rolemaster roll");
|
||||
break;
|
||||
case RollDieKinds.RolemasterOpenEndedInitial:
|
||||
labels.Add(die.SignedContribution.HasValue ? "Rolemaster open-ended initial" : "Rolemaster low-end trigger (ignored in total)");
|
||||
break;
|
||||
case RollDieKinds.RolemasterOpenEndedHigh:
|
||||
labels.Add($"Rolemaster high follow-up (+{die.Roll})");
|
||||
break;
|
||||
case RollDieKinds.RolemasterOpenEndedLowSubtract:
|
||||
labels.Add($"Rolemaster low-end subtraction (-{die.Roll})");
|
||||
break;
|
||||
}
|
||||
|
||||
return string.Join(", ", labels);
|
||||
}
|
||||
|
||||
[Parameter]
|
||||
public IReadOnlyList<RollDieResult> Dice { get; set; } = [];
|
||||
|
||||
[Parameter]
|
||||
public string AriaLabel { get; set; } = "Rolled dice";
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using RpgRoller.Domain;
|
||||
using RpgRoller.Services;
|
||||
|
||||
namespace RpgRoller.Components.Pages.HomeControls;
|
||||
|
||||
[ExcludeFromCodeCoverage]
|
||||
internal static class RulesetFormHelpers
|
||||
{
|
||||
internal static class RulesetIds
|
||||
{
|
||||
public const string D6 = "d6";
|
||||
public const string Dnd5e = "dnd5e";
|
||||
public const string Rolemaster = "rolemaster";
|
||||
}
|
||||
|
||||
public static bool IsD6(string? rulesetId)
|
||||
{
|
||||
return string.Equals(rulesetId, RulesetIds.D6, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public static bool IsRolemaster(string? rulesetId)
|
||||
{
|
||||
return string.Equals(rulesetId, RulesetIds.Rolemaster, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public static bool IsRolemasterOpenEndedExpression(string? expression)
|
||||
{
|
||||
var parseResult = TryParseRolemasterExpression(expression);
|
||||
return parseResult.Succeeded && parseResult.Value is not null && parseResult.Value.Kind == DiceExpressionKind.RolemasterOpenEndedPercentile;
|
||||
}
|
||||
|
||||
public static string DescribeRolemasterExpression(string expression, int? fumbleRange, bool rolemasterAutoRetry = false)
|
||||
{
|
||||
var parseResult = TryParseRolemasterExpression(expression);
|
||||
if (!parseResult.Succeeded || parseResult.Value is null)
|
||||
return expression;
|
||||
|
||||
return parseResult.Value.Kind switch
|
||||
{
|
||||
DiceExpressionKind.RolemasterOpenEndedPercentile => DescribeOpenEndedExpression(parseResult.Value.Canonical, fumbleRange, rolemasterAutoRetry),
|
||||
_ => $"Rolemaster: {parseResult.Value.Canonical}"
|
||||
};
|
||||
}
|
||||
|
||||
public static string RolemasterExampleText()
|
||||
{
|
||||
return "Examples: d10, 15d10, d100-15, d100!+85";
|
||||
}
|
||||
|
||||
private static ServiceResult<DiceExpression> TryParseRolemasterExpression(string? expression)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(expression))
|
||||
return ServiceResult<DiceExpression>.Failure("invalid_expression", "Expression is required.");
|
||||
|
||||
return DiceRules.ParseExpression(RulesetKind.Rolemaster, expression);
|
||||
}
|
||||
|
||||
private static string DescribeOpenEndedExpression(string canonicalExpression, int? fumbleRange, bool rolemasterAutoRetry)
|
||||
{
|
||||
var parts = new List<string> { $"Open-ended percentile: {canonicalExpression}" };
|
||||
|
||||
if (fumbleRange.HasValue)
|
||||
parts.Add($"fumble <= {fumbleRange.Value}");
|
||||
|
||||
if (rolemasterAutoRetry)
|
||||
parts.Add("auto retry");
|
||||
|
||||
return string.Join(", ", parts);
|
||||
}
|
||||
}
|
||||
72
RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor
Normal file
72
RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor
Normal file
@@ -0,0 +1,72 @@
|
||||
@if (Visible)
|
||||
{
|
||||
<div class="modal-overlay" role="presentation">
|
||||
<section class="modal-card" role="dialog" aria-modal="true" aria-label="@Title">
|
||||
<h2>@Title</h2>
|
||||
@if (!string.IsNullOrWhiteSpace(FormState.ErrorMessage))
|
||||
{
|
||||
<p class="form-error">@FormState.ErrorMessage</p>
|
||||
}
|
||||
<form class="form-grid" @onsubmit="SubmitAsync" @onsubmit:preventDefault>
|
||||
<label for="@NameInputId">Skill name</label>
|
||||
<input id="@NameInputId" @ref="NameInputElement" @bind="FormState.Model.Name" @bind:event="oninput"/>
|
||||
@if (FormState.Errors.TryGetValue("name", out var skillNameError))
|
||||
{
|
||||
<p class="field-error">@skillNameError</p>
|
||||
}
|
||||
<label for="@ExpressionInputId">Expression</label>
|
||||
<input id="@ExpressionInputId" value="@FormState.Model.DiceRollDefinition" @oninput="OnExpressionChanged"/>
|
||||
<p class="field-help">@ExpressionHelpText</p>
|
||||
@if (FormState.Errors.TryGetValue("diceRollDefinition", out var expressionError))
|
||||
{
|
||||
<p class="field-error">@expressionError</p>
|
||||
}
|
||||
<label for="@SkillGroupInputId">Group</label>
|
||||
<select id="@SkillGroupInputId" @bind="FormState.Model.SkillGroupId">
|
||||
<option value="">No group</option>
|
||||
@foreach (var group in AvailableSkillGroups)
|
||||
{
|
||||
<option value="@group.Id">@group.Name</option>
|
||||
}
|
||||
</select>
|
||||
@if (FormState.Errors.TryGetValue("skillGroupId", out var skillGroupError))
|
||||
{
|
||||
<p class="field-error">@skillGroupError</p>
|
||||
}
|
||||
@if (IsD6Ruleset)
|
||||
{
|
||||
<label for="@WildDiceInputId">Wild dice</label>
|
||||
<input id="@WildDiceInputId" type="number" min="1" step="1" @bind="FormState.Model.WildDice"/>
|
||||
@if (FormState.Errors.TryGetValue("wildDice", out var wildDiceError))
|
||||
{
|
||||
<p class="field-error">@wildDiceError</p>
|
||||
}
|
||||
|
||||
<label for="@AllowFumbleInputId">Allow fumble</label>
|
||||
<input id="@AllowFumbleInputId" type="checkbox" @bind="FormState.Model.AllowFumble"/>
|
||||
}
|
||||
else if (IsRolemasterRuleset)
|
||||
{
|
||||
@if (IsRolemasterOpenEndedSelected)
|
||||
{
|
||||
<label for="@FumbleRangeInputId">Fumble range</label>
|
||||
<input id="@FumbleRangeInputId" type="number" min="0" max="95" step="1" @bind="FormState.Model.FumbleRange"/>
|
||||
<p class="field-help">Used only for low-end open-ended rolls. Allowed range: 0 to 95.</p>
|
||||
@if (FormState.Errors.TryGetValue("fumbleRange", out var fumbleRangeError))
|
||||
{
|
||||
<p class="field-error">@fumbleRangeError</p>
|
||||
}
|
||||
|
||||
<label for="skill-auto-retry">Automatic retry</label>
|
||||
<input id="skill-auto-retry" type="checkbox" @bind="FormState.Model.RolemasterAutoRetry"/>
|
||||
<p class="field-help">When later enabled in rolling, retry bands are 76-90 and 91-110.</p>
|
||||
}
|
||||
}
|
||||
<div class="inline-actions">
|
||||
<button type="submit" disabled="@(IsMutating || IsSubmitting)">@SubmitLabel</button>
|
||||
<button type="button" class="ghost" @onclick="CancelRequested">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
210
RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor.cs
Normal file
210
RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor.cs
Normal file
@@ -0,0 +1,210 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using RpgRoller.Contracts;
|
||||
|
||||
namespace RpgRoller.Components.Pages.HomeControls;
|
||||
|
||||
[ExcludeFromCodeCoverage]
|
||||
public partial class SkillFormModal
|
||||
{
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (!Visible || FormVersion == AppliedFormVersion)
|
||||
return;
|
||||
|
||||
FormState.Model.Name = InitialModel.Name;
|
||||
FormState.Model.RulesetId = RulesetId;
|
||||
FormState.Model.DiceRollDefinition = InitialModel.DiceRollDefinition;
|
||||
FormState.Model.SkillGroupId = InitialModel.SkillGroupId;
|
||||
FormState.Model.WildDice = InitialModel.WildDice;
|
||||
FormState.Model.AllowFumble = InitialModel.AllowFumble;
|
||||
FormState.Model.FumbleRange = InitialModel.FumbleRange;
|
||||
FormState.Model.RolemasterAutoRetry = InitialModel.RolemasterAutoRetry;
|
||||
SynchronizeRulesetSpecificFields();
|
||||
FormState.ResetValidation();
|
||||
AppliedFormVersion = FormVersion;
|
||||
PendingNameFocus = AutoFocusName;
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (!Visible || !PendingNameFocus)
|
||||
return;
|
||||
|
||||
PendingNameFocus = false;
|
||||
await NameInputElement.FocusAsync();
|
||||
}
|
||||
|
||||
private async Task SubmitAsync()
|
||||
{
|
||||
FormState.ResetValidation();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(FormState.Model.Name))
|
||||
FormState.Errors["name"] = "Skill name is required.";
|
||||
|
||||
if (string.IsNullOrWhiteSpace(FormState.Model.DiceRollDefinition))
|
||||
FormState.Errors["diceRollDefinition"] = "Expression is required.";
|
||||
|
||||
if (IsD6Ruleset && FormState.Model.WildDice < 1)
|
||||
FormState.Errors["wildDice"] = "D6 skills require at least one wild die.";
|
||||
|
||||
if (IsRolemasterRuleset)
|
||||
{
|
||||
if (IsRolemasterOpenEndedSelected && !FormState.Model.FumbleRange.HasValue)
|
||||
FormState.Errors["fumbleRange"] = "Open-ended Rolemaster skills require a fumble range.";
|
||||
}
|
||||
else
|
||||
{
|
||||
FormState.Model.FumbleRange = null;
|
||||
FormState.Model.RolemasterAutoRetry = false;
|
||||
}
|
||||
|
||||
if (!IsD6Ruleset)
|
||||
{
|
||||
FormState.Model.WildDice = 0;
|
||||
FormState.Model.AllowFumble = false;
|
||||
}
|
||||
|
||||
Guid? skillGroupId = null;
|
||||
if (!string.IsNullOrWhiteSpace(FormState.Model.SkillGroupId))
|
||||
{
|
||||
if (!Guid.TryParse(FormState.Model.SkillGroupId, out var parsedSkillGroupId))
|
||||
FormState.Errors["skillGroupId"] = "Skill group is invalid.";
|
||||
else
|
||||
skillGroupId = parsedSkillGroupId;
|
||||
}
|
||||
|
||||
if (FormState.Errors.Count > 0)
|
||||
{
|
||||
FormState.ErrorMessage = "Resolve validation issues before submitting.";
|
||||
return;
|
||||
}
|
||||
|
||||
IsSubmitting = true;
|
||||
try
|
||||
{
|
||||
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));
|
||||
else
|
||||
{
|
||||
if (!SelectedCharacterId.HasValue)
|
||||
{
|
||||
FormState.ErrorMessage = "Select a character first.";
|
||||
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));
|
||||
}
|
||||
|
||||
await SkillSaved.InvokeAsync(skill.Id);
|
||||
}
|
||||
catch (ApiRequestException ex)
|
||||
{
|
||||
FormState.ErrorMessage = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsSubmitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnExpressionChanged(ChangeEventArgs args)
|
||||
{
|
||||
FormState.Model.DiceRollDefinition = args.Value?.ToString() ?? string.Empty;
|
||||
if (IsRolemasterRuleset)
|
||||
NormalizeRolemasterFumbleRange();
|
||||
}
|
||||
|
||||
private void SynchronizeRulesetSpecificFields()
|
||||
{
|
||||
if (!IsRolemasterRuleset)
|
||||
{
|
||||
FormState.Model.RolemasterAutoRetry = false;
|
||||
return;
|
||||
}
|
||||
|
||||
NormalizeRolemasterFumbleRange();
|
||||
}
|
||||
|
||||
private void NormalizeRolemasterFumbleRange()
|
||||
{
|
||||
if (!IsRolemasterRuleset)
|
||||
{
|
||||
FormState.Model.FumbleRange = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (IsRolemasterOpenEndedSelected)
|
||||
{
|
||||
FormState.Model.FumbleRange ??= 5;
|
||||
return;
|
||||
}
|
||||
|
||||
FormState.Model.FumbleRange = null;
|
||||
FormState.Model.RolemasterAutoRetry = false;
|
||||
}
|
||||
|
||||
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.";
|
||||
|
||||
[Inject] private RpgRollerApiClient ApiClient { get; set; } = null!;
|
||||
|
||||
private FormState<SkillFormModel> FormState { get; } = new();
|
||||
private int AppliedFormVersion { get; set; } = -1;
|
||||
private bool IsSubmitting { get; set; }
|
||||
private bool PendingNameFocus { get; set; }
|
||||
private ElementReference NameInputElement { get; set; }
|
||||
|
||||
[Parameter] public bool Visible { get; set; }
|
||||
|
||||
[Parameter] public string RulesetId { get; set; } = string.Empty;
|
||||
|
||||
[Parameter] public string Title { get; set; } = "Skill";
|
||||
|
||||
[Parameter] public string SubmitLabel { get; set; } = "Save";
|
||||
|
||||
[Parameter] public string NameInputId { get; set; } = "skill-name";
|
||||
|
||||
[Parameter] public string ExpressionInputId { get; set; } = "skill-expression";
|
||||
|
||||
[Parameter] public string SkillGroupInputId { get; set; } = "skill-group";
|
||||
|
||||
[Parameter] public string WildDiceInputId { get; set; } = "skill-wild";
|
||||
|
||||
[Parameter] public string AllowFumbleInputId { get; set; } = "skill-fumble";
|
||||
|
||||
[Parameter] public string FumbleRangeInputId { get; set; } = "skill-fumble-range";
|
||||
|
||||
[Parameter] public SkillFormModel InitialModel { get; set; } = new();
|
||||
|
||||
[Parameter] public int FormVersion { get; set; }
|
||||
|
||||
[Parameter] public Guid? SelectedCharacterId { get; set; }
|
||||
|
||||
[Parameter] public Guid? EditingSkillId { get; set; }
|
||||
|
||||
[Parameter] public IReadOnlyList<CharacterSheetSkillGroup> AvailableSkillGroups { get; set; } = [];
|
||||
|
||||
[Parameter] public bool IsMutating { get; set; }
|
||||
|
||||
[Parameter] public bool AutoFocusName { get; set; }
|
||||
|
||||
[Parameter] public EventCallback<Guid> SkillSaved { get; set; }
|
||||
|
||||
[Parameter] public EventCallback CancelRequested { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
<div class="skill-group-block">
|
||||
<div class="skill-group-head">
|
||||
<strong>@Title</strong>
|
||||
@if (ShowGroupActions && SkillGroupId.HasValue)
|
||||
{
|
||||
<div class="skill-chip-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="chip-button"
|
||||
title="Edit skill group"
|
||||
disabled="@(IsMutating || !CanEditGroup)"
|
||||
@onclick="() => EditGroupRequested.InvokeAsync(SkillGroupId.Value)">
|
||||
<span aria-hidden="true" class="emoji">✏️</span>
|
||||
<span class="sr-only">Edit @Title</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="chip-button"
|
||||
title="Delete skill group"
|
||||
disabled="@(IsMutating || !CanEditGroup)"
|
||||
@onclick="() => DeleteGroupRequested.InvokeAsync(SkillGroupId.Value)">
|
||||
<span aria-hidden="true" class="emoji">🗑️</span>
|
||||
<span class="sr-only">Delete @Title</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (!HasSkillFilter && Skills.Count == 0)
|
||||
{
|
||||
<p class="empty">@EmptyMessage</p>
|
||||
}
|
||||
<div class="skill-list">
|
||||
@foreach (var skill in Skills)
|
||||
{
|
||||
<div class="skill-item">
|
||||
<div class="skill-details">
|
||||
<strong>@skill.Name</strong>
|
||||
<span>@SkillDefinitionLabel(skill)</span>
|
||||
</div>
|
||||
<div class="skill-chip-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="chip-button"
|
||||
title="Edit skill"
|
||||
disabled="@(IsMutating || !CanEditSkill(skill))"
|
||||
@onclick="() => EditSkillRequested.InvokeAsync(skill)">
|
||||
<span aria-hidden="true" class="emoji">✏️</span>
|
||||
<span class="sr-only">Edit @skill.Name</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="chip-button"
|
||||
title="Roll skill"
|
||||
disabled="@(IsMutating)"
|
||||
@onclick="() => RollSkillRequested.InvokeAsync(skill)">
|
||||
<span aria-hidden="true" class="emoji">🎲</span>
|
||||
<span class="sr-only">Roll @skill.Name</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="chip-button"
|
||||
title="Delete skill"
|
||||
disabled="@(IsMutating || !CanEditSkill(skill))"
|
||||
@onclick="() => DeleteSkillRequested.InvokeAsync(skill.Id)">
|
||||
<span aria-hidden="true" class="emoji">🗑️</span>
|
||||
<span class="sr-only">Delete @skill.Name</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
class="skill-item create-skill-item"
|
||||
disabled="@(IsMutating || !CanCreateSkill)"
|
||||
@onclick="() => AddSkillRequested.InvokeAsync(SkillGroupId)">
|
||||
<span class="skill-create-icon" aria-hidden="true">+</span>
|
||||
<span>Add skill</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,60 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using RpgRoller.Contracts;
|
||||
|
||||
namespace RpgRoller.Components.Pages.HomeControls;
|
||||
|
||||
[ExcludeFromCodeCoverage]
|
||||
public partial class SkillGroupBlock
|
||||
{
|
||||
[Parameter]
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
[Parameter]
|
||||
public Guid? SkillGroupId { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public IReadOnlyList<CharacterSheetSkill> Skills { get; set; } = [];
|
||||
|
||||
[Parameter]
|
||||
public bool IsMutating { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public bool CanEditGroup { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public bool CanCreateSkill { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public bool HasSkillFilter { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public string EmptyMessage { get; set; } = "No skills in this group yet.";
|
||||
|
||||
[Parameter]
|
||||
public bool ShowGroupActions { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public Func<CharacterSheetSkill, bool> CanEditSkill { get; set; } = _ => false;
|
||||
|
||||
[Parameter]
|
||||
public Func<CharacterSheetSkill, string> SkillDefinitionLabel { get; set; } = _ => string.Empty;
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<Guid?> AddSkillRequested { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<CharacterSheetSkill> EditSkillRequested { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<CharacterSheetSkill> RollSkillRequested { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<Guid> DeleteSkillRequested { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<Guid> EditGroupRequested { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<Guid> DeleteGroupRequested { get; set; }
|
||||
}
|
||||
55
RpgRoller/Components/Pages/HomeControls/StaticAuthPage.razor
Normal file
55
RpgRoller/Components/Pages/HomeControls/StaticAuthPage.razor
Normal 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; }
|
||||
}
|
||||
1
RpgRoller/Components/Pages/LoginPage.razor
Normal file
1
RpgRoller/Components/Pages/LoginPage.razor
Normal file
@@ -0,0 +1 @@
|
||||
@page "/login"
|
||||
12
RpgRoller/Components/Pages/PlayPage.razor
Normal file
12
RpgRoller/Components/Pages/PlayPage.razor
Normal 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>
|
||||
8
RpgRoller/Components/Pages/PlayPage.razor.cs
Normal file
8
RpgRoller/Components/Pages/PlayPage.razor.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace RpgRoller.Components.Pages;
|
||||
|
||||
[ExcludeFromCodeCoverage]
|
||||
public partial class PlayPage
|
||||
{
|
||||
}
|
||||
94
RpgRoller/Components/Pages/PlayWorkspaceContent.razor
Normal file
94
RpgRoller/Components/Pages/PlayWorkspaceContent.razor
Normal 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;
|
||||
}
|
||||
}
|
||||
48
RpgRoller/Components/Pages/Workspace.razor
Normal file
48
RpgRoller/Components/Pages/Workspace.razor
Normal file
@@ -0,0 +1,48 @@
|
||||
@using RpgRoller.Components.Pages.HomeControls
|
||||
<div class="@AppCssClass">
|
||||
<p class="sr-only" aria-live="polite">@State.LiveAnnouncement</p>
|
||||
|
||||
@if (State.HasHealthIssue)
|
||||
{
|
||||
<section class="health-banner" role="alert">
|
||||
<div>
|
||||
<strong>API currently unavailable.</strong>
|
||||
<p>@State.HealthIssueMessage</p>
|
||||
</div>
|
||||
<button type="button" @onclick="Session.RetryAfterHealthIssueAsync">Retry</button>
|
||||
</section>
|
||||
}
|
||||
|
||||
<div class="workspace-shell">
|
||||
<AppHeader
|
||||
User="State.User"
|
||||
ShowCampaign="@ShowCampaignInHeader"
|
||||
CampaignName="@State.SelectedCampaignName"
|
||||
ShowConnectionState="@ShowConnectionStateInHeader"
|
||||
ConnectionStateLabel="@State.ConnectionStateLabel"
|
||||
ConnectionStateCssClass="@State.ConnectionStateCssClass"
|
||||
IsMenuOpen="State.IsScreenMenuOpen"
|
||||
MenuButtonId="workspace-screen-menu-button"
|
||||
MenuId="workspace-screen-menu"
|
||||
MenuItems="HeaderMenuItems"
|
||||
ToggleMenuRequested="ToggleScreenMenu"
|
||||
LogoutRequested="Session.LogoutAsync"/>
|
||||
|
||||
@if (ChildContent is not null)
|
||||
{
|
||||
@ChildContent(PageContext)
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (State.Toasts.Count > 0)
|
||||
{
|
||||
<div class="toast-stack" aria-live="polite" aria-atomic="false">
|
||||
@foreach (var toast in State.Toasts)
|
||||
{
|
||||
<div class="toast @(toast.IsError ? "error" : "success")" role="status">
|
||||
<p>@toast.Message</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
223
RpgRoller/Components/Pages/Workspace.razor.cs
Normal file
223
RpgRoller/Components/Pages/Workspace.razor.cs
Normal file
@@ -0,0 +1,223 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.JSInterop;
|
||||
using RpgRoller.Components.Pages.HomeControls;
|
||||
using RpgRoller.Contracts;
|
||||
|
||||
namespace RpgRoller.Components.Pages;
|
||||
|
||||
[ExcludeFromCodeCoverage]
|
||||
public partial class Workspace : IAsyncDisposable
|
||||
{
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
State.IsScreenMenuOpen = false;
|
||||
}
|
||||
|
||||
[JSInvokable]
|
||||
public Task OnStateEventReceived(CampaignStateSnapshot state)
|
||||
{
|
||||
return Live.OnStateEventReceivedAsync(state);
|
||||
}
|
||||
|
||||
[JSInvokable]
|
||||
public Task OnConnectionStateChanged(string state)
|
||||
{
|
||||
return Live.OnConnectionStateChangedAsync(state);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await StopStateEventsAsync();
|
||||
DotNetRef?.Dispose();
|
||||
}
|
||||
|
||||
private bool CanEditCharacter(CharacterSummary character)
|
||||
{
|
||||
return Campaigns.CanEditCharacter(character);
|
||||
}
|
||||
|
||||
private void ClearAuthenticatedState()
|
||||
{
|
||||
Session.ClearAuthenticatedState();
|
||||
}
|
||||
|
||||
private Task EnsureAdminUsersLoadedAsync()
|
||||
{
|
||||
return Admin.EnsureAdminUsersLoadedAsync();
|
||||
}
|
||||
|
||||
private Task StopStateEventsAsync()
|
||||
{
|
||||
return Live.StopStateEventsAsync();
|
||||
}
|
||||
|
||||
private async Task StartStateEventsCoreAsync(Guid campaignId)
|
||||
{
|
||||
DotNetRef ??= DotNetObjectReference.Create(this);
|
||||
await JS.InvokeVoidAsync("rpgRollerApi.startStateEvents", campaignId.ToString(), DotNetRef);
|
||||
}
|
||||
|
||||
private async Task StopStateEventsCoreAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await JS.InvokeVoidAsync("rpgRollerApi.stopStateEvents");
|
||||
}
|
||||
catch (JSDisconnectedException)
|
||||
{
|
||||
}
|
||||
catch (InvalidOperationException ex) when (IsStaticRenderInteropException(ex))
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private void ToggleScreenMenu()
|
||||
{
|
||||
State.IsScreenMenuOpen = !State.IsScreenMenuOpen;
|
||||
}
|
||||
|
||||
private Task NavigateToRouteAsync(string route)
|
||||
{
|
||||
State.IsScreenMenuOpen = false;
|
||||
Navigation.NavigateTo(route, forceLoad: true);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private Task RedirectToPlayAsync()
|
||||
{
|
||||
if (IsPlayRoute)
|
||||
return Task.CompletedTask;
|
||||
|
||||
Navigation.NavigateTo("/play", forceLoad: true);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private Task RequestRefreshAsync()
|
||||
{
|
||||
return InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private Task InitializeRouteAsync()
|
||||
{
|
||||
return InitializationTask ??= InitializeRouteCoreAsync();
|
||||
}
|
||||
|
||||
private async Task InitializeRouteCoreAsync()
|
||||
{
|
||||
if (HasSessionInitialized)
|
||||
return;
|
||||
|
||||
State.HasInteractiveRenderStarted = true;
|
||||
await Session.InitializeAsync();
|
||||
HasSessionInitialized = true;
|
||||
await RequestRefreshAsync();
|
||||
}
|
||||
|
||||
private static bool IsStaticRenderInteropException(InvalidOperationException exception)
|
||||
{
|
||||
return exception.Message.Contains("statically rendered", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Inject] private IJSRuntime JS { get; set; } = null!;
|
||||
|
||||
[Inject] private RpgRollerApiClient ApiClient { get; set; } = null!;
|
||||
|
||||
[Inject] private WorkspaceQueryService WorkspaceQuery { get; set; } = null!;
|
||||
|
||||
[Inject] private NavigationManager Navigation { get; set; } = null!;
|
||||
|
||||
[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 WorkspacePageContext PageContext => new(State, Play, Campaigns, Admin, Scope, Session,
|
||||
InitializeRouteAsync, HasSessionInitialized, RequestRefreshAsync, AdminDatabaseDownloadUrl, HeaderMenuItems,
|
||||
IsPlayRoute, IsCampaignsRoute, IsAdminRoute);
|
||||
|
||||
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 WorkspaceLiveStateController Live => m_Live ??=
|
||||
new(State, Feedback, () => IsPlayRoute, () => IsAdminRoute, StartStateEventsCoreAsync,
|
||||
StopStateEventsCoreAsync, Scope.RefreshCampaignRosterAsync, Play.RefreshSelectedCharacterSheetAsync,
|
||||
Play.RefreshCampaignLogAsync, RequestRefreshAsync);
|
||||
|
||||
private WorkspacePlayCoordinator Play => m_Play ??= new(State, Feedback, () => IsPlayRoute, ApiClient,
|
||||
WorkspaceQuery,
|
||||
CanEditCharacter, RequestRefreshAsync);
|
||||
|
||||
private WorkspaceCampaignCoordinator Campaigns => m_Campaigns ??= new(State, Feedback, JS, ApiClient,
|
||||
Session.LoadKnownUsernamesAsync, Scope.ReloadCampaignsAsync, Scope.ReloadCharacterCampaignOptionsAsync,
|
||||
Scope.RefreshCampaignScopeAsync, Live.SyncStateEventsAsync, RequestRefreshAsync);
|
||||
|
||||
private WorkspaceAdminCoordinator Admin => m_Admin ??= new(State, Feedback, JS, ApiClient, WorkspaceQuery,
|
||||
ClearAuthenticatedState, StopStateEventsAsync, 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
|
||||
{
|
||||
get
|
||||
{
|
||||
var items = new List<AppHeaderMenuItem>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Label = "Play",
|
||||
IsActive = IsPlayRoute,
|
||||
OnSelected = () => NavigateToRouteAsync("/play")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Label = "Campaign Management",
|
||||
IsActive = IsCampaignsRoute,
|
||||
OnSelected = () => NavigateToRouteAsync("/campaigns")
|
||||
}
|
||||
};
|
||||
|
||||
if (State.IsCurrentUserAdmin)
|
||||
{
|
||||
items.Add(new()
|
||||
{
|
||||
Label = "Admin",
|
||||
IsActive = IsAdminRoute,
|
||||
OnSelected = () => NavigateToRouteAsync("/admin")
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
}
|
||||
|
||||
private string AdminDatabaseDownloadUrl => Navigation.ToAbsoluteUri("api/admin/database").ToString();
|
||||
private DotNetObjectReference<Workspace>? DotNetRef { get; set; }
|
||||
|
||||
private WorkspaceAdminCoordinator? m_Admin;
|
||||
private WorkspaceCampaignCoordinator? m_Campaigns;
|
||||
private WorkspaceFeedbackService? m_Feedback;
|
||||
private WorkspaceLiveStateController? m_Live;
|
||||
private WorkspacePlayCoordinator? m_Play;
|
||||
|
||||
private WorkspaceCampaignScopeCoordinator? m_Scope;
|
||||
private WorkspaceSessionCoordinator? m_Session;
|
||||
private Task? InitializationTask { get; set; }
|
||||
}
|
||||
98
RpgRoller/Components/Pages/WorkspaceAdminCoordinator.cs
Normal file
98
RpgRoller/Components/Pages/WorkspaceAdminCoordinator.cs
Normal file
@@ -0,0 +1,98 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.JSInterop;
|
||||
using RpgRoller.Contracts;
|
||||
using RpgRoller.Domain;
|
||||
|
||||
namespace RpgRoller.Components.Pages;
|
||||
|
||||
[ExcludeFromCodeCoverage]
|
||||
public sealed class WorkspaceAdminCoordinator(WorkspaceState state, WorkspaceFeedbackService feedback, IJSRuntime js, RpgRollerApiClient apiClient, WorkspaceQueryService workspaceQuery, Action clearAuthenticatedState, Func<Task> stopStateEventsAsync, Func<string?, Task> onLoggedOutAsync)
|
||||
{
|
||||
public async Task EnsureAdminUsersLoadedAsync()
|
||||
{
|
||||
if (!state.IsCurrentUserAdmin || state.HasLoadedAdminUsers || state.IsAdminDataLoading)
|
||||
return;
|
||||
|
||||
state.IsAdminDataLoading = true;
|
||||
try
|
||||
{
|
||||
await ReloadAdminUsersAsync();
|
||||
}
|
||||
catch (ApiRequestException ex) when (ex.StatusCode == 401)
|
||||
{
|
||||
clearAuthenticatedState();
|
||||
await stopStateEventsAsync();
|
||||
await onLoggedOutAsync("Session expired. Please log in again.");
|
||||
}
|
||||
catch (ApiRequestException ex)
|
||||
{
|
||||
feedback.SetStatus(ex.Message, true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
state.IsAdminDataLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ToggleAdminRoleAsync(AdminUserSummary user)
|
||||
{
|
||||
if (state.IsMutating || state.User is null || !state.IsCurrentUserAdmin || user.Id == state.User.Id)
|
||||
return;
|
||||
|
||||
state.IsMutating = true;
|
||||
try
|
||||
{
|
||||
IReadOnlyList<string> roles = HasAdminRole(user) ? Array.Empty<string>() : [UserRoles.Admin];
|
||||
_ = await apiClient.RequestAsync<AdminUserSummary>("PUT", $"/api/admin/users/{user.Id}/roles", new UpdateUserRolesRequest(roles));
|
||||
|
||||
await ReloadAdminUsersAsync();
|
||||
feedback.SetStatus("User roles updated.", false);
|
||||
}
|
||||
catch (ApiRequestException ex)
|
||||
{
|
||||
feedback.SetStatus(ex.Message, true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
state.IsMutating = false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DeleteUserAsync(AdminUserSummary user)
|
||||
{
|
||||
if (state.IsMutating || state.User is null || !state.IsCurrentUserAdmin || user.Id == state.User.Id)
|
||||
return;
|
||||
|
||||
var confirmed = await js.InvokeAsync<bool>("confirm", $"Delete user '{user.Username}'?");
|
||||
if (!confirmed)
|
||||
return;
|
||||
|
||||
state.IsMutating = true;
|
||||
try
|
||||
{
|
||||
_ = await apiClient.RequestAsync<bool>("DELETE", $"/api/admin/users/{user.Id}");
|
||||
await ReloadAdminUsersAsync();
|
||||
feedback.SetStatus("User deleted.", false);
|
||||
}
|
||||
catch (ApiRequestException ex)
|
||||
{
|
||||
feedback.SetStatus(ex.Message, true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
state.IsMutating = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReloadAdminUsersAsync()
|
||||
{
|
||||
state.AdminUsers = (await workspaceQuery.GetAdminUsersAsync()).OrderBy(user => user.Username, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
|
||||
state.HasLoadedAdminUsers = true;
|
||||
}
|
||||
|
||||
private static bool HasAdminRole(AdminUserSummary user)
|
||||
{
|
||||
return user.Roles.Contains(UserRoles.Admin, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
178
RpgRoller/Components/Pages/WorkspaceCampaignCoordinator.cs
Normal file
178
RpgRoller/Components/Pages/WorkspaceCampaignCoordinator.cs
Normal file
@@ -0,0 +1,178 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.JSInterop;
|
||||
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,
|
||||
Func<Task> requestRefreshAsync)
|
||||
{
|
||||
public async Task OnCampaignSelectionChangedAsync(ChangeEventArgs args)
|
||||
{
|
||||
if (!Guid.TryParse(args.Value?.ToString(), out var campaignId))
|
||||
return;
|
||||
|
||||
state.SelectedCampaignId = campaignId;
|
||||
await js.InvokeVoidAsync("rpgRollerApi.setSessionValue", CampaignSessionKey, campaignId.ToString());
|
||||
await refreshCampaignScopeAsync();
|
||||
await syncStateEventsAsync();
|
||||
state.IsScreenMenuOpen = false;
|
||||
}
|
||||
|
||||
public async Task OnCampaignCreatedAsync(Guid campaignId)
|
||||
{
|
||||
await reloadCampaignsAsync(campaignId);
|
||||
await reloadCharacterCampaignOptionsAsync();
|
||||
await refreshCampaignScopeAsync();
|
||||
await syncStateEventsAsync();
|
||||
feedback.SetStatus("Campaign created.", false);
|
||||
await requestRefreshAsync();
|
||||
}
|
||||
|
||||
public void OpenCreateCharacterModal()
|
||||
{
|
||||
state.CreateCharacterInitialModel = new()
|
||||
{
|
||||
Name = string.Empty,
|
||||
CampaignId = state.SelectedCampaignId?.ToString() ??
|
||||
state.CharacterCampaignOptions.FirstOrDefault()?.Id.ToString() ?? string.Empty,
|
||||
OwnerUsername = string.Empty
|
||||
};
|
||||
|
||||
state.CreateCharacterFormVersion += 1;
|
||||
state.CanEditCharacterOwner = false;
|
||||
state.ShowCreateCharacterModal = true;
|
||||
}
|
||||
|
||||
public async Task OpenEditCharacterModal(CharacterSummary character)
|
||||
{
|
||||
if (state.IsCurrentUserGm || state.IsCurrentUserAdmin)
|
||||
await loadKnownUsernamesAsync();
|
||||
|
||||
state.EditingCharacterId = character.Id;
|
||||
state.EditCharacterInitialModel = new()
|
||||
{
|
||||
Name = character.Name,
|
||||
CampaignId = character.CampaignId?.ToString() ?? string.Empty,
|
||||
OwnerUsername = string.Empty
|
||||
};
|
||||
|
||||
state.EditCharacterFormVersion += 1;
|
||||
state.CanEditCharacterOwner = state.IsCurrentUserGm || state.IsCurrentUserAdmin;
|
||||
state.ShowEditCharacterModal = true;
|
||||
}
|
||||
|
||||
public void CloseCharacterModals()
|
||||
{
|
||||
state.ShowCreateCharacterModal = false;
|
||||
state.ShowEditCharacterModal = false;
|
||||
state.CanEditCharacterOwner = false;
|
||||
state.EditingCharacterId = null;
|
||||
}
|
||||
|
||||
public async Task OnCharacterCreatedAsync(Guid? campaignId)
|
||||
{
|
||||
CloseCharacterModals();
|
||||
await reloadCampaignsAsync(campaignId);
|
||||
await reloadCharacterCampaignOptionsAsync();
|
||||
await refreshCampaignScopeAsync();
|
||||
await syncStateEventsAsync();
|
||||
feedback.SetStatus("Character created.", false);
|
||||
await requestRefreshAsync();
|
||||
}
|
||||
|
||||
public async Task OnCharacterUpdatedAsync(Guid? campaignId)
|
||||
{
|
||||
CloseCharacterModals();
|
||||
await reloadCampaignsAsync(campaignId);
|
||||
await reloadCharacterCampaignOptionsAsync();
|
||||
await refreshCampaignScopeAsync();
|
||||
await syncStateEventsAsync();
|
||||
feedback.SetStatus(campaignId.HasValue ? "Character updated." : "Character unlinked from campaign.", false);
|
||||
await requestRefreshAsync();
|
||||
}
|
||||
|
||||
public async Task DeleteSelectedCampaignAsync()
|
||||
{
|
||||
if (state.SelectedCampaign is null || state.IsMutating || !state.CanDeleteSelectedCampaign)
|
||||
return;
|
||||
|
||||
var confirmed = await js.InvokeAsync<bool>("confirm", $"Delete campaign '{state.SelectedCampaign.Name}'?");
|
||||
if (!confirmed)
|
||||
return;
|
||||
|
||||
state.IsMutating = true;
|
||||
try
|
||||
{
|
||||
_ = await apiClient.RequestAsync<bool>("DELETE", $"/api/campaigns/{state.SelectedCampaign.Id}");
|
||||
await reloadCampaignsAsync(null);
|
||||
await reloadCharacterCampaignOptionsAsync();
|
||||
await refreshCampaignScopeAsync();
|
||||
await syncStateEventsAsync();
|
||||
feedback.SetStatus("Campaign deleted.", false);
|
||||
}
|
||||
catch (ApiRequestException ex)
|
||||
{
|
||||
feedback.SetStatus(ex.Message, true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
state.IsMutating = false;
|
||||
await requestRefreshAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DeleteCharacterAsync(CharacterSummary character)
|
||||
{
|
||||
if (state.IsMutating || !CanDeleteCharacter(character))
|
||||
return;
|
||||
|
||||
var confirmed = await js.InvokeAsync<bool>("confirm", $"Delete character '{character.Name}'?");
|
||||
if (!confirmed)
|
||||
return;
|
||||
|
||||
state.IsMutating = true;
|
||||
try
|
||||
{
|
||||
_ = await apiClient.RequestAsync<bool>("DELETE", $"/api/characters/{character.Id}");
|
||||
await reloadCampaignsAsync(state.SelectedCampaignId);
|
||||
await reloadCharacterCampaignOptionsAsync();
|
||||
await refreshCampaignScopeAsync();
|
||||
await syncStateEventsAsync();
|
||||
feedback.SetStatus("Character deleted.", false);
|
||||
}
|
||||
catch (ApiRequestException ex)
|
||||
{
|
||||
feedback.SetStatus(ex.Message, true);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
public bool CanDeleteCharacter(CharacterSummary character)
|
||||
{
|
||||
return state.User is not null && (character.OwnerUserId == state.User.Id || state.IsCurrentUserAdmin);
|
||||
}
|
||||
|
||||
private const string CampaignSessionKey = "campaign";
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user