Compare commits
52 Commits
feature/bl
...
feature/pa
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -6,7 +6,10 @@ artifacts/
|
|||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.vs/
|
.vs/
|
||||||
.vscode/
|
.idea/
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/tasks.json
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|
||||||
# User secrets / configs
|
# User secrets / configs
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,16 +1,20 @@
|
|||||||
# Agent Guide
|
# Agent Guide
|
||||||
|
|
||||||
Also see the other related technical documentation: TECH.md, REQUIREMENTS.md and possibly other markdown files.
|
Also see the other related technical documentation: README.md.
|
||||||
|
|
||||||
## Rules
|
## 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.
|
- This is a Windows environment, WSL is not installed (i.e. sed is not available). You're running under PowerShell 7.6.0. Due to platform restrictions, file deletions are not possible. Replacing the entire file content via a context diff is a viable alternative.
|
||||||
- PowerShell doesn't support bash-style heredocs. If complex scripts need to be executed, consider using python. Run Python code using python -c with inline commands instead of python - <<'PY'.
|
- 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.
|
- web.config in the server is different than locally, it must be exluded from deployment.
|
||||||
|
- Before beginning with the edit phase, always present a plan first. Only begin editing after the user approves the plan.
|
||||||
- After every iteration, evaluate if the test coverage would fall below 100%, and write tests if necessary.
|
- 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, 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, 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 frontend change, verify the results using playwright.
|
||||||
- After every iteration, do a git commit with a brief summary of the changes as a commit message.
|
- 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.
|
- 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.
|
- If you find unexpected changes in the code (deletions, changes, diff results that were not communicated), never revert them and never restore the old state. Assume that those changes happened with intent.
|
||||||
|
- Never use `git restore`, `git checkout --`, reset commands, or equivalent rollback actions to discard local changes unless the user explicitly asks for that exact rollback.
|
||||||
|
- If a required tool is missing (for example `dotnet-ef`), install/configure the tool (prefer repo-local setup such as `dotnet tool manifest`) instead of weakening validations or muting warnings. If installation is blocked, stop and ask before changing validation strictness.
|
||||||
- After changing the database, if your build is blocked by a running dotnet process, feel free to kill the process and retry the operation once.
|
- 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.
|
||||||
|
|||||||
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.
|
|
||||||
78
README.md
78
README.md
@@ -5,8 +5,6 @@ Fresh full-stack starter scaffold:
|
|||||||
- `RpgRoller/`: ASP.NET Core backend + Blazor frontend host (`Components` + `wwwroot`)
|
- `RpgRoller/`: ASP.NET Core backend + Blazor frontend host (`Components` + `wwwroot`)
|
||||||
- `RpgRoller.Tests/`: xUnit integration-heavy test project
|
- `RpgRoller.Tests/`: xUnit integration-heavy test project
|
||||||
- `RpgRoller.sln`: solution used by local CI script
|
- `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`)
|
|
||||||
|
|
||||||
Test layout:
|
Test layout:
|
||||||
|
|
||||||
@@ -26,7 +24,15 @@ Backend:
|
|||||||
Frontend:
|
Frontend:
|
||||||
|
|
||||||
- `RpgRoller/Components/`: Blazor root app, routes, layout and page components
|
- `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/Components/Pages/Home.razor`: minimal gateway page (loading/auth/workspace switch)
|
||||||
|
- `RpgRoller/Components/Pages/Home.razor.cs`: single `Home` code-behind with only gateway/session-view orchestration
|
||||||
|
- `RpgRoller/Components/Pages/Workspace.razor`: authenticated workspace UI and workspace-specific state/logic
|
||||||
|
- `RpgRoller/Components/**/*.razor.cs`: component code-behind classes (state, handlers, parameters, injected dependencies); `.razor` files remain markup-focused
|
||||||
|
- `RpgRoller/Components/Pages/Home.Models.cs`: reusable `FormState<TModel>` + page form models
|
||||||
|
- `RpgRoller/Components/Pages/HomeControls/`: auth, campaign management, play-screen, and modal controls extracted from `Home.razor`
|
||||||
|
- Form ownership model: controls own transient form/error state and execute their concern-specific API mutations directly
|
||||||
|
- Skill create/edit workflow ownership: `CharacterPanel` (characters own skills in UI and behavior)
|
||||||
|
- `RpgRoller/Components/RpgRollerApiClient.cs`: shared browser API client used by `Home`, `Workspace`, and leaf controls
|
||||||
- `RpgRoller/wwwroot/js/rpgroller-api.js`: browser-side API + SSE + session storage interop for Blazor
|
- `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/wwwroot/styles.css`: responsive UX styling and theme tokens
|
||||||
|
|
||||||
@@ -35,13 +41,32 @@ Backend state persistence:
|
|||||||
- EF Core with SQLite (`Microsoft.EntityFrameworkCore.Sqlite`)
|
- EF Core with SQLite (`Microsoft.EntityFrameworkCore.Sqlite`)
|
||||||
- Development DB: `RpgRoller/App_Data/rpgroller.development.db`
|
- Development DB: `RpgRoller/App_Data/rpgroller.development.db`
|
||||||
- Default DB: `RpgRoller/App_Data/rpgroller.db`
|
- Default DB: `RpgRoller/App_Data/rpgroller.db`
|
||||||
- Database schema is created automatically on startup (`EnsureCreated`)
|
- Database schema is created/upgraded automatically on startup via EF Core migrations (`Database.Migrate`)
|
||||||
- Runtime state is loaded once at startup into memory and written back to SQLite on successful state changes
|
- Runtime state is loaded once at startup into memory and written back to SQLite on successful state changes
|
||||||
|
|
||||||
|
Gameplay capabilities now include:
|
||||||
|
|
||||||
|
- Instant skill filtering in the character panel (filters live while typing and hides non-matching skills/groups)
|
||||||
|
- Skill groups per character with skill prototypes (create/edit/delete groups, assign/reassign skills, and prefill new skill forms from group defaults)
|
||||||
|
- Skill and skill-group deletion flows
|
||||||
|
- GM-driven character owner transfer within campaign management flows
|
||||||
|
- Character owner selection in edit modal backed by existing-username dropdown data
|
||||||
|
- Role-aware authorization with admin role support (including admin user/role management)
|
||||||
|
- Admin workspace tools include direct download of the live SQLite database file
|
||||||
|
- Campaign deletion by campaign owner or admin (unlinks characters from the campaign and clears campaign log entries)
|
||||||
|
- User deletion by admin also deletes campaigns owned by that user and unlinks all characters from those deleted campaigns
|
||||||
|
- Play screen visibility is owner-scoped: only owned characters are listed, and private log entries are visible only to the roller
|
||||||
|
- Campaign management owner labels use account display names (no GUID fallback rendering)
|
||||||
|
- Character edit flow supports unlinking from campaigns (owner/GM/admin) and assigning to any existing campaign via expanded campaign options
|
||||||
|
- Campaign management supports character deletion by character owner or admin
|
||||||
|
- Shared top header control across all authenticated workspace screens (play, campaign management, admin)
|
||||||
|
- Admin user management is integrated into workspace screen toggles (`Play`, `Campaign Management`, `Admin`)
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- .NET SDK 10.0+
|
- .NET SDK 10.0+
|
||||||
- PowerShell 7+
|
- PowerShell 7+
|
||||||
|
- Run `dotnet tool restore` once to enable the repo-local `dotnet-ef` command.
|
||||||
|
|
||||||
## Local Development
|
## Local Development
|
||||||
|
|
||||||
@@ -55,12 +80,30 @@ Backend state persistence:
|
|||||||
```
|
```
|
||||||
3. Open `http://localhost:5000` (or the port shown in the console).
|
3. Open `http://localhost:5000` (or the port shown in the console).
|
||||||
|
|
||||||
|
VS Code F5 debug profiles are available in `.vscode/launch.json`:
|
||||||
|
|
||||||
|
- `RpgRoller: Server`
|
||||||
|
- `RpgRoller: Server + Edge (F5)`
|
||||||
|
- `RpgRoller: Server + Firefox (F5)`
|
||||||
|
|
||||||
To use a custom SQLite database path, set `ConnectionStrings__RpgRoller`.
|
To use a custom SQLite database path, set `ConnectionStrings__RpgRoller`.
|
||||||
|
To run under a subfolder (for example `/rpgroller`), set `PathBase` (for example `PathBase=/rpgroller`).
|
||||||
|
|
||||||
|
For migration authoring, use the local tool command form:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet dotnet-ef migrations add <MigrationName> --project RpgRoller/RpgRoller.csproj --startup-project RpgRoller/RpgRoller.csproj
|
||||||
|
```
|
||||||
|
|
||||||
## Frontend Runtime
|
## Frontend Runtime
|
||||||
|
|
||||||
- Runtime frontend is Blazor Server with interactive components.
|
- Runtime frontend is Blazor Server with interactive components.
|
||||||
- Browser interop is in `RpgRoller/wwwroot/js/rpgroller-api.js`.
|
- Browser interop is in `RpgRoller/wwwroot/js/rpgroller-api.js`.
|
||||||
|
- Workspace reads are resolved server-side through scoped query services; browser interop remains for browser-only concerns such as session storage, SSE wiring, and DOM helpers.
|
||||||
|
- Live workspace refreshes now compare separate roster, per-character sheet, and log versions so unrelated state changes do not force a full roster + sheet + log reload.
|
||||||
|
- Workspace campaign data is loaded in bounded slices: visible campaign summaries, a selected campaign roster, a selected character sheet, and a 25-row incremental log window backed by `/api/campaigns/{campaignId}/log/page`.
|
||||||
|
- Campaign log rows now ship compact summary data first and lazy-load dice + breakdown detail through `/api/rolls/{rollId}` only when a row is expanded.
|
||||||
|
- Hot API contracts share a source-generated `System.Text.Json` context, and HTTP JSON responses are gzip-compressed when the client advertises support.
|
||||||
- OpenAPI contract source remains at `openapi/RpgRoller.json`.
|
- OpenAPI contract source remains at `openapi/RpgRoller.json`.
|
||||||
|
|
||||||
## Test and Coverage
|
## Test and Coverage
|
||||||
@@ -69,35 +112,10 @@ To use a custom SQLite database path, set `ConnectionStrings__RpgRoller`.
|
|||||||
```powershell
|
```powershell
|
||||||
dotnet test RpgRoller.Tests/RpgRoller.Tests.csproj --collect:"XPlat Code Coverage" --settings RpgRoller.Tests/coverlet.runsettings
|
dotnet test RpgRoller.Tests/RpgRoller.Tests.csproj --collect:"XPlat Code Coverage" --settings RpgRoller.Tests/coverlet.runsettings
|
||||||
```
|
```
|
||||||
|
- Regression tests enforce payload budgets for the hottest contracts: character sheet reads, initial log page loads, incremental log updates, and roll mutation responses.
|
||||||
- Coverage gate:
|
- Coverage gate:
|
||||||
```powershell
|
```powershell
|
||||||
pwsh ./scripts/check-coverage.ps1 -MinLineRate 0.90 -MinBranchRate 0.70
|
pwsh ./scripts/check-coverage.ps1 -MinLineRate 0.90 -MinBranchRate 0.70
|
||||||
```
|
```
|
||||||
- Coverage collector scope:
|
- Coverage collector scope:
|
||||||
- `RpgRoller.Tests/coverlet.runsettings` now measures the full backend assembly (`RpgRoller`), not only service namespace files.
|
- `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
|
|
||||||
|
|||||||
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,11 +1,8 @@
|
|||||||
using Microsoft.AspNetCore.Mvc.Testing;
|
|
||||||
|
|
||||||
namespace RpgRoller.Tests;
|
namespace RpgRoller.Tests;
|
||||||
|
|
||||||
public sealed class AuthApiTests : ApiTestBase
|
public sealed class AuthApiTests : ApiTestBase
|
||||||
{
|
{
|
||||||
public AuthApiTests(WebApplicationFactory<Program> factory)
|
public AuthApiTests(WebApplicationFactory<Program> factory) : base(factory)
|
||||||
: base(factory)
|
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -13,10 +10,11 @@ public sealed class AuthApiTests : ApiTestBase
|
|||||||
public async Task RegisterLoginAndMeFlow_WorksWithDuplicateUsernameGuard()
|
public async Task RegisterLoginAndMeFlow_WorksWithDuplicateUsernameGuard()
|
||||||
{
|
{
|
||||||
using var factory = CreateFactory(4, 4, 4);
|
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");
|
var registerResult = await RegisterAsync(client, "alice", "Password123", "Alice");
|
||||||
Assert.Equal("alice", registerResult.Username);
|
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);
|
Assert.Equal(HttpStatusCode.BadRequest, duplicate.StatusCode);
|
||||||
@@ -32,4 +30,22 @@ public sealed class AuthApiTests : ApiTestBase
|
|||||||
var invalidLogin = await client.PostAsJsonAsync("/api/auth/login", new LoginRequest("alice", "wrong-password"));
|
var invalidLogin = await client.PostAsJsonAsync("/api/auth/login", new LoginRequest("alice", "wrong-password"));
|
||||||
Assert.Equal(HttpStatusCode.BadRequest, invalidLogin.StatusCode);
|
Assert.Equal(HttpStatusCode.BadRequest, invalidLogin.StatusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
using Microsoft.AspNetCore.Mvc.Testing;
|
using System.Text;
|
||||||
|
|
||||||
namespace RpgRoller.Tests;
|
namespace RpgRoller.Tests;
|
||||||
|
|
||||||
public sealed class CampaignApiTests : ApiTestBase
|
public sealed class CampaignApiTests : ApiTestBase
|
||||||
{
|
{
|
||||||
public CampaignApiTests(WebApplicationFactory<Program> factory)
|
public CampaignApiTests(WebApplicationFactory<Program> factory) : base(factory)
|
||||||
: base(factory)
|
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -13,66 +12,353 @@ public sealed class CampaignApiTests : ApiTestBase
|
|||||||
public async Task CampaignCharacterAndSkillFlow_EnforcesRulesetValidation()
|
public async Task CampaignCharacterAndSkillFlow_EnforcesRulesetValidation()
|
||||||
{
|
{
|
||||||
using var factory = CreateFactory(6, 6, 6);
|
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 RegisterAsync(gmClient, "gm", "Password123", "Game Master");
|
||||||
await LoginAsync(gmClient, "gm", "Password123");
|
await LoginAsync(gmClient, "gm", "Password123");
|
||||||
|
|
||||||
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(
|
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns", new("Alpha Campaign", "dnd5e"));
|
||||||
gmClient,
|
|
||||||
"/api/campaigns",
|
|
||||||
new CreateCampaignRequest("Alpha Campaign", "dnd5e"));
|
|
||||||
|
|
||||||
var gmCharacter = await PostAsync<CreateCharacterRequest, CharacterSummary>(
|
var gmCharacter = await PostAsync<CreateCharacterRequest, CharacterSummary>(gmClient, "/api/characters", new("Arin", campaign.Id));
|
||||||
gmClient,
|
Assert.Equal("Game Master", gmCharacter.OwnerDisplayName);
|
||||||
"/api/characters",
|
|
||||||
new CreateCharacterRequest("Arin", campaign.Id));
|
|
||||||
|
|
||||||
var activateResponse = await gmClient.PostAsync($"/api/characters/{gmCharacter.Id}/activate", null);
|
var activateResponse = await gmClient.PostAsync($"/api/characters/{gmCharacter.Id}/activate", null);
|
||||||
Assert.Equal(HttpStatusCode.OK, activateResponse.StatusCode);
|
Assert.Equal(HttpStatusCode.OK, activateResponse.StatusCode);
|
||||||
|
|
||||||
var createdSkill = await PostAsync<CreateSkillRequest, SkillSummary>(
|
var createdSkill = await PostAsync<CreateSkillRequest, SkillSummary>(gmClient, $"/api/characters/{gmCharacter.Id}/skills", new("Arcana", "2d12+2", 0, false));
|
||||||
gmClient,
|
|
||||||
$"/api/characters/{gmCharacter.Id}/skills",
|
|
||||||
new CreateSkillRequest("Arcana", "2d12+2", 0, false));
|
|
||||||
Assert.Equal("2d12+2", createdSkill.DiceRollDefinition);
|
Assert.Equal("2d12+2", createdSkill.DiceRollDefinition);
|
||||||
Assert.Equal(0, createdSkill.WildDice);
|
Assert.Equal(0, createdSkill.WildDice);
|
||||||
Assert.False(createdSkill.AllowFumble);
|
Assert.False(createdSkill.AllowFumble);
|
||||||
|
|
||||||
var updatedSkill = await PutAsync<UpdateSkillRequest, SkillSummary>(
|
var updatedSkill = await PutAsync<UpdateSkillRequest, SkillSummary>(gmClient, $"/api/skills/{createdSkill.Id}", new("Arcana Mastery", "2d12+3", 0, false));
|
||||||
gmClient,
|
|
||||||
$"/api/skills/{createdSkill.Id}",
|
|
||||||
new UpdateSkillRequest("Arcana Mastery", "2d12+3", 0, false));
|
|
||||||
Assert.Equal("Arcana Mastery", updatedSkill.Name);
|
Assert.Equal("Arcana Mastery", updatedSkill.Name);
|
||||||
Assert.Equal("2d12+3", updatedSkill.DiceRollDefinition);
|
Assert.Equal("2d12+3", updatedSkill.DiceRollDefinition);
|
||||||
Assert.Equal(0, updatedSkill.WildDice);
|
Assert.Equal(0, updatedSkill.WildDice);
|
||||||
Assert.False(updatedSkill.AllowFumble);
|
Assert.False(updatedSkill.AllowFumble);
|
||||||
|
|
||||||
var invalidSkill = await gmClient.PostAsJsonAsync(
|
var invalidSkill = await gmClient.PostAsJsonAsync($"/api/characters/{gmCharacter.Id}/skills", new CreateSkillRequest("Broken", "5D+4", 0, false));
|
||||||
$"/api/characters/{gmCharacter.Id}/skills",
|
|
||||||
new CreateSkillRequest("Broken", "5D+4", 0, false));
|
|
||||||
Assert.Equal(HttpStatusCode.BadRequest, invalidSkill.StatusCode);
|
Assert.Equal(HttpStatusCode.BadRequest, invalidSkill.StatusCode);
|
||||||
|
|
||||||
var details = await GetAsync<CampaignDetails>(gmClient, $"/api/campaigns/{campaign.Id}");
|
var details = await GetAsync<CampaignRoster>(gmClient, $"/api/campaigns/{campaign.Id}");
|
||||||
Assert.Equal(campaign.Id, details.Id);
|
Assert.Equal(campaign.Id, details.Id);
|
||||||
Assert.Single(details.Characters);
|
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.Single(currentCampaignCharacters);
|
||||||
Assert.Equal(gmCharacter.Id, currentCampaignCharacters[0].Id);
|
Assert.Equal(gmCharacter.Id, currentCampaignCharacters[0].Id);
|
||||||
|
Assert.Equal("Game Master", currentCampaignCharacters[0].OwnerDisplayName);
|
||||||
|
|
||||||
var otherCampaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(
|
var otherCampaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns", new("Beta Campaign", "d6"));
|
||||||
gmClient,
|
|
||||||
"/api/campaigns",
|
|
||||||
new CreateCampaignRequest("Beta Campaign", "d6"));
|
|
||||||
|
|
||||||
var updatedCharacter = await PutAsync<UpdateCharacterRequest, CharacterSummary>(
|
var updatedCharacter = await PutAsync<UpdateCharacterRequest, CharacterSummary>(gmClient, $"/api/characters/{gmCharacter.Id}", new("Arin Updated", otherCampaign.Id));
|
||||||
gmClient,
|
|
||||||
$"/api/characters/{gmCharacter.Id}",
|
|
||||||
new UpdateCharacterRequest("Arin Updated", otherCampaign.Id));
|
|
||||||
|
|
||||||
Assert.Equal("Arin Updated", updatedCharacter.Name);
|
Assert.Equal("Arin Updated", updatedCharacter.Name);
|
||||||
Assert.Equal(otherCampaign.Id, updatedCharacter.CampaignId);
|
Assert.Equal(otherCampaign.Id, updatedCharacter.CampaignId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task 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, null));
|
||||||
|
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,11 +1,8 @@
|
|||||||
using Microsoft.AspNetCore.Mvc.Testing;
|
|
||||||
|
|
||||||
namespace RpgRoller.Tests;
|
namespace RpgRoller.Tests;
|
||||||
|
|
||||||
public sealed class FrontendHostTests : ApiTestBase
|
public sealed class FrontendHostTests : ApiTestBase
|
||||||
{
|
{
|
||||||
public FrontendHostTests(WebApplicationFactory<Program> factory)
|
public FrontendHostTests(WebApplicationFactory<Program> factory) : base(factory)
|
||||||
: base(factory)
|
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -13,7 +10,7 @@ public sealed class FrontendHostTests : ApiTestBase
|
|||||||
public async Task RootPath_ServesBlazorFrontendShell()
|
public async Task RootPath_ServesBlazorFrontendShell()
|
||||||
{
|
{
|
||||||
using var factory = CreateFactory(1);
|
using var factory = CreateFactory(1);
|
||||||
using var client = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
|
using var client = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||||
var response = await client.GetAsync("/");
|
var response = await client.GetAsync("/");
|
||||||
|
|
||||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
|||||||
27
RpgRoller.Tests/Api/ResponseCompressionApiTests.cs
Normal file
27
RpgRoller.Tests/Api/ResponseCompressionApiTests.cs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
namespace RpgRoller.Tests;
|
||||||
|
|
||||||
|
public sealed class ResponseCompressionApiTests : ApiTestBase
|
||||||
|
{
|
||||||
|
public ResponseCompressionApiTests(WebApplicationFactory<Program> factory) : base(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,8 @@
|
|||||||
using Microsoft.AspNetCore.Mvc.Testing;
|
|
||||||
|
|
||||||
namespace RpgRoller.Tests;
|
namespace RpgRoller.Tests;
|
||||||
|
|
||||||
public sealed class RollVisibilityApiTests : ApiTestBase
|
public sealed class RollVisibilityApiTests : ApiTestBase
|
||||||
{
|
{
|
||||||
public RollVisibilityApiTests(WebApplicationFactory<Program> factory)
|
public RollVisibilityApiTests(WebApplicationFactory<Program> factory) : base(factory)
|
||||||
: base(factory)
|
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -13,60 +10,58 @@ public sealed class RollVisibilityApiTests : ApiTestBase
|
|||||||
public async Task RollVisibilityAndAuthorization_AreEnforced()
|
public async Task RollVisibilityAndAuthorization_AreEnforced()
|
||||||
{
|
{
|
||||||
using var factory = CreateFactory(4, 3, 5, 2, 6);
|
using var factory = CreateFactory(4, 3, 5, 2, 6);
|
||||||
using var gmClient = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
|
using var gmClient = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||||
using var playerClient = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
|
using var playerClient = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||||
using var observerClient = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
|
using var observerClient = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||||
using var outsiderClient = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
|
using var outsiderClient = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||||
|
|
||||||
await RegisterAsync(gmClient, "gm", "Password123", "GM");
|
await RegisterAsync(gmClient, "gm", "Password123", "GM");
|
||||||
await LoginAsync(gmClient, "gm", "Password123");
|
await LoginAsync(gmClient, "gm", "Password123");
|
||||||
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(
|
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns", new("Main", "d6"));
|
||||||
gmClient,
|
|
||||||
"/api/campaigns",
|
|
||||||
new CreateCampaignRequest("Main", "d6"));
|
|
||||||
|
|
||||||
await RegisterAsync(playerClient, "player", "Password123", "Player");
|
await RegisterAsync(playerClient, "player", "Password123", "Player");
|
||||||
await LoginAsync(playerClient, "player", "Password123");
|
await LoginAsync(playerClient, "player", "Password123");
|
||||||
var playerCharacter = await PostAsync<CreateCharacterRequest, CharacterSummary>(
|
var playerCharacter = await PostAsync<CreateCharacterRequest, CharacterSummary>(playerClient, "/api/characters", new("Rogue", campaign.Id));
|
||||||
playerClient,
|
|
||||||
"/api/characters",
|
|
||||||
new CreateCharacterRequest("Rogue", campaign.Id));
|
|
||||||
|
|
||||||
var skill = await PostAsync<CreateSkillRequest, SkillSummary>(
|
var skill = await PostAsync<CreateSkillRequest, SkillSummary>(playerClient, $"/api/characters/{playerCharacter.Id}/skills", new("Stealth", "2D+1", 1, true));
|
||||||
playerClient,
|
|
||||||
$"/api/characters/{playerCharacter.Id}/skills",
|
|
||||||
new CreateSkillRequest("Stealth", "2D+1", 1, true));
|
|
||||||
Assert.Equal(1, skill.WildDice);
|
Assert.Equal(1, skill.WildDice);
|
||||||
Assert.True(skill.AllowFumble);
|
Assert.True(skill.AllowFumble);
|
||||||
|
|
||||||
await RegisterAsync(observerClient, "observer", "Password123", "Observer");
|
await RegisterAsync(observerClient, "observer", "Password123", "Observer");
|
||||||
await LoginAsync(observerClient, "observer", "Password123");
|
await LoginAsync(observerClient, "observer", "Password123");
|
||||||
await PostAsync<CreateCharacterRequest, CharacterSummary>(
|
await PostAsync<CreateCharacterRequest, CharacterSummary>(observerClient, "/api/characters", new("Watcher", campaign.Id));
|
||||||
observerClient,
|
|
||||||
"/api/characters",
|
|
||||||
new CreateCharacterRequest("Watcher", campaign.Id));
|
|
||||||
|
|
||||||
var privateRoll = await PostAsync<RollSkillRequest, RollResult>(
|
var privateRoll = await PostAsync<RollSkillRequest, RollResult>(playerClient, $"/api/skills/{skill.Id}/roll", new("private"));
|
||||||
playerClient,
|
var publicRoll = await PostAsync<RollSkillRequest, RollResult>(playerClient, $"/api/skills/{skill.Id}/roll", new("public"));
|
||||||
$"/api/skills/{skill.Id}/roll",
|
|
||||||
new RollSkillRequest("private"));
|
|
||||||
var publicRoll = await PostAsync<RollSkillRequest, RollResult>(
|
|
||||||
playerClient,
|
|
||||||
$"/api/skills/{skill.Id}/roll",
|
|
||||||
new RollSkillRequest("public"));
|
|
||||||
|
|
||||||
Assert.Equal("private", privateRoll.Visibility);
|
Assert.Equal("private", privateRoll.Visibility);
|
||||||
Assert.Equal("public", publicRoll.Visibility);
|
Assert.Equal("public", publicRoll.Visibility);
|
||||||
|
|
||||||
var gmLog = await GetAsync<IReadOnlyList<CampaignLogEntry>>(gmClient, $"/api/campaigns/{campaign.Id}/log");
|
var gmLog = await GetAsync<IReadOnlyList<CampaignLogEntry>>(gmClient, $"/api/campaigns/{campaign.Id}/log");
|
||||||
Assert.Equal(2, gmLog.Count);
|
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");
|
var playerLog = await GetAsync<IReadOnlyList<CampaignLogEntry>>(playerClient, $"/api/campaigns/{campaign.Id}/log");
|
||||||
Assert.Equal(2, playerLog.Count);
|
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");
|
var observerLog = await GetAsync<IReadOnlyList<CampaignLogEntry>>(observerClient, $"/api/campaigns/{campaign.Id}/log");
|
||||||
Assert.Single(observerLog);
|
Assert.Single(observerLog);
|
||||||
Assert.Equal("public", observerLog[0].Visibility);
|
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 RegisterAsync(outsiderClient, "outsider", "Password123", "Outsider");
|
||||||
await LoginAsync(outsiderClient, "outsider", "Password123");
|
await LoginAsync(outsiderClient, "outsider", "Password123");
|
||||||
@@ -74,15 +69,14 @@ public sealed class RollVisibilityApiTests : ApiTestBase
|
|||||||
var forbiddenCampaign = await outsiderClient.GetAsync($"/api/campaigns/{campaign.Id}");
|
var forbiddenCampaign = await outsiderClient.GetAsync($"/api/campaigns/{campaign.Id}");
|
||||||
Assert.Equal(HttpStatusCode.BadRequest, forbiddenCampaign.StatusCode);
|
Assert.Equal(HttpStatusCode.BadRequest, forbiddenCampaign.StatusCode);
|
||||||
|
|
||||||
var invalidVisibility = await playerClient.PostAsJsonAsync(
|
var outsiderPublicDetail = await outsiderClient.GetAsync($"/api/rolls/{publicRoll.RollId}");
|
||||||
$"/api/skills/{skill.Id}/roll",
|
Assert.Equal(HttpStatusCode.BadRequest, outsiderPublicDetail.StatusCode);
|
||||||
new RollSkillRequest("hidden"));
|
|
||||||
|
var invalidVisibility = await playerClient.PostAsJsonAsync($"/api/skills/{skill.Id}/roll", new RollSkillRequest("hidden"));
|
||||||
Assert.Equal(HttpStatusCode.BadRequest, invalidVisibility.StatusCode);
|
Assert.Equal(HttpStatusCode.BadRequest, invalidVisibility.StatusCode);
|
||||||
|
|
||||||
using var anonymousClient = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
|
using var anonymousClient = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||||
var unauthorizedCampaignCreate = await anonymousClient.PostAsJsonAsync(
|
var unauthorizedCampaignCreate = await anonymousClient.PostAsJsonAsync("/api/campaigns", new CreateCampaignRequest("Nope", "d6"));
|
||||||
"/api/campaigns",
|
|
||||||
new CreateCampaignRequest("Nope", "d6"));
|
|
||||||
Assert.Equal(HttpStatusCode.Unauthorized, unauthorizedCampaignCreate.StatusCode);
|
Assert.Equal(HttpStatusCode.Unauthorized, unauthorizedCampaignCreate.StatusCode);
|
||||||
|
|
||||||
var invalidSessionRequest = new HttpRequestMessage(HttpMethod.Get, "/api/campaigns");
|
var invalidSessionRequest = new HttpRequestMessage(HttpMethod.Get, "/api/campaigns");
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
using Microsoft.AspNetCore.Mvc.Testing;
|
|
||||||
|
|
||||||
namespace RpgRoller.Tests;
|
namespace RpgRoller.Tests;
|
||||||
|
|
||||||
public sealed class SystemApiTests : ApiTestBase
|
public sealed class SystemApiTests : ApiTestBase
|
||||||
{
|
{
|
||||||
public SystemApiTests(WebApplicationFactory<Program> factory)
|
public SystemApiTests(WebApplicationFactory<Program> factory) : base(factory)
|
||||||
: base(factory)
|
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -13,7 +10,7 @@ public sealed class SystemApiTests : ApiTestBase
|
|||||||
public async Task RulesetAndSseEndpoints_ReturnExpectedResponses()
|
public async Task RulesetAndSseEndpoints_ReturnExpectedResponses()
|
||||||
{
|
{
|
||||||
using var factory = CreateFactory(2, 2, 2);
|
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");
|
var rulesets = await GetAsync<IReadOnlyList<RulesetDefinition>>(client, "/api/rulesets");
|
||||||
Assert.Equal(2, rulesets.Count);
|
Assert.Equal(2, rulesets.Count);
|
||||||
@@ -21,10 +18,7 @@ public sealed class SystemApiTests : ApiTestBase
|
|||||||
await RegisterAsync(client, "sse", "Password123", "Sse User");
|
await RegisterAsync(client, "sse", "Password123", "Sse User");
|
||||||
await LoginAsync(client, "sse", "Password123");
|
await LoginAsync(client, "sse", "Password123");
|
||||||
|
|
||||||
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(
|
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(client, "/api/campaigns", new("SSE", "d6"));
|
||||||
client,
|
|
||||||
"/api/campaigns",
|
|
||||||
new CreateCampaignRequest("SSE", "d6"));
|
|
||||||
|
|
||||||
var sseResponse = await client.GetAsync($"/api/events/state?campaignId={campaign.Id}", HttpCompletionOption.ResponseHeadersRead);
|
var sseResponse = await client.GetAsync($"/api/events/state?campaignId={campaign.Id}", HttpCompletionOption.ResponseHeadersRead);
|
||||||
Assert.Equal(HttpStatusCode.OK, sseResponse.StatusCode);
|
Assert.Equal(HttpStatusCode.OK, sseResponse.StatusCode);
|
||||||
|
|||||||
@@ -11,9 +11,7 @@ public sealed class BackendCoverageTests
|
|||||||
var extensionsType = typeof(Program).Assembly.GetType("RpgRoller.Api.SessionTokenHttpContextExtensions");
|
var extensionsType = typeof(Program).Assembly.GetType("RpgRoller.Api.SessionTokenHttpContextExtensions");
|
||||||
Assert.NotNull(extensionsType);
|
Assert.NotNull(extensionsType);
|
||||||
|
|
||||||
var method = extensionsType!.GetMethod(
|
var method = extensionsType!.GetMethod("GetRequiredSessionToken", BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic);
|
||||||
"GetRequiredSessionToken",
|
|
||||||
BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic);
|
|
||||||
Assert.NotNull(method);
|
Assert.NotNull(method);
|
||||||
|
|
||||||
var context = new DefaultHttpContext();
|
var context = new DefaultHttpContext();
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
namespace RpgRoller.Tests;
|
|
||||||
|
|
||||||
// Service-level tests were split by concern under RpgRoller.Tests/Services.
|
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using RpgRoller.Data;
|
||||||
using RpgRoller.Hosting;
|
using RpgRoller.Hosting;
|
||||||
|
|
||||||
namespace RpgRoller.Tests;
|
namespace RpgRoller.Tests;
|
||||||
@@ -10,21 +13,175 @@ public sealed class HostingCoverageTests
|
|||||||
public void AddRpgRollerCore_WithInMemoryConnectionString_RegistersCoreServices()
|
public void AddRpgRollerCore_WithInMemoryConnectionString_RegistersCoreServices()
|
||||||
{
|
{
|
||||||
var services = new ServiceCollection();
|
var services = new ServiceCollection();
|
||||||
var configuration = new ConfigurationBuilder()
|
var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?> { ["ConnectionStrings:RpgRoller"] = "Data Source=:memory:" }).Build();
|
||||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
|
||||||
{
|
|
||||||
["ConnectionStrings:RpgRoller"] = "Data Source=:memory:"
|
|
||||||
})
|
|
||||||
.Build();
|
|
||||||
|
|
||||||
var environment = new TestWebHostEnvironment
|
var environment = new TestWebHostEnvironment { ContentRootPath = Path.GetTempPath() };
|
||||||
{
|
|
||||||
ContentRootPath = Path.GetTempPath()
|
|
||||||
};
|
|
||||||
|
|
||||||
services.AddRpgRollerCore(configuration, environment);
|
services.AddRpgRollerCore(configuration, environment);
|
||||||
|
|
||||||
Assert.Contains(services, d => d.ServiceType == typeof(RpgRoller.Services.IGameService));
|
Assert.Contains(services, d => d.ServiceType == typeof(IGameService));
|
||||||
Assert.Contains(services, d => d.ServiceType == typeof(RpgRoller.Services.IDiceRoller));
|
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);
|
||||||
|
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
127
RpgRoller.Tests/PayloadBudgetTests.cs
Normal file
127
RpgRoller.Tests/PayloadBudgetTests.cs
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using RpgRoller.Contracts;
|
||||||
|
|
||||||
|
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 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");
|
||||||
|
}
|
||||||
|
|
||||||
|
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 readonly JsonSerializerOptions SerializerOptions = RpgRollerJson.CreateSerializerOptions();
|
||||||
|
}
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
using RpgRoller.Services;
|
|
||||||
|
|
||||||
namespace RpgRoller.Tests;
|
namespace RpgRoller.Tests;
|
||||||
|
|
||||||
public sealed class DiceRulesTests
|
public sealed class DiceRulesTests
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
using RpgRoller.Services;
|
|
||||||
|
|
||||||
namespace RpgRoller.Tests;
|
namespace RpgRoller.Tests;
|
||||||
|
|
||||||
public sealed class RandomDiceRollerTests
|
public sealed class RandomDiceRollerTests
|
||||||
|
|||||||
132
RpgRoller.Tests/Services/ServiceAdminAndCampaignDeletionTests.cs
Normal file
132
RpgRoller.Tests/Services/ServiceAdminAndCampaignDeletionTests.cs
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
using RpgRoller.Domain;
|
||||||
|
|
||||||
|
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 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 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.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.True(login.Succeeded);
|
||||||
Assert.Equal(2, hasher.HashCalls);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ public sealed class ServiceCampaignTests
|
|||||||
var activateSuccess = service.ActivateCharacter(gmSession, character.Id);
|
var activateSuccess = service.ActivateCharacter(gmSession, character.Id);
|
||||||
Assert.True(activateSuccess.Succeeded);
|
Assert.True(activateSuccess.Succeeded);
|
||||||
|
|
||||||
var currentCharacters = service.GetCurrentCampaignCharacters(gmSession);
|
var currentCharacters = service.GetOwnCharacters(gmSession);
|
||||||
Assert.True(currentCharacters.Succeeded);
|
Assert.True(currentCharacters.Succeeded);
|
||||||
Assert.Single(ServiceTestSupport.GetValue(currentCharacters));
|
Assert.Single(ServiceTestSupport.GetValue(currentCharacters));
|
||||||
|
|
||||||
@@ -45,8 +45,9 @@ public sealed class ServiceCampaignTests
|
|||||||
service.Register("user", "Password123", "User");
|
service.Register("user", "Password123", "User");
|
||||||
var sessionToken = ServiceTestSupport.GetValue(service.Login("user", "Password123")).SessionToken;
|
var sessionToken = ServiceTestSupport.GetValue(service.Login("user", "Password123")).SessionToken;
|
||||||
|
|
||||||
var result = service.GetCurrentCampaignCharacters(sessionToken);
|
var result = service.GetOwnCharacters(sessionToken);
|
||||||
Assert.False(result.Succeeded);
|
Assert.True(result.Succeeded);
|
||||||
|
Assert.Empty(ServiceTestSupport.GetValue(result));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -71,7 +72,31 @@ public sealed class ServiceCampaignTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[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();
|
using var harness = ServiceTestSupport.CreateHarness();
|
||||||
var service = harness.Service;
|
var service = harness.Service;
|
||||||
@@ -92,9 +117,48 @@ public sealed class ServiceCampaignTests
|
|||||||
_ = ServiceTestSupport.GetValue(service.CreateSkill(otherSession, otherCharacter.Id, "Perception", "1D+2", 1, true));
|
_ = ServiceTestSupport.GetValue(service.CreateSkill(otherSession, otherCharacter.Id, "Perception", "1D+2", 1, true));
|
||||||
|
|
||||||
var ownerView = ServiceTestSupport.GetValue(service.GetCampaign(ownerSession, campaign.Id));
|
var ownerView = ServiceTestSupport.GetValue(service.GetCampaign(ownerSession, campaign.Id));
|
||||||
Assert.Single(ownerView.Characters);
|
Assert.Equal(2, ownerView.Characters.Length);
|
||||||
Assert.Equal(ownerCharacter.Id, ownerView.Characters[0].Id);
|
Assert.Contains(ownerView.Characters, character => character.Id == ownerCharacter.Id);
|
||||||
Assert.Single(ownerView.Skills);
|
Assert.Contains(ownerView.Characters, character => character.Id == otherCharacter.Id);
|
||||||
Assert.Equal(ownerSkill.Id, ownerView.Skills[0].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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ public sealed class ServicePersistenceTests
|
|||||||
Assert.False(service.UpdateCharacter(ownerSession, Guid.NewGuid(), "Renamed", campaign.Id).Succeeded);
|
Assert.False(service.UpdateCharacter(ownerSession, Guid.NewGuid(), "Renamed", campaign.Id).Succeeded);
|
||||||
Assert.False(service.ActivateCharacter(string.Empty, ownerCharacter.Id).Succeeded);
|
Assert.False(service.ActivateCharacter(string.Empty, ownerCharacter.Id).Succeeded);
|
||||||
Assert.False(service.ActivateCharacter(gmSession, ownerCharacter.Id).Succeeded);
|
Assert.False(service.ActivateCharacter(gmSession, ownerCharacter.Id).Succeeded);
|
||||||
Assert.False(service.GetCurrentCampaignCharacters(string.Empty).Succeeded);
|
Assert.False(service.GetOwnCharacters(string.Empty).Succeeded);
|
||||||
Assert.False(service.CreateSkill(string.Empty, ownerCharacter.Id, "Stealth", "2D+1", 1, true).Succeeded);
|
Assert.False(service.CreateSkill(string.Empty, ownerCharacter.Id, "Stealth", "2D+1", 1, true).Succeeded);
|
||||||
Assert.False(service.CreateSkill(ownerSession, Guid.NewGuid(), "Stealth", "2D+1", 1, true).Succeeded);
|
Assert.False(service.CreateSkill(ownerSession, Guid.NewGuid(), "Stealth", "2D+1", 1, true).Succeeded);
|
||||||
Assert.False(service.CreateSkill(otherSession, ownerCharacter.Id, "Stealth", "2D+1", 1, true).Succeeded);
|
Assert.False(service.CreateSkill(otherSession, ownerCharacter.Id, "Stealth", "2D+1", 1, true).Succeeded);
|
||||||
@@ -67,11 +67,12 @@ public sealed class ServicePersistenceTests
|
|||||||
using var staleCurrentHarness = ServiceTestSupport.CreateHarnessFromPath(harness.DbPath, 2, 3, 4);
|
using var staleCurrentHarness = ServiceTestSupport.CreateHarnessFromPath(harness.DbPath, 2, 3, 4);
|
||||||
var staleCurrentService = staleCurrentHarness.Service;
|
var staleCurrentService = staleCurrentHarness.Service;
|
||||||
|
|
||||||
var staleCurrentCampaign = staleCurrentService.GetCurrentCampaignCharacters(ownerSession);
|
var staleCurrentCampaign = staleCurrentService.GetOwnCharacters(ownerSession);
|
||||||
Assert.False(staleCurrentCampaign.Succeeded);
|
Assert.True(staleCurrentCampaign.Succeeded);
|
||||||
|
Assert.Single(ServiceTestSupport.GetValue(staleCurrentCampaign));
|
||||||
using (var db = harness.CreateDbContext())
|
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));
|
||||||
|
|||||||
177
RpgRoller.Tests/Services/ServiceSkillGroupAndOwnershipTests.cs
Normal file
177
RpgRoller.Tests/Services/ServiceSkillGroupAndOwnershipTests.cs
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
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, null));
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -65,10 +65,126 @@ public sealed class ServiceSkillRollTests
|
|||||||
Assert.Equal(2, ServiceTestSupport.GetValue(ownerLog).Count);
|
Assert.Equal(2, ServiceTestSupport.GetValue(ownerLog).Count);
|
||||||
Assert.Equal(2, ServiceTestSupport.GetValue(gmLog).Count);
|
Assert.Equal(2, ServiceTestSupport.GetValue(gmLog).Count);
|
||||||
Assert.False(outsiderLog.Succeeded);
|
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 state = service.GetCampaignStateSnapshot(ownerSession, campaign.Id);
|
||||||
var missingVersion = service.GetCampaignVersion(ownerSession, Guid.NewGuid());
|
var missingState = service.GetCampaignStateSnapshot(ownerSession, Guid.NewGuid());
|
||||||
Assert.True(version.Succeeded);
|
Assert.True(state.Succeeded);
|
||||||
Assert.False(missingVersion.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 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
103
RpgRoller.Tests/Services/WorkspaceQueryServiceTests.cs
Normal file
103
RpgRoller.Tests/Services/WorkspaceQueryServiceTests.cs
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using RpgRoller.Components;
|
||||||
|
using RpgRoller.Contracts;
|
||||||
|
using RpgRoller.Services;
|
||||||
|
|
||||||
|
namespace RpgRoller.Tests;
|
||||||
|
|
||||||
|
public sealed class WorkspaceQueryServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void SessionTokenAccessor_ReadsSessionCookieFromHttpContext()
|
||||||
|
{
|
||||||
|
var httpContext = new DefaultHttpContext();
|
||||||
|
httpContext.Request.Headers.Cookie = "rpgroller_session=session-token";
|
||||||
|
|
||||||
|
var accessor = new HttpContextAccessor { HttpContext = httpContext };
|
||||||
|
var sessionTokenAccessor = new WorkspaceSessionTokenAccessor(accessor);
|
||||||
|
|
||||||
|
Assert.Equal("session-token", sessionTokenAccessor.GetRequiredSessionToken());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetCampaignsAsync_UsesCapturedSessionToken()
|
||||||
|
{
|
||||||
|
var service = new StubGameService
|
||||||
|
{
|
||||||
|
GetCampaignsHandler = sessionToken =>
|
||||||
|
{
|
||||||
|
Assert.Equal("server-session", sessionToken);
|
||||||
|
return ServiceResult<IReadOnlyList<CampaignSummary>>.Success([new CampaignSummary(Guid.NewGuid(), "Alpha", "d6", new CampaignGmSummary(Guid.NewGuid(), "GM"), 1)]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var queryService = new WorkspaceQueryService(service, CreateSessionTokenAccessor("server-session"));
|
||||||
|
var campaigns = await queryService.GetCampaignsAsync();
|
||||||
|
|
||||||
|
Assert.Single(campaigns);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetMeAsync_MapsUnauthorizedServiceResultToApiRequestException()
|
||||||
|
{
|
||||||
|
var service = new StubGameService
|
||||||
|
{
|
||||||
|
GetMeHandler = _ => ServiceResult<MeResponse>.Failure("unauthorized", "You must be logged in.")
|
||||||
|
};
|
||||||
|
|
||||||
|
var queryService = new WorkspaceQueryService(service, CreateSessionTokenAccessor("expired-session"));
|
||||||
|
var exception = await Assert.ThrowsAsync<ApiRequestException>(queryService.GetMeAsync);
|
||||||
|
|
||||||
|
Assert.Equal(401, exception.StatusCode);
|
||||||
|
Assert.Equal("You must be logged in.", exception.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WorkspaceSessionTokenAccessor CreateSessionTokenAccessor(string sessionToken)
|
||||||
|
{
|
||||||
|
var httpContext = new DefaultHttpContext();
|
||||||
|
httpContext.Request.Headers.Cookie = $"rpgroller_session={sessionToken}";
|
||||||
|
return new WorkspaceSessionTokenAccessor(new HttpContextAccessor { HttpContext = httpContext });
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class StubGameService : IGameService
|
||||||
|
{
|
||||||
|
public Func<string, ServiceResult<MeResponse>> GetMeHandler { get; init; } =
|
||||||
|
_ => ServiceResult<MeResponse>.Failure("unexpected_call", "Unexpected GetMe call.");
|
||||||
|
|
||||||
|
public Func<string, ServiceResult<IReadOnlyList<CampaignSummary>>> GetCampaignsHandler { get; init; } =
|
||||||
|
_ => ServiceResult<IReadOnlyList<CampaignSummary>>.Failure("unexpected_call", "Unexpected GetCampaigns call.");
|
||||||
|
|
||||||
|
public IReadOnlyList<RulesetDefinition> GetRulesets() => throw new NotSupportedException();
|
||||||
|
public ServiceResult<UserSummary> Register(string username, string password, string displayName) => throw new NotSupportedException();
|
||||||
|
public ServiceResult<(UserSummary User, string SessionToken)> Login(string username, string password) => throw new NotSupportedException();
|
||||||
|
public void Logout(string sessionToken) => throw new NotSupportedException();
|
||||||
|
public UserSummary? GetUserBySession(string sessionToken) => throw new NotSupportedException();
|
||||||
|
public ServiceResult<MeResponse> GetMe(string sessionToken) => GetMeHandler(sessionToken);
|
||||||
|
public ServiceResult<CampaignSummary> CreateCampaign(string sessionToken, string name, string rulesetId) => throw new NotSupportedException();
|
||||||
|
public ServiceResult<IReadOnlyList<CampaignSummary>> GetCampaigns(string sessionToken) => GetCampaignsHandler(sessionToken);
|
||||||
|
public ServiceResult<IReadOnlyList<CampaignOption>> GetCharacterCampaignOptions(string sessionToken) => throw new NotSupportedException();
|
||||||
|
public ServiceResult<CampaignRoster> GetCampaign(string sessionToken, Guid campaignId) => throw new NotSupportedException();
|
||||||
|
public ServiceResult<bool> DeleteCampaign(string sessionToken, Guid campaignId) => throw new NotSupportedException();
|
||||||
|
public ServiceResult<IReadOnlyList<string>> GetUsernames(string sessionToken) => throw new NotSupportedException();
|
||||||
|
public ServiceResult<IReadOnlyList<AdminUserSummary>> GetUsers(string sessionToken) => throw new NotSupportedException();
|
||||||
|
public ServiceResult<AdminUserSummary> UpdateUserRoles(string sessionToken, Guid userId, IReadOnlyList<string> roles) => throw new NotSupportedException();
|
||||||
|
public ServiceResult<bool> DeleteUser(string sessionToken, Guid userId) => throw new NotSupportedException();
|
||||||
|
public ServiceResult<CharacterSummary> CreateCharacter(string sessionToken, string name, Guid campaignId) => throw new NotSupportedException();
|
||||||
|
public ServiceResult<CharacterSummary> UpdateCharacter(string sessionToken, Guid characterId, string name, Guid? campaignId, string? ownerUsername = null) => throw new NotSupportedException();
|
||||||
|
public ServiceResult<bool> DeleteCharacter(string sessionToken, Guid characterId) => throw new NotSupportedException();
|
||||||
|
public ServiceResult<bool> ActivateCharacter(string sessionToken, Guid characterId) => throw new NotSupportedException();
|
||||||
|
public ServiceResult<IReadOnlyList<CharacterSummary>> GetOwnCharacters(string sessionToken) => throw new NotSupportedException();
|
||||||
|
public ServiceResult<SkillGroupSummary> CreateSkillGroup(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble) => throw new NotSupportedException();
|
||||||
|
public ServiceResult<SkillGroupSummary> UpdateSkillGroup(string sessionToken, Guid skillGroupId, string name, string diceRollDefinition, int wildDice, bool allowFumble) => throw new NotSupportedException();
|
||||||
|
public ServiceResult<bool> DeleteSkillGroup(string sessionToken, Guid skillGroupId) => throw new NotSupportedException();
|
||||||
|
public ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null) => throw new NotSupportedException();
|
||||||
|
public ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null) => throw new NotSupportedException();
|
||||||
|
public ServiceResult<bool> DeleteSkill(string sessionToken, Guid skillId) => throw new NotSupportedException();
|
||||||
|
public ServiceResult<CharacterSheet> GetCharacterSheet(string sessionToken, Guid characterId) => throw new NotSupportedException();
|
||||||
|
public ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility) => throw new NotSupportedException();
|
||||||
|
public ServiceResult<IReadOnlyList<CampaignLogEntry>> GetCampaignLog(string sessionToken, Guid campaignId) => throw new NotSupportedException();
|
||||||
|
public ServiceResult<CampaignLogPage> GetCampaignLogPage(string sessionToken, Guid campaignId, Guid? afterRollId = null, int? limit = null) => throw new NotSupportedException();
|
||||||
|
public ServiceResult<CampaignRollDetail> GetRollDetail(string sessionToken, Guid rollId) => throw new NotSupportedException();
|
||||||
|
public ServiceResult<CampaignStateSnapshot> GetCampaignStateSnapshot(string sessionToken, Guid campaignId) => throw new NotSupportedException();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +1,28 @@
|
|||||||
using System.Net;
|
|
||||||
using System.Net.Http.Json;
|
|
||||||
using Microsoft.AspNetCore.Mvc.Testing;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||||
using RpgRoller.Contracts;
|
|
||||||
using RpgRoller.Data;
|
using RpgRoller.Data;
|
||||||
using RpgRoller.Services;
|
using RpgRoller.Hosting;
|
||||||
|
|
||||||
namespace RpgRoller.Tests;
|
namespace RpgRoller.Tests;
|
||||||
|
|
||||||
public abstract class ApiTestBase : IClassFixture<WebApplicationFactory<Program>>
|
public abstract class ApiTestBase : IClassFixture<WebApplicationFactory<Program>>
|
||||||
{
|
{
|
||||||
private readonly WebApplicationFactory<Program> m_BaseFactory;
|
private sealed class FixedDiceRoller : IDiceRoller
|
||||||
|
{
|
||||||
|
public FixedDiceRoller(IEnumerable<int> values)
|
||||||
|
{
|
||||||
|
m_Values = new(values);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
protected ApiTestBase(WebApplicationFactory<Program> factory)
|
protected ApiTestBase(WebApplicationFactory<Program> factory)
|
||||||
{
|
{
|
||||||
@@ -21,8 +31,7 @@ public abstract class ApiTestBase : IClassFixture<WebApplicationFactory<Program>
|
|||||||
|
|
||||||
protected WebApplicationFactory<Program> CreateFactory(params int[] rollValues)
|
protected WebApplicationFactory<Program> CreateFactory(params int[] rollValues)
|
||||||
{
|
{
|
||||||
return m_BaseFactory.WithWebHostBuilder(builder =>
|
return m_BaseFactory.WithWebHostBuilder(builder => builder.ConfigureServices(services =>
|
||||||
builder.ConfigureServices(services =>
|
|
||||||
{
|
{
|
||||||
services.RemoveAll<IDiceRoller>();
|
services.RemoveAll<IDiceRoller>();
|
||||||
services.AddSingleton<IDiceRoller>(new FixedDiceRoller(rollValues));
|
services.AddSingleton<IDiceRoller>(new FixedDiceRoller(rollValues));
|
||||||
@@ -30,18 +39,17 @@ public abstract class ApiTestBase : IClassFixture<WebApplicationFactory<Program>
|
|||||||
services.RemoveAll<DbContextOptions<RpgRollerDbContext>>();
|
services.RemoveAll<DbContextOptions<RpgRollerDbContext>>();
|
||||||
services.RemoveAll<IDbContextFactory<RpgRollerDbContext>>();
|
services.RemoveAll<IDbContextFactory<RpgRollerDbContext>>();
|
||||||
services.RemoveAll<RpgRollerDbContext>();
|
services.RemoveAll<RpgRollerDbContext>();
|
||||||
|
services.RemoveAll<SqliteDatabaseFile>();
|
||||||
|
|
||||||
var dbPath = Path.Combine(Path.GetTempPath(), $"rpgroller-tests-{Guid.NewGuid():N}.db");
|
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}"));
|
services.AddDbContextFactory<RpgRollerDbContext>(options => options.UseSqlite($"Data Source={dbPath}"));
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
protected static async Task<UserSummary> RegisterAsync(HttpClient client, string username, string password, string displayName)
|
protected static async Task<UserSummary> RegisterAsync(HttpClient client, string username, string password, string displayName)
|
||||||
{
|
{
|
||||||
return await PostAsync<RegisterRequest, UserSummary>(
|
return await PostAsync<RegisterRequest, UserSummary>(client, "/api/auth/register", new(username, password, displayName));
|
||||||
client,
|
|
||||||
"/api/auth/register",
|
|
||||||
new RegisterRequest(username, password, displayName));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected static async Task LoginAsync(HttpClient client, string username, string password)
|
protected static async Task LoginAsync(HttpClient client, string username, string password)
|
||||||
@@ -77,19 +85,5 @@ public abstract class ApiTestBase : IClassFixture<WebApplicationFactory<Program>
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class FixedDiceRoller : IDiceRoller
|
private readonly WebApplicationFactory<Program> m_BaseFactory;
|
||||||
{
|
|
||||||
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,86 @@
|
|||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using RpgRoller.Data;
|
using RpgRoller.Data;
|
||||||
using RpgRoller.Domain;
|
|
||||||
using RpgRoller.Services;
|
|
||||||
|
|
||||||
namespace RpgRoller.Tests;
|
namespace RpgRoller.Tests;
|
||||||
|
|
||||||
internal static class ServiceTestSupport
|
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 : IDiceRoller
|
||||||
|
{
|
||||||
|
public FixedDiceRoller(IEnumerable<int> values)
|
||||||
|
{
|
||||||
|
m_Values = new(values);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class SqliteDbContextFactory : IDbContextFactory<RpgRollerDbContext>, IDisposable
|
||||||
|
{
|
||||||
|
public SqliteDbContextFactory(string dbPath)
|
||||||
|
{
|
||||||
|
m_Options = new DbContextOptionsBuilder<RpgRollerDbContext>().UseSqlite($"Data Source={dbPath}").Options;
|
||||||
|
}
|
||||||
|
|
||||||
|
public RpgRollerDbContext CreateDbContext()
|
||||||
|
{
|
||||||
|
return new(m_Options);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly DbContextOptions<RpgRollerDbContext> m_Options;
|
||||||
|
}
|
||||||
|
|
||||||
internal static ServiceHarness CreateHarness(params int[] rollValues)
|
internal static ServiceHarness CreateHarness(params int[] rollValues)
|
||||||
{
|
{
|
||||||
return CreateHarness(new PasswordHasher<UserAccount>(), rollValues);
|
return CreateHarness(new PasswordHasher<UserAccount>(), rollValues);
|
||||||
@@ -26,9 +99,7 @@ internal static class ServiceTestSupport
|
|||||||
|
|
||||||
internal static ServiceHarness CreateHarnessFromPath(string dbPath, IPasswordHasher<UserAccount> passwordHasher, params int[] rollValues)
|
internal static ServiceHarness CreateHarnessFromPath(string dbPath, IPasswordHasher<UserAccount> passwordHasher, params int[] rollValues)
|
||||||
{
|
{
|
||||||
var options = new DbContextOptionsBuilder<RpgRollerDbContext>()
|
var options = new DbContextOptionsBuilder<RpgRollerDbContext>().UseSqlite($"Data Source={dbPath}").Options;
|
||||||
.UseSqlite($"Data Source={dbPath}")
|
|
||||||
.Options;
|
|
||||||
|
|
||||||
using (var db = new RpgRollerDbContext(options))
|
using (var db = new RpgRollerDbContext(options))
|
||||||
{
|
{
|
||||||
@@ -37,7 +108,7 @@ internal static class ServiceTestSupport
|
|||||||
|
|
||||||
var factory = new SqliteDbContextFactory(dbPath);
|
var factory = new SqliteDbContextFactory(dbPath);
|
||||||
var service = new GameService(factory, passwordHasher, new FixedDiceRoller(rollValues));
|
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)
|
internal static T GetValue<T>(ServiceResult<T> result)
|
||||||
@@ -46,84 +117,4 @@ internal static class ServiceTestSupport
|
|||||||
Assert.NotNull(result.Value);
|
Assert.NotNull(result.Value);
|
||||||
return 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()
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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 ServiceError("forbidden", "Admin role is required."));
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(databaseFile.Path) || !File.Exists(databaseFile.Path))
|
||||||
|
return ApiResultMapper.ToBadRequest(new ServiceError("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.MapSystemEndpoints();
|
||||||
api.MapAuthEndpoints();
|
api.MapAuthEndpoints();
|
||||||
|
|
||||||
var authenticatedApi = api.MapGroup(string.Empty)
|
var authenticatedApi = api.MapGroup(string.Empty).AddEndpointFilter<RequireSessionTokenFilter>();
|
||||||
.AddEndpointFilter<RequireSessionTokenFilter>();
|
|
||||||
|
|
||||||
authenticatedApi.MapMeEndpoints();
|
authenticatedApi.MapMeEndpoints();
|
||||||
authenticatedApi.MapCampaignEndpoints();
|
authenticatedApi.MapCampaignEndpoints();
|
||||||
authenticatedApi.MapCharacterEndpoints();
|
authenticatedApi.MapCharacterEndpoints();
|
||||||
|
authenticatedApi.MapAdminEndpoints();
|
||||||
authenticatedApi.MapSkillEndpoints();
|
authenticatedApi.MapSkillEndpoints();
|
||||||
|
authenticatedApi.MapRollEndpoints();
|
||||||
authenticatedApi.MapStateEventEndpoints();
|
authenticatedApi.MapStateEventEndpoints();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,14 +9,10 @@ internal static class ApiResultMapper
|
|||||||
public static Results<Ok<T>, BadRequest<ApiError>, UnauthorizedHttpResult> ToApiResult<T>(ServiceResult<T> result)
|
public static Results<Ok<T>, BadRequest<ApiError>, UnauthorizedHttpResult> ToApiResult<T>(ServiceResult<T> result)
|
||||||
{
|
{
|
||||||
if (result.Succeeded)
|
if (result.Succeeded)
|
||||||
{
|
|
||||||
return TypedResults.Ok(result.Value!);
|
return TypedResults.Ok(result.Value!);
|
||||||
}
|
|
||||||
|
|
||||||
if (result.Error!.Code == "unauthorized")
|
if (result.Error!.Code == "unauthorized")
|
||||||
{
|
|
||||||
return TypedResults.Unauthorized();
|
return TypedResults.Unauthorized();
|
||||||
}
|
|
||||||
|
|
||||||
return TypedResults.BadRequest(new ApiError(result.Error.Message));
|
return TypedResults.BadRequest(new ApiError(result.Error.Message));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,9 +12,7 @@ internal static class AuthEndpoints
|
|||||||
{
|
{
|
||||||
var result = game.Register(request.Username, request.Password, request.DisplayName);
|
var result = game.Register(request.Username, request.Password, request.DisplayName);
|
||||||
if (!result.Succeeded)
|
if (!result.Succeeded)
|
||||||
{
|
|
||||||
return ApiResultMapper.ToBadRequest(result.Error!);
|
return ApiResultMapper.ToBadRequest(result.Error!);
|
||||||
}
|
|
||||||
|
|
||||||
return TypedResults.Ok(result.Value!);
|
return TypedResults.Ok(result.Value!);
|
||||||
});
|
});
|
||||||
@@ -23,11 +21,9 @@ internal static class AuthEndpoints
|
|||||||
{
|
{
|
||||||
var result = game.Login(request.Username, request.Password);
|
var result = game.Login(request.Username, request.Password);
|
||||||
if (!result.Succeeded)
|
if (!result.Succeeded)
|
||||||
{
|
|
||||||
return ApiResultMapper.ToBadRequest(result.Error!);
|
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,
|
HttpOnly = true,
|
||||||
SameSite = SameSiteMode.Strict,
|
SameSite = SameSiteMode.Strict,
|
||||||
@@ -41,9 +37,7 @@ internal static class AuthEndpoints
|
|||||||
group.MapPost("/auth/logout", (HttpContext context, IGameService game) =>
|
group.MapPost("/auth/logout", (HttpContext context, IGameService game) =>
|
||||||
{
|
{
|
||||||
if (context.TryReadSessionTokenFromCookie(out var sessionToken))
|
if (context.TryReadSessionTokenFromCookie(out var sessionToken))
|
||||||
{
|
|
||||||
game.Logout(sessionToken);
|
game.Logout(sessionToken);
|
||||||
}
|
|
||||||
|
|
||||||
context.Response.Cookies.Delete(SessionCookie.Name);
|
context.Response.Cookies.Delete(SessionCookie.Name);
|
||||||
return TypedResults.NoContent();
|
return TypedResults.NoContent();
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ internal static class CampaignEndpoints
|
|||||||
return ApiResultMapper.ToApiResult(result);
|
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) =>
|
group.MapGet("/campaigns/{campaignId:guid}", (Guid campaignId, HttpContext context, IGameService game) =>
|
||||||
{
|
{
|
||||||
var result = game.GetCampaign(context.GetRequiredSessionToken(), campaignId);
|
var result = game.GetCampaign(context.GetRequiredSessionToken(), campaignId);
|
||||||
@@ -31,6 +37,18 @@ internal static class CampaignEndpoints
|
|||||||
return ApiResultMapper.ToApiResult(result);
|
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;
|
return group;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,13 @@ internal static class CharacterEndpoints
|
|||||||
|
|
||||||
group.MapPut("/characters/{characterId:guid}", (Guid characterId, UpdateCharacterRequest request, HttpContext context, IGameService game) =>
|
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);
|
return ApiResultMapper.ToApiResult(result);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -25,9 +31,21 @@ internal static class CharacterEndpoints
|
|||||||
return ApiResultMapper.ToApiResult(result);
|
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);
|
return ApiResultMapper.ToApiResult(result);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,7 @@ internal sealed class RequireSessionTokenFilter : IEndpointFilter
|
|||||||
public ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
|
public ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
|
||||||
{
|
{
|
||||||
if (!context.HttpContext.TryReadSessionTokenFromCookie(out var sessionToken))
|
if (!context.HttpContext.TryReadSessionTokenFromCookie(out var sessionToken))
|
||||||
{
|
|
||||||
return ValueTask.FromResult<object?>(TypedResults.Unauthorized());
|
return ValueTask.FromResult<object?>(TypedResults.Unauthorized());
|
||||||
}
|
|
||||||
|
|
||||||
context.HttpContext.StoreSessionToken(sessionToken);
|
context.HttpContext.StoreSessionToken(sessionToken);
|
||||||
return next(context);
|
return next(context);
|
||||||
|
|||||||
18
RpgRoller/Api/RollEndpoints.cs
Normal file
18
RpgRoller/Api/RollEndpoints.cs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
using RpgRoller.Contracts;
|
||||||
|
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
|
internal static class SessionTokenHttpContextExtensions
|
||||||
{
|
{
|
||||||
private const string SessionTokenItemKey = "__rpgroller.session-token";
|
|
||||||
|
|
||||||
public static bool TryReadSessionTokenFromCookie(this HttpContext context, out string sessionToken)
|
public static bool TryReadSessionTokenFromCookie(this HttpContext context, out string sessionToken)
|
||||||
{
|
{
|
||||||
sessionToken = context.Request.Cookies[SessionCookie.Name] ?? string.Empty;
|
sessionToken = context.Request.Cookies[SessionCookie.Name] ?? string.Empty;
|
||||||
@@ -17,13 +15,11 @@ internal static class SessionTokenHttpContextExtensions
|
|||||||
|
|
||||||
public static string GetRequiredSessionToken(this HttpContext context)
|
public static string GetRequiredSessionToken(this HttpContext context)
|
||||||
{
|
{
|
||||||
if (context.Items.TryGetValue(SessionTokenItemKey, out var token)
|
if (context.Items.TryGetValue(SessionTokenItemKey, out var token) && token is string sessionToken && !string.IsNullOrWhiteSpace(sessionToken))
|
||||||
&& token is string sessionToken
|
|
||||||
&& !string.IsNullOrWhiteSpace(sessionToken))
|
|
||||||
{
|
|
||||||
return sessionToken;
|
return sessionToken;
|
||||||
}
|
|
||||||
|
|
||||||
throw new InvalidOperationException("Session token is not available in this request.");
|
throw new InvalidOperationException("Session token is not available in this request.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private const string SessionTokenItemKey = "__rpgroller.session-token";
|
||||||
}
|
}
|
||||||
@@ -9,13 +9,37 @@ internal static class SkillEndpoints
|
|||||||
{
|
{
|
||||||
group.MapPost("/characters/{characterId:guid}/skills", (Guid characterId, CreateSkillRequest request, HttpContext context, IGameService game) =>
|
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);
|
||||||
return ApiResultMapper.ToApiResult(result);
|
return ApiResultMapper.ToApiResult(result);
|
||||||
});
|
});
|
||||||
|
|
||||||
group.MapPut("/skills/{skillId:guid}", (Guid skillId, UpdateSkillRequest request, HttpContext context, IGameService game) =>
|
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);
|
||||||
|
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);
|
||||||
|
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);
|
||||||
|
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);
|
return ApiResultMapper.ToApiResult(result);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -7,26 +7,21 @@ internal static class StateEventEndpoints
|
|||||||
{
|
{
|
||||||
public static RouteGroupBuilder MapStateEventEndpoints(this RouteGroupBuilder group)
|
public static RouteGroupBuilder MapStateEventEndpoints(this RouteGroupBuilder group)
|
||||||
{
|
{
|
||||||
group.MapGet("/events/state", async Task<IResult> (
|
group.MapGet("/events/state", async Task<IResult> (Guid campaignId, HttpContext context, IGameService game) =>
|
||||||
Guid campaignId,
|
|
||||||
HttpContext context,
|
|
||||||
IGameService game) =>
|
|
||||||
{
|
{
|
||||||
var sessionToken = context.GetRequiredSessionToken();
|
var sessionToken = context.GetRequiredSessionToken();
|
||||||
var versionResult = game.GetCampaignVersion(sessionToken, campaignId);
|
var stateResult = game.GetCampaignStateSnapshot(sessionToken, campaignId);
|
||||||
if (!versionResult.Succeeded)
|
if (!stateResult.Succeeded)
|
||||||
{
|
{
|
||||||
return versionResult.Error!.Code == "unauthorized"
|
return stateResult.Error!.Code == "unauthorized" ? TypedResults.Unauthorized() : TypedResults.BadRequest(new ApiError(stateResult.Error.Message));
|
||||||
? TypedResults.Unauthorized()
|
|
||||||
: TypedResults.BadRequest(new ApiError(versionResult.Error.Message));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
context.Response.Headers.CacheControl = "no-cache";
|
context.Response.Headers.CacheControl = "no-cache";
|
||||||
context.Response.Headers.Connection = "keep-alive";
|
context.Response.Headers.Connection = "keep-alive";
|
||||||
context.Response.ContentType = "text/event-stream";
|
context.Response.ContentType = "text/event-stream";
|
||||||
|
|
||||||
var lastVersion = versionResult.Value;
|
var lastState = stateResult.Value!;
|
||||||
await context.Response.WriteAsync($"event: state\ndata: {{\"campaignId\":\"{campaignId}\",\"version\":{lastVersion}}}\n\n");
|
await WriteStateEventAsync(context.Response, lastState);
|
||||||
await context.Response.Body.FlushAsync();
|
await context.Response.Body.FlushAsync();
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -35,21 +30,18 @@ internal static class StateEventEndpoints
|
|||||||
{
|
{
|
||||||
await Task.Delay(TimeSpan.FromSeconds(1), context.RequestAborted);
|
await Task.Delay(TimeSpan.FromSeconds(1), context.RequestAborted);
|
||||||
|
|
||||||
var currentVersionResult = game.GetCampaignVersion(sessionToken, campaignId);
|
var currentStateResult = game.GetCampaignStateSnapshot(sessionToken, campaignId);
|
||||||
if (!currentVersionResult.Succeeded)
|
if (!currentStateResult.Succeeded)
|
||||||
{
|
|
||||||
break;
|
break;
|
||||||
}
|
|
||||||
|
|
||||||
if (currentVersionResult.Value != lastVersion)
|
var currentState = currentStateResult.Value!;
|
||||||
|
if (currentState.TotalVersion != lastState.TotalVersion)
|
||||||
{
|
{
|
||||||
lastVersion = currentVersionResult.Value;
|
lastState = currentState;
|
||||||
await context.Response.WriteAsync($"event: state\ndata: {{\"campaignId\":\"{campaignId}\",\"version\":{lastVersion}}}\n\n");
|
await WriteStateEventAsync(context.Response, currentState);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
|
||||||
await context.Response.WriteAsync(": heartbeat\n\n");
|
await context.Response.WriteAsync(": heartbeat\n\n");
|
||||||
}
|
|
||||||
|
|
||||||
await context.Response.Body.FlushAsync();
|
await context.Response.Body.FlushAsync();
|
||||||
}
|
}
|
||||||
@@ -63,4 +55,14 @@ internal static class StateEventEndpoints
|
|||||||
|
|
||||||
return group;
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
@using Microsoft.AspNetCore.Components.Web
|
|
||||||
@attribute [ExcludeFromCodeCoverage]
|
@attribute [ExcludeFromCodeCoverage]
|
||||||
|
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
@@ -6,14 +5,35 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8"/>
|
<meta charset="utf-8"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
<base href="/" />
|
<base href="@BaseHref"/>
|
||||||
<title>RpgRoller</title>
|
<title>RpgRoller</title>
|
||||||
<link rel="stylesheet" href="/styles.css" />
|
<link rel="stylesheet" href="styles.css"/>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Baloo+2:wght@400;500;600;700&family=Nunito:wght@400;600;700&display=swap" rel="stylesheet">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Noto+Color+Emoji&display=swap" rel="stylesheet">
|
||||||
<HeadOutlet @rendermode="InteractiveServer"/>
|
<HeadOutlet @rendermode="InteractiveServer"/>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<Routes @rendermode="InteractiveServer"/>
|
<Routes @rendermode="InteractiveServer"/>
|
||||||
<script src="/js/rpgroller-api.js"></script>
|
<script src="js/rpgroller-api.js"></script>
|
||||||
<script src="_framework/blazor.web.js"></script>
|
<script src="_framework/blazor.web.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[CascadingParameter]
|
||||||
|
private Microsoft.AspNetCore.Http.HttpContext? HttpContext { get; set; }
|
||||||
|
|
||||||
|
private string BaseHref
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var pathBase = HttpContext?.Request.PathBase.Value;
|
||||||
|
if (string.IsNullOrWhiteSpace(pathBase))
|
||||||
|
return "/";
|
||||||
|
|
||||||
|
return pathBase.EndsWith('/') ? pathBase : $"{pathBase}/";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
64
RpgRoller/Components/Pages/Home.Models.cs
Normal file
64
RpgRoller/Components/Pages/Home.Models.cs
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
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 DiceRollDefinition { get; set; } = string.Empty;
|
||||||
|
public string SkillGroupId { get; set; } = string.Empty;
|
||||||
|
public int WildDice { get; set; }
|
||||||
|
public bool AllowFumble { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class SkillGroupFormModel
|
||||||
|
{
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string DiceRollDefinition { get; set; } = string.Empty;
|
||||||
|
public int WildDice { get; set; }
|
||||||
|
public bool AllowFumble { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum HomeViewMode
|
||||||
|
{
|
||||||
|
Loading,
|
||||||
|
Anonymous,
|
||||||
|
Workspace
|
||||||
|
}
|
||||||
@@ -1,502 +1,27 @@
|
|||||||
@page "/"
|
@page "/"
|
||||||
@implements IAsyncDisposable
|
@using RpgRoller.Components.Pages.HomeControls
|
||||||
|
|
||||||
<div class="rr-app">
|
@switch (CurrentView)
|
||||||
<p class="sr-only" aria-live="polite">@LiveAnnouncement</p>
|
|
||||||
|
|
||||||
@if (!IsInitialized)
|
|
||||||
{
|
{
|
||||||
|
case HomeViewMode.Loading:
|
||||||
|
<div class="rr-app">
|
||||||
<main class="loading-shell" aria-busy="true" aria-live="polite">
|
<main class="loading-shell" aria-busy="true" aria-live="polite">
|
||||||
<h1>RpgRoller</h1>
|
<h1>RpgRoller</h1>
|
||||||
<p>Connecting...</p>
|
<p>Connecting...</p>
|
||||||
</main>
|
</main>
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
@if (HasHealthIssue)
|
|
||||||
{
|
|
||||||
<section class="health-banner" role="alert">
|
|
||||||
<div>
|
|
||||||
<strong>API currently unavailable.</strong>
|
|
||||||
<p>@HealthIssueMessage</p>
|
|
||||||
</div>
|
</div>
|
||||||
<button type="button" @onclick="RetryAfterHealthIssueAsync">Retry</button>
|
break;
|
||||||
</section>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (User is null)
|
case HomeViewMode.Anonymous:
|
||||||
{
|
<div class="rr-app">
|
||||||
<main class="auth-shell">
|
<AuthSection
|
||||||
<h1>RpgRoller</h1>
|
StatusMessage="StatusMessage"
|
||||||
<p class="auth-subtitle">Register or log in to join a campaign session.</p>
|
StatusIsError="StatusIsError"
|
||||||
@if (!string.IsNullOrWhiteSpace(StatusMessage))
|
LoggedIn="OnLoggedInAsync"/>
|
||||||
{
|
</div>
|
||||||
<p class="status-message @(StatusIsError ? "error" : "success")">@StatusMessage</p>
|
break;
|
||||||
}
|
|
||||||
<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">
|
case HomeViewMode.Workspace:
|
||||||
<h2>Login</h2>
|
<Workspace LoggedOut="OnLoggedOutAsync"/>
|
||||||
@if (!string.IsNullOrWhiteSpace(LoginFormError))
|
break;
|
||||||
{
|
|
||||||
<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>
|
||||||
205
RpgRoller/Components/Pages/HomeControls/AdminHome.razor.cs
Normal file
205
RpgRoller/Components/Pages/HomeControls/AdminHome.razor.cs
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
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
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
return
|
||||||
|
[
|
||||||
|
new AppHeaderMenuItem { Label = "Play", IsActive = false, OnSelected = OpenPlayAsync },
|
||||||
|
new AppHeaderMenuItem { Label = "Campaign Management", IsActive = false, OnSelected = OpenCampaignManagementAsync },
|
||||||
|
new AppHeaderMenuItem { Label = "Admin", IsActive = true, OnSelected = OpenAdminAsync }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback<string?> LoggedOut { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback<string> WorkspaceRequested { get; set; }
|
||||||
|
}
|
||||||
53
RpgRoller/Components/Pages/HomeControls/AppHeader.razor
Normal file
53
RpgRoller/Components/Pages/HomeControls/AppHeader.razor
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<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>
|
||||||
|
}
|
||||||
|
@if (ShowConnectionState)
|
||||||
|
{
|
||||||
|
<div class="header-connection-cell">
|
||||||
|
<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>
|
||||||
60
RpgRoller/Components/Pages/HomeControls/AppHeader.razor.cs
Normal file
60
RpgRoller/Components/Pages/HomeControls/AppHeader.razor.cs
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<aside @ref="LogPanelRef" 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)
|
||||||
|
{
|
||||||
|
var isExpanded = ExpandedRollId == entry.RollId;
|
||||||
|
<li class="log-entry @LogEntryCssClass(entry) @(isExpanded ? "expanded" : string.Empty)">
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
}
|
||||||
|
</aside>
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
using Microsoft.JSInterop;
|
||||||
|
using RpgRoller.Contracts;
|
||||||
|
|
||||||
|
namespace RpgRoller.Components.Pages.HomeControls;
|
||||||
|
|
||||||
|
[ExcludeFromCodeCoverage]
|
||||||
|
public partial class CampaignLogPanel
|
||||||
|
{
|
||||||
|
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", LogPanelRef);
|
||||||
|
}
|
||||||
|
catch (JSDisconnectedException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex) when (ex.Message.Contains("statically rendered", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LastRenderedLogCount = CampaignLog.Count;
|
||||||
|
LastRenderedLogRollId = currentLastRollId;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Inject]
|
||||||
|
private IJSRuntime JS { get; set; } = null!;
|
||||||
|
|
||||||
|
private ElementReference LogPanelRef { get; set; }
|
||||||
|
private int LastRenderedLogCount { get; set; }
|
||||||
|
private Guid? LastRenderedLogRollId { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public bool IsCampaignDataLoading { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public IReadOnlyList<CampaignLogListEntry> CampaignLog { get; set; } = [];
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public Guid? ExpandedRollId { 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;
|
||||||
|
|
||||||
|
private static string LogEntryCssClass(CampaignLogListEntry entry)
|
||||||
|
{
|
||||||
|
return entry.VisibilityStyle;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,124 @@
|
|||||||
|
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; }
|
||||||
|
}
|
||||||
229
RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor
Normal file
229
RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
<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" @bind="SkillGroupState.Model.DiceRollDefinition" @bind:event="oninput"/>
|
||||||
|
@if (SkillGroupState.Errors.TryGetValue("diceRollDefinition", out var expressionError))
|
||||||
|
{
|
||||||
|
<p class="field-error">@expressionError</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (IsD6)
|
||||||
|
{
|
||||||
|
<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"/>
|
||||||
|
}
|
||||||
|
|
||||||
|
@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"
|
||||||
|
IsD6="IsD6"
|
||||||
|
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"
|
||||||
|
InitialModel="CreateSkillInitialModel"
|
||||||
|
FormVersion="CreateSkillFormVersion"
|
||||||
|
SelectedCharacterId="SelectedCharacterId"
|
||||||
|
EditingSkillId="null"
|
||||||
|
AvailableSkillGroups="SelectedCharacterSkillGroups"
|
||||||
|
IsMutating="IsMutating"
|
||||||
|
SkillSaved="OnSkillCreatedAsync"
|
||||||
|
CancelRequested="CloseSkillModals"/>
|
||||||
|
|
||||||
|
<SkillFormModal
|
||||||
|
Visible="ShowEditSkillModal"
|
||||||
|
IsD6="IsD6"
|
||||||
|
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"
|
||||||
|
InitialModel="EditSkillInitialModel"
|
||||||
|
FormVersion="EditSkillFormVersion"
|
||||||
|
SelectedCharacterId="SelectedCharacterId"
|
||||||
|
EditingSkillId="EditingSkillId"
|
||||||
|
AvailableSkillGroups="SelectedCharacterSkillGroups"
|
||||||
|
IsMutating="IsMutating"
|
||||||
|
SkillSaved="OnSkillUpdatedAsync"
|
||||||
|
CancelRequested="CloseSkillModals"/>
|
||||||
355
RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor.cs
Normal file
355
RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor.cs
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
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,
|
||||||
|
DiceRollDefinition = selectedGroup?.DiceRollDefinition ?? string.Empty,
|
||||||
|
SkillGroupId = selectedGroup?.Id.ToString() ?? string.Empty,
|
||||||
|
WildDice = selectedGroup?.WildDice ?? (IsD6 ? 1 : 0),
|
||||||
|
AllowFumble = selectedGroup?.AllowFumble ?? IsD6
|
||||||
|
};
|
||||||
|
|
||||||
|
CreateSkillFormVersion++;
|
||||||
|
ShowCreateSkillModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OpenEditSkillModal(CharacterSheetSkill skill)
|
||||||
|
{
|
||||||
|
EditingSkillId = skill.Id;
|
||||||
|
EditSkillInitialModel = new()
|
||||||
|
{
|
||||||
|
Name = skill.Name,
|
||||||
|
DiceRollDefinition = skill.DiceRollDefinition,
|
||||||
|
SkillGroupId = skill.SkillGroupId?.ToString() ?? string.Empty,
|
||||||
|
WildDice = skill.WildDice,
|
||||||
|
AllowFumble = skill.AllowFumble
|
||||||
|
};
|
||||||
|
|
||||||
|
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(Guid skillId)
|
||||||
|
{
|
||||||
|
await RollRequested.InvokeAsync(skillId);
|
||||||
|
}
|
||||||
|
|
||||||
|
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.DiceRollDefinition = string.Empty;
|
||||||
|
SkillGroupState.Model.WildDice = IsD6 ? 1 : 0;
|
||||||
|
SkillGroupState.Model.AllowFumble = IsD6;
|
||||||
|
SkillGroupState.ResetValidation();
|
||||||
|
ShowCreateSkillGroupModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OpenEditSkillGroupModal(CharacterSheetSkillGroup skillGroup)
|
||||||
|
{
|
||||||
|
EditingSkillGroupId = skillGroup.Id;
|
||||||
|
SkillGroupState.Model.Name = skillGroup.Name;
|
||||||
|
SkillGroupState.Model.DiceRollDefinition = skillGroup.DiceRollDefinition;
|
||||||
|
SkillGroupState.Model.WildDice = skillGroup.WildDice;
|
||||||
|
SkillGroupState.Model.AllowFumble = skillGroup.AllowFumble;
|
||||||
|
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 (IsD6 && SkillGroupState.Model.WildDice < 1)
|
||||||
|
SkillGroupState.Errors["wildDice"] = "D6 groups require at least one wild die.";
|
||||||
|
|
||||||
|
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));
|
||||||
|
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 (IsD6 && SkillGroupState.Model.WildDice < 1)
|
||||||
|
SkillGroupState.Errors["wildDice"] = "D6 groups require at least one wild die.";
|
||||||
|
|
||||||
|
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));
|
||||||
|
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 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 bool IsD6 { get; set; }
|
||||||
|
|
||||||
|
[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<Guid> RollRequested { 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)">@RollDieGlyph(die.Roll)</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
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 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");
|
||||||
|
|
||||||
|
return string.Join(" ", classes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string RollDieTitle(RollDieResult die)
|
||||||
|
{
|
||||||
|
var labels = new List<string> { $"Roll {die.Roll}" };
|
||||||
|
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");
|
||||||
|
|
||||||
|
return string.Join(", ", labels);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public IReadOnlyList<RollDieResult> Dice { get; set; } = [];
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public string AriaLabel { get; set; } = "Rolled dice";
|
||||||
|
}
|
||||||
54
RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor
Normal file
54
RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
@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" @bind="FormState.Model.DiceRollDefinition" @bind:event="oninput"/>
|
||||||
|
@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 (IsD6)
|
||||||
|
{
|
||||||
|
<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"/>
|
||||||
|
}
|
||||||
|
<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>
|
||||||
|
}
|
||||||
156
RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor.cs
Normal file
156
RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor.cs
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
using Microsoft.AspNetCore.Components.Web;
|
||||||
|
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.DiceRollDefinition = InitialModel.DiceRollDefinition;
|
||||||
|
FormState.Model.SkillGroupId = InitialModel.SkillGroupId;
|
||||||
|
FormState.Model.WildDice = InitialModel.WildDice;
|
||||||
|
FormState.Model.AllowFumble = InitialModel.AllowFumble;
|
||||||
|
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 (IsD6 && FormState.Model.WildDice < 1)
|
||||||
|
FormState.Errors["wildDice"] = "D6 skills require at least one wild die.";
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
await SkillSaved.InvokeAsync(skill.Id);
|
||||||
|
}
|
||||||
|
catch (ApiRequestException ex)
|
||||||
|
{
|
||||||
|
FormState.ErrorMessage = ex.Message;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsSubmitting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[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 bool IsD6 { get; set; }
|
||||||
|
|
||||||
|
[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 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.Id)">
|
||||||
|
<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<Guid> RollSkillRequested { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback<Guid> DeleteSkillRequested { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback<Guid> EditGroupRequested { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback<Guid> DeleteGroupRequested { get; set; }
|
||||||
|
}
|
||||||
209
RpgRoller/Components/Pages/Workspace.razor
Normal file
209
RpgRoller/Components/Pages/Workspace.razor
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
@using RpgRoller.Components.Pages.HomeControls
|
||||||
|
<div class="@AppCssClass">
|
||||||
|
<p class="sr-only" aria-live="polite">@LiveAnnouncement</p>
|
||||||
|
|
||||||
|
@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>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="workspace-shell">
|
||||||
|
<AppHeader
|
||||||
|
User="User"
|
||||||
|
ShowCampaign="true"
|
||||||
|
CampaignName="@SelectedCampaignName"
|
||||||
|
ShowConnectionState="true"
|
||||||
|
ConnectionStateLabel="@ConnectionStateLabel"
|
||||||
|
ConnectionStateCssClass="@ConnectionStateCssClass"
|
||||||
|
IsMenuOpen="IsScreenMenuOpen"
|
||||||
|
MenuButtonId="workspace-screen-menu-button"
|
||||||
|
MenuId="workspace-screen-menu"
|
||||||
|
MenuItems="HeaderMenuItems"
|
||||||
|
ToggleMenuRequested="ToggleScreenMenu"
|
||||||
|
LogoutRequested="LogoutAsync"/>
|
||||||
|
|
||||||
|
@if (IsPlayScreen)
|
||||||
|
{
|
||||||
|
<main class="play-screen @(MobilePanel == "log" ? "mobile-log" : "mobile-character")">
|
||||||
|
<CharacterPanel
|
||||||
|
IsCampaignDataLoading="IsCampaignDataLoading"
|
||||||
|
SelectedCampaign="PlaySelectedCampaign"
|
||||||
|
SelectedCharacterId="PlaySelectedCharacterId"
|
||||||
|
SelectedCharacter="PlaySelectedCharacter"
|
||||||
|
IsMutating="IsMutating"
|
||||||
|
SelectedCharacterSkills="PlaySelectedCharacterSkills"
|
||||||
|
SelectedCharacterSkillGroups="PlaySelectedCharacterSkillGroups"
|
||||||
|
IsD6="IsSelectedCampaignD6"
|
||||||
|
RollVisibility="RollVisibility"
|
||||||
|
RollVisibilityChanged="OnRollVisibilityChanged"
|
||||||
|
OwnerLabel="OwnerLabel"
|
||||||
|
SkillDefinitionLabel="SkillDefinitionLabel"
|
||||||
|
CanEditCharacter="CanEditCharacter"
|
||||||
|
CanEditSkill="CanEditSkill"
|
||||||
|
CharacterSelected="SelectCharacterAsync"
|
||||||
|
EditCharacterRequested="OpenEditCharacterModal"
|
||||||
|
SkillCreated="OnSkillCreatedAsync"
|
||||||
|
SkillUpdated="OnSkillUpdatedAsync"
|
||||||
|
SkillGroupCreated="OnSkillGroupCreatedAsync"
|
||||||
|
SkillGroupUpdated="OnSkillGroupUpdatedAsync"
|
||||||
|
SkillDeleted="OnSkillDeletedAsync"
|
||||||
|
SkillGroupDeleted="OnSkillGroupDeletedAsync"
|
||||||
|
ErrorOccurred="OnCharacterPanelErrorAsync"
|
||||||
|
RollRequested="RollSkillAsync"/>
|
||||||
|
|
||||||
|
<CampaignLogPanel
|
||||||
|
IsCampaignDataLoading="IsCampaignDataLoading"
|
||||||
|
CampaignLog="PlayVisibleCampaignLog"
|
||||||
|
ExpandedRollId="ExpandedCampaignLogRollId"
|
||||||
|
ToggleRollDetailRequested="ToggleRollDetailAsync"
|
||||||
|
ResolveRollDetail="ResolveRollDetail"
|
||||||
|
IsRollDetailLoading="IsRollDetailLoading"
|
||||||
|
GetRollDetailError="GetRollDetailError"/>
|
||||||
|
</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 if (IsManagementScreen)
|
||||||
|
{
|
||||||
|
<CampaignManagementPanel
|
||||||
|
Campaigns="Campaigns"
|
||||||
|
SelectedCampaignId="SelectedCampaignId"
|
||||||
|
SelectedCampaign="SelectedCampaign"
|
||||||
|
Rulesets="Rulesets"
|
||||||
|
IsMutating="IsMutating"
|
||||||
|
OwnerLabel="OwnerLabel"
|
||||||
|
CanEditCharacter="CanEditCharacter"
|
||||||
|
CanDeleteCharacter="CanDeleteCharacter"
|
||||||
|
CanDeleteCampaign="CanDeleteSelectedCampaign"
|
||||||
|
CampaignSelectionChanged="OnCampaignSelectionChangedAsync"
|
||||||
|
CampaignCreated="OnCampaignCreatedAsync"
|
||||||
|
DeleteCampaignRequested="DeleteSelectedCampaignAsync"
|
||||||
|
CreateCharacterRequested="OpenCreateCharacterModal"
|
||||||
|
EditCharacterRequested="OpenEditCharacterModal"
|
||||||
|
DeleteCharacterRequested="DeleteCharacterAsync"/>
|
||||||
|
}
|
||||||
|
else if (IsAdminScreen)
|
||||||
|
{
|
||||||
|
<main class="management-screen">
|
||||||
|
@if (IsCurrentUserAdmin)
|
||||||
|
{
|
||||||
|
<section class="card">
|
||||||
|
<div class="section-head">
|
||||||
|
<h2>Database</h2>
|
||||||
|
</div>
|
||||||
|
<p class="muted">Download the current SQLite file for backup or offline inspection.</p>
|
||||||
|
<div class="management-actions">
|
||||||
|
<a class="action-link" href="@AdminDatabaseDownloadUrl" download>Download SQLite database</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
<section class="card">
|
||||||
|
<div class="section-head">
|
||||||
|
<h2>User Management</h2>
|
||||||
|
</div>
|
||||||
|
@if (IsAdminDataLoading)
|
||||||
|
{
|
||||||
|
<p class="empty">Loading users...</p>
|
||||||
|
}
|
||||||
|
else if (!IsCurrentUserAdmin)
|
||||||
|
{
|
||||||
|
<p class="empty">Admin role is required to manage users.</p>
|
||||||
|
}
|
||||||
|
else if (AdminUsers.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="empty">No users found.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<ul class="management-list">
|
||||||
|
@foreach (var user in 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="@(IsMutating || user.Id == User?.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 == User?.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>
|
||||||
|
|
||||||
|
@if (Toasts.Count > 0)
|
||||||
|
{
|
||||||
|
<div class="toast-stack" aria-live="polite" aria-atomic="false">
|
||||||
|
@foreach (var toast in Toasts)
|
||||||
|
{
|
||||||
|
<div class="toast @(toast.IsError ? "error" : "success")" role="status">
|
||||||
|
<p>@toast.Message</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CharacterFormModal
|
||||||
|
Visible="ShowCreateCharacterModal"
|
||||||
|
Title="Create Character"
|
||||||
|
SubmitLabel="Create Character"
|
||||||
|
NameInputId="character-create-name"
|
||||||
|
CampaignInputId="character-create-campaign"
|
||||||
|
OwnerUsernameInputId="character-create-owner"
|
||||||
|
InitialModel="CreateCharacterInitialModel"
|
||||||
|
FormVersion="CreateCharacterFormVersion"
|
||||||
|
EditingCharacterId="null"
|
||||||
|
CampaignOptions="CharacterCampaignOptions"
|
||||||
|
IsMutating="IsMutating"
|
||||||
|
AllowOwnerEdit="false"
|
||||||
|
AvailableUsernames="KnownUsernames"
|
||||||
|
CharacterSaved="OnCharacterCreatedAsync"
|
||||||
|
CancelRequested="CloseCharacterModals"/>
|
||||||
|
|
||||||
|
<CharacterFormModal
|
||||||
|
Visible="ShowEditCharacterModal"
|
||||||
|
Title="Edit Character"
|
||||||
|
SubmitLabel="Save Character"
|
||||||
|
NameInputId="character-edit-name"
|
||||||
|
CampaignInputId="character-edit-campaign"
|
||||||
|
OwnerUsernameInputId="character-edit-owner"
|
||||||
|
InitialModel="EditCharacterInitialModel"
|
||||||
|
FormVersion="EditCharacterFormVersion"
|
||||||
|
EditingCharacterId="EditingCharacterId"
|
||||||
|
CampaignOptions="CharacterCampaignOptions"
|
||||||
|
IsMutating="IsMutating"
|
||||||
|
AllowOwnerEdit="CanEditCharacterOwner"
|
||||||
|
AvailableUsernames="KnownUsernames"
|
||||||
|
CharacterSaved="OnCharacterUpdatedAsync"
|
||||||
|
CancelRequested="CloseCharacterModals"/>
|
||||||
1243
RpgRoller/Components/Pages/Workspace.razor.cs
Normal file
1243
RpgRoller/Components/Pages/Workspace.razor.cs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,9 @@
|
|||||||
|
@using RpgRoller.Components.Layout
|
||||||
@attribute [ExcludeFromCodeCoverage]
|
@attribute [ExcludeFromCodeCoverage]
|
||||||
|
|
||||||
<Router AppAssembly="@typeof(Program).Assembly">
|
<Router AppAssembly="@typeof(Program).Assembly">
|
||||||
<Found Context="routeData">
|
<Found Context="routeData">
|
||||||
<RouteView RouteData="@routeData" DefaultLayout="@typeof(Layout.MainLayout)" />
|
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)"/>
|
||||||
<FocusOnNavigate RouteData="@routeData" Selector="h1"/>
|
<FocusOnNavigate RouteData="@routeData" Selector="h1"/>
|
||||||
</Found>
|
</Found>
|
||||||
</Router>
|
</Router>
|
||||||
|
|||||||
53
RpgRoller/Components/RpgRollerApiClient.cs
Normal file
53
RpgRoller/Components/RpgRollerApiClient.cs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.JSInterop;
|
||||||
|
using RpgRoller.Contracts;
|
||||||
|
|
||||||
|
namespace RpgRoller.Components;
|
||||||
|
|
||||||
|
public sealed class RpgRollerApiClient
|
||||||
|
{
|
||||||
|
private sealed class JsApiResponse
|
||||||
|
{
|
||||||
|
public bool Ok { get; set; }
|
||||||
|
public int Status { get; set; }
|
||||||
|
public string? Error { get; set; }
|
||||||
|
public JsonElement Data { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public RpgRollerApiClient(IJSRuntime js)
|
||||||
|
{
|
||||||
|
m_Js = js;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<T> RequestAsync<T>(string method, string path, object? payload = null)
|
||||||
|
{
|
||||||
|
var response = await m_Js.InvokeAsync<JsApiResponse>("rpgRollerApi.request", method, path, payload);
|
||||||
|
if (!response.Ok)
|
||||||
|
throw new ApiRequestException(response.Status, response.Error ?? "Request failed.");
|
||||||
|
|
||||||
|
if (response.Data.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null)
|
||||||
|
return default!;
|
||||||
|
|
||||||
|
return response.Data.Deserialize<T>(JsonOptions)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RequestWithoutPayloadAsync(string method, string path)
|
||||||
|
{
|
||||||
|
var response = await m_Js.InvokeAsync<JsApiResponse>("rpgRollerApi.request", method, path, null);
|
||||||
|
if (!response.Ok)
|
||||||
|
throw new ApiRequestException(response.Status, response.Error ?? "Request failed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = RpgRollerJson.CreateSerializerOptions();
|
||||||
|
private readonly IJSRuntime m_Js;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ApiRequestException : Exception
|
||||||
|
{
|
||||||
|
public ApiRequestException(int statusCode, string message) : base(message)
|
||||||
|
{
|
||||||
|
StatusCode = statusCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int StatusCode { get; }
|
||||||
|
}
|
||||||
90
RpgRoller/Components/WorkspaceQueryService.cs
Normal file
90
RpgRoller/Components/WorkspaceQueryService.cs
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
using RpgRoller.Contracts;
|
||||||
|
using RpgRoller.Services;
|
||||||
|
|
||||||
|
namespace RpgRoller.Components;
|
||||||
|
|
||||||
|
public sealed class WorkspaceQueryService
|
||||||
|
{
|
||||||
|
public WorkspaceQueryService(IGameService gameService, WorkspaceSessionTokenAccessor sessionTokenAccessor)
|
||||||
|
{
|
||||||
|
m_GameService = gameService;
|
||||||
|
m_SessionTokenAccessor = sessionTokenAccessor;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<MeResponse> GetMeAsync()
|
||||||
|
{
|
||||||
|
return Task.FromResult(GetValue(m_GameService.GetMe(GetRequiredSessionToken())));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<RulesetDefinition>> GetRulesetsAsync()
|
||||||
|
{
|
||||||
|
return Task.FromResult(m_GameService.GetRulesets());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<CampaignSummary>> GetCampaignsAsync()
|
||||||
|
{
|
||||||
|
return Task.FromResult(GetValue(m_GameService.GetCampaigns(GetRequiredSessionToken())));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<CampaignOption>> GetCharacterCampaignOptionsAsync()
|
||||||
|
{
|
||||||
|
return Task.FromResult(GetValue(m_GameService.GetCharacterCampaignOptions(GetRequiredSessionToken())));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<CampaignRoster> GetCampaignAsync(Guid campaignId)
|
||||||
|
{
|
||||||
|
return Task.FromResult(GetValue(m_GameService.GetCampaign(GetRequiredSessionToken(), campaignId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<string>> GetUsernamesAsync()
|
||||||
|
{
|
||||||
|
return Task.FromResult(GetValue(m_GameService.GetUsernames(GetRequiredSessionToken())));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<CharacterSheet> GetCharacterSheetAsync(Guid characterId)
|
||||||
|
{
|
||||||
|
return Task.FromResult(GetValue(m_GameService.GetCharacterSheet(GetRequiredSessionToken(), characterId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<CampaignLogEntry>> GetCampaignLogAsync(Guid campaignId)
|
||||||
|
{
|
||||||
|
return Task.FromResult(GetValue(m_GameService.GetCampaignLog(GetRequiredSessionToken(), campaignId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<CampaignLogPage> GetCampaignLogPageAsync(Guid campaignId, Guid? afterRollId = null, int? limit = null)
|
||||||
|
{
|
||||||
|
return Task.FromResult(GetValue(m_GameService.GetCampaignLogPage(GetRequiredSessionToken(), campaignId, afterRollId, limit)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<CampaignRollDetail> GetRollDetailAsync(Guid rollId)
|
||||||
|
{
|
||||||
|
return Task.FromResult(GetValue(m_GameService.GetRollDetail(GetRequiredSessionToken(), rollId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<AdminUserSummary>> GetAdminUsersAsync()
|
||||||
|
{
|
||||||
|
return Task.FromResult(GetValue(m_GameService.GetUsers(GetRequiredSessionToken())));
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetRequiredSessionToken()
|
||||||
|
{
|
||||||
|
return m_SessionTokenAccessor.GetRequiredSessionToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static T GetValue<T>(ServiceResult<T> result)
|
||||||
|
{
|
||||||
|
if (result.Succeeded)
|
||||||
|
return result.Value!;
|
||||||
|
|
||||||
|
throw ToApiRequestException(result.Error!);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ApiRequestException ToApiRequestException(ServiceError error)
|
||||||
|
{
|
||||||
|
var statusCode = error.Code == "unauthorized" ? 401 : 400;
|
||||||
|
return new ApiRequestException(statusCode, error.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly IGameService m_GameService;
|
||||||
|
private readonly WorkspaceSessionTokenAccessor m_SessionTokenAccessor;
|
||||||
|
}
|
||||||
35
RpgRoller/Components/WorkspaceSessionTokenAccessor.cs
Normal file
35
RpgRoller/Components/WorkspaceSessionTokenAccessor.cs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
using RpgRoller.Api;
|
||||||
|
|
||||||
|
namespace RpgRoller.Components;
|
||||||
|
|
||||||
|
public sealed class WorkspaceSessionTokenAccessor
|
||||||
|
{
|
||||||
|
public WorkspaceSessionTokenAccessor(IHttpContextAccessor httpContextAccessor)
|
||||||
|
{
|
||||||
|
var httpContext = httpContextAccessor.HttpContext;
|
||||||
|
if (httpContext is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (httpContext.Items.TryGetValue(SessionTokenItemKey, out var storedToken) &&
|
||||||
|
storedToken is string sessionToken &&
|
||||||
|
!string.IsNullOrWhiteSpace(sessionToken))
|
||||||
|
{
|
||||||
|
m_SessionToken = sessionToken;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (httpContext.TryReadSessionTokenFromCookie(out sessionToken))
|
||||||
|
m_SessionToken = sessionToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string GetRequiredSessionToken()
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(m_SessionToken))
|
||||||
|
return m_SessionToken;
|
||||||
|
|
||||||
|
throw new ApiRequestException(401, "You must be logged in.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private const string SessionTokenItemKey = "__rpgroller.session-token";
|
||||||
|
private readonly string? m_SessionToken;
|
||||||
|
}
|
||||||
@@ -1,55 +1,71 @@
|
|||||||
namespace RpgRoller.Contracts;
|
namespace RpgRoller.Contracts;
|
||||||
|
|
||||||
public sealed record HealthResponse(string Status);
|
public sealed record HealthResponse(string Status);
|
||||||
|
|
||||||
public sealed record ApiError(string Error);
|
public sealed record ApiError(string Error);
|
||||||
|
|
||||||
public sealed record RegisterRequest(string Username, string Password, string DisplayName);
|
public sealed record RegisterRequest(string Username, string Password, string DisplayName);
|
||||||
|
|
||||||
public sealed record LoginRequest(string Username, string Password);
|
public sealed record LoginRequest(string Username, string Password);
|
||||||
|
|
||||||
public sealed record UserSummary(Guid Id, string Username, string DisplayName);
|
public sealed record UserSummary(Guid Id, string Username, string DisplayName, IReadOnlyList<string> Roles);
|
||||||
|
|
||||||
public sealed record MeResponse(UserSummary User, Guid? ActiveCharacterId, Guid? CurrentCampaignId);
|
public sealed record MeResponse(UserSummary User, Guid? ActiveCharacterId, Guid? CurrentCampaignId);
|
||||||
|
|
||||||
|
public sealed record AdminUserSummary(Guid Id, string Username, string DisplayName, IReadOnlyList<string> Roles);
|
||||||
|
|
||||||
|
public sealed record UpdateUserRolesRequest(IReadOnlyList<string> Roles);
|
||||||
|
|
||||||
public sealed record RulesetDefinition(string Id, string Name, string DiceSyntax);
|
public sealed record RulesetDefinition(string Id, string Name, string DiceSyntax);
|
||||||
|
|
||||||
public sealed record CreateCampaignRequest(string Name, string RulesetId);
|
public sealed record CreateCampaignRequest(string Name, string RulesetId);
|
||||||
public sealed record CampaignSummary(Guid Id, string Name, string RulesetId, Guid GmUserId);
|
|
||||||
public sealed record CampaignDetails(
|
public sealed record CampaignGmSummary(Guid Id, string DisplayName);
|
||||||
Guid Id,
|
|
||||||
string Name,
|
public sealed record CampaignSummary(Guid Id, string Name, string RulesetId, CampaignGmSummary Gm, int CharacterCount);
|
||||||
string RulesetId,
|
|
||||||
UserSummary Gm,
|
public sealed record CampaignRoster(Guid Id, string Name, string RulesetId, CampaignGmSummary Gm, CharacterSummary[] Characters);
|
||||||
IReadOnlyList<CharacterSummary> Characters,
|
|
||||||
IReadOnlyList<SkillSummary> Skills);
|
public sealed record CampaignOption(Guid Id, string Name);
|
||||||
|
|
||||||
public sealed record CreateCharacterRequest(string Name, Guid CampaignId);
|
public sealed record CreateCharacterRequest(string Name, Guid CampaignId);
|
||||||
public sealed record UpdateCharacterRequest(string Name, Guid CampaignId);
|
|
||||||
public sealed record CharacterSummary(Guid Id, string Name, Guid OwnerUserId, Guid CampaignId);
|
|
||||||
|
|
||||||
public sealed record CreateSkillRequest(string Name, string DiceRollDefinition, int WildDice, bool AllowFumble);
|
public sealed record UpdateCharacterRequest(string Name, Guid? CampaignId, string? OwnerUsername = null);
|
||||||
public sealed record UpdateSkillRequest(string Name, string DiceRollDefinition, int WildDice, bool AllowFumble);
|
|
||||||
public sealed record SkillSummary(Guid Id, Guid CharacterId, string Name, string DiceRollDefinition, int WildDice, bool AllowFumble);
|
public sealed record CharacterSummary(Guid Id, string Name, Guid OwnerUserId, Guid? CampaignId, string OwnerDisplayName);
|
||||||
|
|
||||||
|
public sealed record CreateSkillRequest(string Name, string DiceRollDefinition, int WildDice, bool AllowFumble, Guid? SkillGroupId = null);
|
||||||
|
|
||||||
|
public sealed record UpdateSkillRequest(string Name, string DiceRollDefinition, int WildDice, bool AllowFumble, Guid? SkillGroupId = null);
|
||||||
|
|
||||||
|
public sealed record SkillGroupSummary(Guid Id, Guid CharacterId, string Name, string DiceRollDefinition, int WildDice, bool AllowFumble);
|
||||||
|
|
||||||
|
public sealed record CreateSkillGroupRequest(string Name, string DiceRollDefinition, int WildDice, bool AllowFumble);
|
||||||
|
|
||||||
|
public sealed record UpdateSkillGroupRequest(string Name, string DiceRollDefinition, int WildDice, bool AllowFumble);
|
||||||
|
|
||||||
|
public sealed record SkillSummary(Guid Id, Guid CharacterId, Guid? SkillGroupId, string Name, string DiceRollDefinition, int WildDice, bool AllowFumble);
|
||||||
|
|
||||||
public sealed record RollSkillRequest(string Visibility);
|
public sealed record RollSkillRequest(string Visibility);
|
||||||
public sealed record RollDieResult(int Roll, bool Crit, bool Fumble, bool Wild, bool Removed, bool Added);
|
|
||||||
public sealed record RollResult(
|
|
||||||
Guid RollId,
|
|
||||||
Guid CampaignId,
|
|
||||||
Guid CharacterId,
|
|
||||||
Guid SkillId,
|
|
||||||
Guid RollerUserId,
|
|
||||||
string Visibility,
|
|
||||||
int Result,
|
|
||||||
string Breakdown,
|
|
||||||
IReadOnlyList<RollDieResult> Dice,
|
|
||||||
DateTimeOffset TimestampUtc);
|
|
||||||
|
|
||||||
public sealed record CampaignLogEntry(
|
public sealed record RollDieResult(int Roll, bool Crit, bool Fumble, bool Wild, bool Removed, bool Added);
|
||||||
Guid RollId,
|
|
||||||
Guid CampaignId,
|
public sealed record RollResult(Guid RollId, Guid CampaignId, Guid CharacterId, Guid SkillId, Guid RollerUserId, string Visibility, int Result, string Breakdown, IReadOnlyList<RollDieResult> Dice, DateTimeOffset TimestampUtc);
|
||||||
Guid CharacterId,
|
|
||||||
Guid SkillId,
|
public sealed record CharacterSheetSkillGroup(Guid Id, string Name, string DiceRollDefinition, int WildDice, bool AllowFumble);
|
||||||
Guid RollerUserId,
|
|
||||||
string Visibility,
|
public sealed record CharacterSheetSkill(Guid Id, Guid? SkillGroupId, string Name, string DiceRollDefinition, int WildDice, bool AllowFumble);
|
||||||
int Result,
|
|
||||||
string Breakdown,
|
public sealed record CharacterSheet(Guid CharacterId, CharacterSheetSkillGroup[] SkillGroups, CharacterSheetSkill[] Skills);
|
||||||
DateTimeOffset TimestampUtc);
|
|
||||||
|
public sealed record CampaignLogEntry(Guid RollId, Guid CampaignId, Guid CharacterId, string CharacterName, Guid SkillId, string SkillName, Guid RollerUserId, string RollerDisplayName, string Visibility, int Result, string Breakdown, IReadOnlyList<RollDieResult> Dice, DateTimeOffset TimestampUtc);
|
||||||
|
|
||||||
|
public sealed record CampaignLogListEntry(Guid RollId, string CharacterName, string SkillName, string RollerLabel, string VisibilityLabel, string VisibilityStyle, int Result, string SummaryText, DateTimeOffset TimestampUtc);
|
||||||
|
|
||||||
|
public sealed record CampaignRollDetail(Guid RollId, string Breakdown, RollDieResult[] Dice);
|
||||||
|
|
||||||
|
public sealed record CharacterStateVersion(Guid CharacterId, long Version);
|
||||||
|
|
||||||
|
public sealed record CampaignStateSnapshot(Guid CampaignId, long TotalVersion, long RosterVersion, long LogVersion, IReadOnlyList<CharacterStateVersion> CharacterVersions);
|
||||||
|
|
||||||
|
public sealed record CampaignLogPage(CampaignLogListEntry[] Entries, Guid? Cursor, bool HasMore, bool ResetRequired);
|
||||||
|
|||||||
19
RpgRoller/Contracts/RpgRollerJson.cs
Normal file
19
RpgRoller/Contracts/RpgRollerJson.cs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace RpgRoller.Contracts;
|
||||||
|
|
||||||
|
public static class RpgRollerJson
|
||||||
|
{
|
||||||
|
public static JsonSerializerOptions CreateSerializerOptions()
|
||||||
|
{
|
||||||
|
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);
|
||||||
|
Configure(options);
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Configure(JsonSerializerOptions options)
|
||||||
|
{
|
||||||
|
if (!options.TypeInfoResolverChain.Contains(RpgRollerJsonSerializerContext.Default))
|
||||||
|
options.TypeInfoResolverChain.Insert(0, RpgRollerJsonSerializerContext.Default);
|
||||||
|
}
|
||||||
|
}
|
||||||
58
RpgRoller/Contracts/RpgRollerJsonSerializerContext.cs
Normal file
58
RpgRoller/Contracts/RpgRollerJsonSerializerContext.cs
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace RpgRoller.Contracts;
|
||||||
|
|
||||||
|
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web)]
|
||||||
|
[JsonSerializable(typeof(ApiError))]
|
||||||
|
[JsonSerializable(typeof(AdminUserSummary))]
|
||||||
|
[JsonSerializable(typeof(AdminUserSummary[]))]
|
||||||
|
[JsonSerializable(typeof(CampaignGmSummary))]
|
||||||
|
[JsonSerializable(typeof(CampaignLogEntry))]
|
||||||
|
[JsonSerializable(typeof(CampaignLogEntry[]))]
|
||||||
|
[JsonSerializable(typeof(CampaignLogListEntry))]
|
||||||
|
[JsonSerializable(typeof(CampaignLogListEntry[]))]
|
||||||
|
[JsonSerializable(typeof(CampaignLogPage))]
|
||||||
|
[JsonSerializable(typeof(CampaignOption))]
|
||||||
|
[JsonSerializable(typeof(CampaignOption[]))]
|
||||||
|
[JsonSerializable(typeof(CampaignRollDetail))]
|
||||||
|
[JsonSerializable(typeof(CampaignRoster))]
|
||||||
|
[JsonSerializable(typeof(CampaignStateSnapshot))]
|
||||||
|
[JsonSerializable(typeof(CampaignSummary))]
|
||||||
|
[JsonSerializable(typeof(CampaignSummary[]))]
|
||||||
|
[JsonSerializable(typeof(CharacterSheet))]
|
||||||
|
[JsonSerializable(typeof(CharacterSheetSkill))]
|
||||||
|
[JsonSerializable(typeof(CharacterSheetSkillGroup))]
|
||||||
|
[JsonSerializable(typeof(CharacterStateVersion))]
|
||||||
|
[JsonSerializable(typeof(CharacterStateVersion[]))]
|
||||||
|
[JsonSerializable(typeof(CharacterSummary))]
|
||||||
|
[JsonSerializable(typeof(CharacterSummary[]))]
|
||||||
|
[JsonSerializable(typeof(CreateCampaignRequest))]
|
||||||
|
[JsonSerializable(typeof(CreateCharacterRequest))]
|
||||||
|
[JsonSerializable(typeof(CreateSkillGroupRequest))]
|
||||||
|
[JsonSerializable(typeof(CreateSkillRequest))]
|
||||||
|
[JsonSerializable(typeof(HealthResponse))]
|
||||||
|
[JsonSerializable(typeof(IReadOnlyList<AdminUserSummary>))]
|
||||||
|
[JsonSerializable(typeof(IReadOnlyList<CharacterStateVersion>))]
|
||||||
|
[JsonSerializable(typeof(IReadOnlyList<RollDieResult>))]
|
||||||
|
[JsonSerializable(typeof(IReadOnlyList<string>))]
|
||||||
|
[JsonSerializable(typeof(LoginRequest))]
|
||||||
|
[JsonSerializable(typeof(MeResponse))]
|
||||||
|
[JsonSerializable(typeof(RegisterRequest))]
|
||||||
|
[JsonSerializable(typeof(RollDieResult))]
|
||||||
|
[JsonSerializable(typeof(RollDieResult[]))]
|
||||||
|
[JsonSerializable(typeof(RollResult))]
|
||||||
|
[JsonSerializable(typeof(RollSkillRequest))]
|
||||||
|
[JsonSerializable(typeof(RulesetDefinition))]
|
||||||
|
[JsonSerializable(typeof(RulesetDefinition[]))]
|
||||||
|
[JsonSerializable(typeof(SkillGroupSummary))]
|
||||||
|
[JsonSerializable(typeof(SkillSummary))]
|
||||||
|
[JsonSerializable(typeof(string[]))]
|
||||||
|
[JsonSerializable(typeof(UpdateCharacterRequest))]
|
||||||
|
[JsonSerializable(typeof(UpdateSkillGroupRequest))]
|
||||||
|
[JsonSerializable(typeof(UpdateSkillRequest))]
|
||||||
|
[JsonSerializable(typeof(UpdateUserRolesRequest))]
|
||||||
|
[JsonSerializable(typeof(UserSummary))]
|
||||||
|
public partial class RpgRollerJsonSerializerContext : JsonSerializerContext
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -5,18 +5,10 @@ namespace RpgRoller.Data;
|
|||||||
|
|
||||||
public sealed class RpgRollerDbContext : DbContext
|
public sealed class RpgRollerDbContext : DbContext
|
||||||
{
|
{
|
||||||
public RpgRollerDbContext(DbContextOptions<RpgRollerDbContext> options)
|
public RpgRollerDbContext(DbContextOptions<RpgRollerDbContext> options) : base(options)
|
||||||
: base(options)
|
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
public DbSet<UserAccount> Users => Set<UserAccount>();
|
|
||||||
public DbSet<UserSession> Sessions => Set<UserSession>();
|
|
||||||
public DbSet<Campaign> Campaigns => Set<Campaign>();
|
|
||||||
public DbSet<Character> Characters => Set<Character>();
|
|
||||||
public DbSet<Skill> Skills => Set<Skill>();
|
|
||||||
public DbSet<RollLogEntry> RollLogEntries => Set<RollLogEntry>();
|
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
modelBuilder.Entity<UserAccount>(entity =>
|
modelBuilder.Entity<UserAccount>(entity =>
|
||||||
@@ -26,6 +18,7 @@ public sealed class RpgRollerDbContext : DbContext
|
|||||||
entity.Property(x => x.UsernameNormalized).IsRequired().HasMaxLength(64);
|
entity.Property(x => x.UsernameNormalized).IsRequired().HasMaxLength(64);
|
||||||
entity.Property(x => x.PasswordHash).IsRequired();
|
entity.Property(x => x.PasswordHash).IsRequired();
|
||||||
entity.Property(x => x.DisplayName).IsRequired().HasMaxLength(128);
|
entity.Property(x => x.DisplayName).IsRequired().HasMaxLength(128);
|
||||||
|
entity.Property(x => x.Roles).IsRequired().HasMaxLength(256);
|
||||||
entity.HasIndex(x => x.UsernameNormalized).IsUnique();
|
entity.HasIndex(x => x.UsernameNormalized).IsUnique();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -55,6 +48,17 @@ public sealed class RpgRollerDbContext : DbContext
|
|||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<Skill>(entity =>
|
modelBuilder.Entity<Skill>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(x => x.Id);
|
||||||
|
entity.Property(x => x.Name).IsRequired().HasMaxLength(128);
|
||||||
|
entity.Property(x => x.DiceRollDefinition).IsRequired().HasMaxLength(128);
|
||||||
|
entity.Property(x => x.WildDice).IsRequired();
|
||||||
|
entity.Property(x => x.AllowFumble).IsRequired();
|
||||||
|
entity.HasIndex(x => x.CharacterId);
|
||||||
|
entity.HasIndex(x => x.SkillGroupId);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<SkillGroup>(entity =>
|
||||||
{
|
{
|
||||||
entity.HasKey(x => x.Id);
|
entity.HasKey(x => x.Id);
|
||||||
entity.Property(x => x.Name).IsRequired().HasMaxLength(128);
|
entity.Property(x => x.Name).IsRequired().HasMaxLength(128);
|
||||||
@@ -69,6 +73,7 @@ public sealed class RpgRollerDbContext : DbContext
|
|||||||
entity.HasKey(x => x.Id);
|
entity.HasKey(x => x.Id);
|
||||||
entity.Property(x => x.Visibility).HasConversion<string>().IsRequired();
|
entity.Property(x => x.Visibility).HasConversion<string>().IsRequired();
|
||||||
entity.Property(x => x.Breakdown).IsRequired().HasMaxLength(256);
|
entity.Property(x => x.Breakdown).IsRequired().HasMaxLength(256);
|
||||||
|
entity.Property(x => x.Dice).IsRequired();
|
||||||
entity.Property(x => x.TimestampUtc).IsRequired();
|
entity.Property(x => x.TimestampUtc).IsRequired();
|
||||||
entity.HasIndex(x => x.CampaignId);
|
entity.HasIndex(x => x.CampaignId);
|
||||||
entity.HasIndex(x => x.RollerUserId);
|
entity.HasIndex(x => x.RollerUserId);
|
||||||
@@ -76,4 +81,12 @@ public sealed class RpgRollerDbContext : DbContext
|
|||||||
entity.HasIndex(x => x.CharacterId);
|
entity.HasIndex(x => x.CharacterId);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public DbSet<UserAccount> Users => Set<UserAccount>();
|
||||||
|
public DbSet<UserSession> Sessions => Set<UserSession>();
|
||||||
|
public DbSet<Campaign> Campaigns => Set<Campaign>();
|
||||||
|
public DbSet<Character> Characters => Set<Character>();
|
||||||
|
public DbSet<Skill> Skills => Set<Skill>();
|
||||||
|
public DbSet<SkillGroup> SkillGroups => Set<SkillGroup>();
|
||||||
|
public DbSet<RollLogEntry> RollLogEntries => Set<RollLogEntry>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,9 +19,15 @@ public sealed class UserAccount
|
|||||||
public required string UsernameNormalized { get; init; }
|
public required string UsernameNormalized { get; init; }
|
||||||
public required string PasswordHash { get; set; }
|
public required string PasswordHash { get; set; }
|
||||||
public required string DisplayName { get; set; }
|
public required string DisplayName { get; set; }
|
||||||
|
public required string Roles { get; set; }
|
||||||
public Guid? ActiveCharacterId { get; set; }
|
public Guid? ActiveCharacterId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static class UserRoles
|
||||||
|
{
|
||||||
|
public const string Admin = "admin";
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class UserSession
|
public sealed class UserSession
|
||||||
{
|
{
|
||||||
public required string Token { get; init; }
|
public required string Token { get; init; }
|
||||||
@@ -41,15 +47,26 @@ public sealed class Campaign
|
|||||||
public sealed class Character
|
public sealed class Character
|
||||||
{
|
{
|
||||||
public required Guid Id { get; init; }
|
public required Guid Id { get; init; }
|
||||||
public required Guid OwnerUserId { get; init; }
|
public required Guid OwnerUserId { get; set; }
|
||||||
public required Guid CampaignId { get; set; }
|
public Guid? CampaignId { get; set; }
|
||||||
public required string Name { get; set; }
|
public required string Name { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class SkillGroup
|
||||||
|
{
|
||||||
|
public required Guid Id { get; init; }
|
||||||
|
public required Guid CharacterId { get; set; }
|
||||||
|
public required string Name { get; set; }
|
||||||
|
public required string DiceRollDefinition { get; set; }
|
||||||
|
public required int WildDice { get; set; }
|
||||||
|
public required bool AllowFumble { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class Skill
|
public sealed class Skill
|
||||||
{
|
{
|
||||||
public required Guid Id { get; init; }
|
public required Guid Id { get; init; }
|
||||||
public required Guid CharacterId { get; set; }
|
public required Guid CharacterId { get; set; }
|
||||||
|
public Guid? SkillGroupId { get; set; }
|
||||||
public required string Name { get; set; }
|
public required string Name { get; set; }
|
||||||
public required string DiceRollDefinition { get; set; }
|
public required string DiceRollDefinition { get; set; }
|
||||||
public required int WildDice { get; set; }
|
public required int WildDice { get; set; }
|
||||||
@@ -66,6 +83,7 @@ public sealed class RollLogEntry
|
|||||||
public required RollVisibility Visibility { get; init; }
|
public required RollVisibility Visibility { get; init; }
|
||||||
public required int Result { get; init; }
|
public required int Result { get; init; }
|
||||||
public required string Breakdown { get; init; }
|
public required string Breakdown { get; init; }
|
||||||
|
public required string Dice { get; init; }
|
||||||
public required DateTimeOffset TimestampUtc { get; init; }
|
public required DateTimeOffset TimestampUtc { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ public static class ApplicationInitializationExtensions
|
|||||||
using var scope = app.Services.CreateScope();
|
using var scope = app.Services.CreateScope();
|
||||||
var dbFactory = scope.ServiceProvider.GetRequiredService<IDbContextFactory<RpgRollerDbContext>>();
|
var dbFactory = scope.ServiceProvider.GetRequiredService<IDbContextFactory<RpgRollerDbContext>>();
|
||||||
using var db = dbFactory.CreateDbContext();
|
using var db = dbFactory.CreateDbContext();
|
||||||
db.Database.EnsureCreated();
|
SqliteSchemaUpgrader.ApplyPendingChanges(db);
|
||||||
_ = scope.ServiceProvider.GetRequiredService<IGameService>();
|
_ = scope.ServiceProvider.GetRequiredService<IGameService>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -9,15 +9,14 @@ namespace RpgRoller.Hosting;
|
|||||||
|
|
||||||
public static class ServiceCollectionExtensions
|
public static class ServiceCollectionExtensions
|
||||||
{
|
{
|
||||||
public static IServiceCollection AddRpgRollerCore(
|
public static IServiceCollection AddRpgRollerCore(this IServiceCollection services, IConfiguration configuration, IWebHostEnvironment environment)
|
||||||
this IServiceCollection services,
|
|
||||||
IConfiguration configuration,
|
|
||||||
IWebHostEnvironment environment)
|
|
||||||
{
|
{
|
||||||
var sqliteConnectionString = configuration.GetConnectionString("RpgRoller") ?? "Data Source=App_Data/rpgroller.db";
|
var sqliteConnectionString = configuration.GetConnectionString("RpgRoller") ?? "Data Source=App_Data/rpgroller.db";
|
||||||
EnsureSqliteDataDirectory(sqliteConnectionString, environment.ContentRootPath);
|
var sqliteDatabasePath = ResolveSqliteDatabasePath(sqliteConnectionString, environment.ContentRootPath);
|
||||||
|
EnsureSqliteDataDirectory(sqliteDatabasePath);
|
||||||
|
|
||||||
services.AddSingleton<IPasswordHasher<UserAccount>, PasswordHasher<UserAccount>>();
|
services.AddSingleton<IPasswordHasher<UserAccount>, PasswordHasher<UserAccount>>();
|
||||||
|
services.AddSingleton(new SqliteDatabaseFile(sqliteDatabasePath));
|
||||||
services.AddDbContextFactory<RpgRollerDbContext>(options => options.UseSqlite(sqliteConnectionString));
|
services.AddDbContextFactory<RpgRollerDbContext>(options => options.UseSqlite(sqliteConnectionString));
|
||||||
services.AddSingleton<IDiceRoller, RandomDiceRoller>();
|
services.AddSingleton<IDiceRoller, RandomDiceRoller>();
|
||||||
services.AddSingleton<IGameService, GameService>();
|
services.AddSingleton<IGameService, GameService>();
|
||||||
@@ -25,22 +24,20 @@ public static class ServiceCollectionExtensions
|
|||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void EnsureSqliteDataDirectory(string connectionString, string contentRootPath)
|
private static string? ResolveSqliteDatabasePath(string connectionString, string contentRootPath)
|
||||||
{
|
{
|
||||||
var builder = new SqliteConnectionStringBuilder(connectionString);
|
var builder = new SqliteConnectionStringBuilder(connectionString);
|
||||||
if (string.IsNullOrWhiteSpace(builder.DataSource) || builder.DataSource == ":memory:")
|
if (string.IsNullOrWhiteSpace(builder.DataSource) || builder.DataSource == ":memory:")
|
||||||
{
|
return null;
|
||||||
return;
|
|
||||||
|
var databasePath = Path.IsPathRooted(builder.DataSource) ? builder.DataSource : Path.Combine(contentRootPath, builder.DataSource);
|
||||||
|
return Path.GetFullPath(databasePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
var fullPath = Path.IsPathRooted(builder.DataSource)
|
private static void EnsureSqliteDataDirectory(string? sqliteDatabasePath)
|
||||||
? builder.DataSource
|
|
||||||
: Path.Combine(contentRootPath, builder.DataSource);
|
|
||||||
|
|
||||||
var directory = Path.GetDirectoryName(fullPath);
|
|
||||||
if (!string.IsNullOrWhiteSpace(directory))
|
|
||||||
{
|
{
|
||||||
|
var directory = Path.GetDirectoryName(sqliteDatabasePath);
|
||||||
|
if (!string.IsNullOrWhiteSpace(directory))
|
||||||
Directory.CreateDirectory(directory);
|
Directory.CreateDirectory(directory);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|||||||
3
RpgRoller/Hosting/SqliteDatabaseFile.cs
Normal file
3
RpgRoller/Hosting/SqliteDatabaseFile.cs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
namespace RpgRoller.Hosting;
|
||||||
|
|
||||||
|
public sealed record SqliteDatabaseFile(string? Path);
|
||||||
92
RpgRoller/Hosting/SqliteSchemaUpgrader.cs
Normal file
92
RpgRoller/Hosting/SqliteSchemaUpgrader.cs
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using RpgRoller.Data;
|
||||||
|
|
||||||
|
namespace RpgRoller.Hosting;
|
||||||
|
|
||||||
|
public static class SqliteSchemaUpgrader
|
||||||
|
{
|
||||||
|
public static void ApplyPendingChanges(RpgRollerDbContext db)
|
||||||
|
{
|
||||||
|
if (db.Database.IsSqlite())
|
||||||
|
EnsureLegacySchemaHistory(db);
|
||||||
|
|
||||||
|
db.Database.Migrate();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void EnsureLegacySchemaHistory(RpgRollerDbContext db)
|
||||||
|
{
|
||||||
|
db.Database.OpenConnection();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (TableExists(db, "__EFMigrationsHistory"))
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (!TableExists(db, "Skills"))
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (!ColumnExists(db, "Skills", "WildDice") || !ColumnExists(db, "Skills", "AllowFumble"))
|
||||||
|
return;
|
||||||
|
|
||||||
|
using var createHistoryCommand = db.Database.GetDbConnection().CreateCommand();
|
||||||
|
createHistoryCommand.CommandText = """
|
||||||
|
CREATE TABLE IF NOT EXISTS "__EFMigrationsHistory" (
|
||||||
|
"MigrationId" TEXT NOT NULL CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY,
|
||||||
|
"ProductVersion" TEXT NOT NULL
|
||||||
|
);
|
||||||
|
""";
|
||||||
|
_ = createHistoryCommand.ExecuteNonQuery();
|
||||||
|
|
||||||
|
using var insertHistoryCommand = db.Database.GetDbConnection().CreateCommand();
|
||||||
|
insertHistoryCommand.CommandText = """
|
||||||
|
INSERT OR IGNORE INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
|
||||||
|
VALUES ($migrationId, $productVersion);
|
||||||
|
""";
|
||||||
|
|
||||||
|
var migrationParameter = insertHistoryCommand.CreateParameter();
|
||||||
|
migrationParameter.ParameterName = "$migrationId";
|
||||||
|
migrationParameter.Value = InitialMigrationId;
|
||||||
|
insertHistoryCommand.Parameters.Add(migrationParameter);
|
||||||
|
|
||||||
|
var productVersionParameter = insertHistoryCommand.CreateParameter();
|
||||||
|
productVersionParameter.ParameterName = "$productVersion";
|
||||||
|
productVersionParameter.Value = ProductVersion;
|
||||||
|
insertHistoryCommand.Parameters.Add(productVersionParameter);
|
||||||
|
|
||||||
|
_ = insertHistoryCommand.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
db.Database.CloseConnection();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TableExists(RpgRollerDbContext db, string tableName)
|
||||||
|
{
|
||||||
|
using var command = db.Database.GetDbConnection().CreateCommand();
|
||||||
|
command.CommandText = "SELECT 1 FROM sqlite_master WHERE type='table' AND name=$name LIMIT 1;";
|
||||||
|
var parameter = command.CreateParameter();
|
||||||
|
parameter.ParameterName = "$name";
|
||||||
|
parameter.Value = tableName;
|
||||||
|
command.Parameters.Add(parameter);
|
||||||
|
|
||||||
|
return command.ExecuteScalar() is not null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ColumnExists(RpgRollerDbContext db, string tableName, string columnName)
|
||||||
|
{
|
||||||
|
using var command = db.Database.GetDbConnection().CreateCommand();
|
||||||
|
command.CommandText = $"PRAGMA table_info('{tableName}');";
|
||||||
|
using var reader = command.ExecuteReader();
|
||||||
|
while (reader.Read())
|
||||||
|
{
|
||||||
|
var currentColumnName = reader.GetString(1);
|
||||||
|
if (string.Equals(currentColumnName, columnName, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private const string InitialMigrationId = "20260226084000_InitialSchema";
|
||||||
|
private const string ProductVersion = "10.0.2";
|
||||||
|
}
|
||||||
212
RpgRoller/Migrations/20260226075224_20260226090000_ModelSync.Designer.cs
generated
Normal file
212
RpgRoller/Migrations/20260226075224_20260226090000_ModelSync.Designer.cs
generated
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using RpgRoller.Data;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace RpgRoller.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(RpgRollerDbContext))]
|
||||||
|
[Migration("20260226090000_ModelSync")]
|
||||||
|
partial class _20260226090000_ModelSync
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "10.0.2");
|
||||||
|
|
||||||
|
modelBuilder.Entity("RpgRoller.Domain.Campaign", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("GmUserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Ruleset")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("Version")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("GmUserId");
|
||||||
|
|
||||||
|
b.ToTable("Campaigns");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("RpgRoller.Domain.Character", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("CampaignId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("OwnerUserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CampaignId");
|
||||||
|
|
||||||
|
b.HasIndex("OwnerUserId");
|
||||||
|
|
||||||
|
b.ToTable("Characters");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("RpgRoller.Domain.RollLogEntry", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Breakdown")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("CampaignId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("CharacterId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("Result")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid>("RollerUserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("SkillId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("TimestampUtc")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Visibility")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CampaignId");
|
||||||
|
|
||||||
|
b.HasIndex("CharacterId");
|
||||||
|
|
||||||
|
b.HasIndex("RollerUserId");
|
||||||
|
|
||||||
|
b.HasIndex("SkillId");
|
||||||
|
|
||||||
|
b.ToTable("RollLogEntries");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("RpgRoller.Domain.Skill", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("AllowFumble")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid>("CharacterId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("DiceRollDefinition")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("WildDice")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CharacterId");
|
||||||
|
|
||||||
|
b.ToTable("Skills");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("RpgRoller.Domain.UserAccount", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ActiveCharacterId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Username")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("UsernameNormalized")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("UsernameNormalized")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Users");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("RpgRoller.Domain.UserSession", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Token")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Token");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("Sessions");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace RpgRoller.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class _20260226090000_ModelSync : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
// No-op migration generated to sync EF snapshot to existing schema.
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
// Intentionally empty.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
111
RpgRoller/Migrations/20260226084000_InitialSchema.cs
Normal file
111
RpgRoller/Migrations/20260226084000_InitialSchema.cs
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using RpgRoller.Data;
|
||||||
|
|
||||||
|
namespace RpgRoller.Migrations;
|
||||||
|
|
||||||
|
[DbContext(typeof(RpgRollerDbContext))]
|
||||||
|
[Migration("20260226084000_InitialSchema")]
|
||||||
|
public sealed class InitialSchema : Migration
|
||||||
|
{
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.Sql(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS "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
|
||||||
|
);
|
||||||
|
""");
|
||||||
|
|
||||||
|
migrationBuilder.Sql(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS "Sessions" (
|
||||||
|
"Token" TEXT NOT NULL CONSTRAINT "PK_Sessions" PRIMARY KEY,
|
||||||
|
"UserId" TEXT NOT NULL,
|
||||||
|
"CreatedAtUtc" TEXT NOT NULL
|
||||||
|
);
|
||||||
|
""");
|
||||||
|
|
||||||
|
migrationBuilder.Sql(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS "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
|
||||||
|
);
|
||||||
|
""");
|
||||||
|
|
||||||
|
migrationBuilder.Sql(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS "Characters" (
|
||||||
|
"Id" TEXT NOT NULL CONSTRAINT "PK_Characters" PRIMARY KEY,
|
||||||
|
"OwnerUserId" TEXT NOT NULL,
|
||||||
|
"CampaignId" TEXT NOT NULL,
|
||||||
|
"Name" TEXT NOT NULL
|
||||||
|
);
|
||||||
|
""");
|
||||||
|
|
||||||
|
migrationBuilder.Sql(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS "Skills" (
|
||||||
|
"Id" TEXT NOT NULL CONSTRAINT "PK_Skills" PRIMARY KEY,
|
||||||
|
"CharacterId" TEXT NOT NULL,
|
||||||
|
"Name" TEXT NOT NULL,
|
||||||
|
"DiceRollDefinition" TEXT NOT NULL
|
||||||
|
);
|
||||||
|
""");
|
||||||
|
|
||||||
|
migrationBuilder.Sql(
|
||||||
|
"""
|
||||||
|
ALTER TABLE "Skills" ADD COLUMN "WildDice" INTEGER NOT NULL DEFAULT 1;
|
||||||
|
""");
|
||||||
|
|
||||||
|
migrationBuilder.Sql(
|
||||||
|
"""
|
||||||
|
ALTER TABLE "Skills" ADD COLUMN "AllowFumble" INTEGER NOT NULL DEFAULT 1;
|
||||||
|
""");
|
||||||
|
|
||||||
|
migrationBuilder.Sql(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS "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
|
||||||
|
);
|
||||||
|
""");
|
||||||
|
|
||||||
|
migrationBuilder.Sql("""CREATE UNIQUE INDEX IF NOT EXISTS "IX_Users_UsernameNormalized" ON "Users" ("UsernameNormalized");""");
|
||||||
|
migrationBuilder.Sql("""CREATE INDEX IF NOT EXISTS "IX_Sessions_UserId" ON "Sessions" ("UserId");""");
|
||||||
|
migrationBuilder.Sql("""CREATE INDEX IF NOT EXISTS "IX_Campaigns_GmUserId" ON "Campaigns" ("GmUserId");""");
|
||||||
|
migrationBuilder.Sql("""CREATE INDEX IF NOT EXISTS "IX_Characters_OwnerUserId" ON "Characters" ("OwnerUserId");""");
|
||||||
|
migrationBuilder.Sql("""CREATE INDEX IF NOT EXISTS "IX_Characters_CampaignId" ON "Characters" ("CampaignId");""");
|
||||||
|
migrationBuilder.Sql("""CREATE INDEX IF NOT EXISTS "IX_Skills_CharacterId" ON "Skills" ("CharacterId");""");
|
||||||
|
migrationBuilder.Sql("""CREATE INDEX IF NOT EXISTS "IX_RollLogEntries_CampaignId" ON "RollLogEntries" ("CampaignId");""");
|
||||||
|
migrationBuilder.Sql("""CREATE INDEX IF NOT EXISTS "IX_RollLogEntries_RollerUserId" ON "RollLogEntries" ("RollerUserId");""");
|
||||||
|
migrationBuilder.Sql("""CREATE INDEX IF NOT EXISTS "IX_RollLogEntries_SkillId" ON "RollLogEntries" ("SkillId");""");
|
||||||
|
migrationBuilder.Sql("""CREATE INDEX IF NOT EXISTS "IX_RollLogEntries_CharacterId" ON "RollLogEntries" ("CharacterId");""");
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.Sql("""DROP TABLE IF EXISTS "RollLogEntries";""");
|
||||||
|
migrationBuilder.Sql("""DROP TABLE IF EXISTS "Skills";""");
|
||||||
|
migrationBuilder.Sql("""DROP TABLE IF EXISTS "Characters";""");
|
||||||
|
migrationBuilder.Sql("""DROP TABLE IF EXISTS "Campaigns";""");
|
||||||
|
migrationBuilder.Sql("""DROP TABLE IF EXISTS "Sessions";""");
|
||||||
|
migrationBuilder.Sql("""DROP TABLE IF EXISTS "Users";""");
|
||||||
|
}
|
||||||
|
}
|
||||||
216
RpgRoller/Migrations/20260226100000_AddRollLogDice.Designer.cs
generated
Normal file
216
RpgRoller/Migrations/20260226100000_AddRollLogDice.Designer.cs
generated
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using RpgRoller.Data;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace RpgRoller.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(RpgRollerDbContext))]
|
||||||
|
[Migration("20260226100000_AddRollLogDice")]
|
||||||
|
partial class AddRollLogDice
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "10.0.2");
|
||||||
|
|
||||||
|
modelBuilder.Entity("RpgRoller.Domain.Campaign", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("GmUserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Ruleset")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("Version")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("GmUserId");
|
||||||
|
|
||||||
|
b.ToTable("Campaigns");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("RpgRoller.Domain.Character", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("CampaignId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("OwnerUserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CampaignId");
|
||||||
|
|
||||||
|
b.HasIndex("OwnerUserId");
|
||||||
|
|
||||||
|
b.ToTable("Characters");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("RpgRoller.Domain.RollLogEntry", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Breakdown")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("CampaignId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("CharacterId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Dice")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("Result")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid>("RollerUserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("SkillId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("TimestampUtc")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Visibility")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CampaignId");
|
||||||
|
|
||||||
|
b.HasIndex("CharacterId");
|
||||||
|
|
||||||
|
b.HasIndex("RollerUserId");
|
||||||
|
|
||||||
|
b.HasIndex("SkillId");
|
||||||
|
|
||||||
|
b.ToTable("RollLogEntries");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("RpgRoller.Domain.Skill", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("AllowFumble")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid>("CharacterId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("DiceRollDefinition")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("WildDice")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CharacterId");
|
||||||
|
|
||||||
|
b.ToTable("Skills");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("RpgRoller.Domain.UserAccount", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ActiveCharacterId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Username")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("UsernameNormalized")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("UsernameNormalized")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Users");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("RpgRoller.Domain.UserSession", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Token")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Token");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("Sessions");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
29
RpgRoller/Migrations/20260226100000_AddRollLogDice.cs
Normal file
29
RpgRoller/Migrations/20260226100000_AddRollLogDice.cs
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace RpgRoller.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddRollLogDice : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Dice",
|
||||||
|
table: "RollLogEntries",
|
||||||
|
type: "TEXT",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Dice",
|
||||||
|
table: "RollLogEntries");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
242
RpgRoller/Migrations/20260226124941_AddSkillGroupsAndCharacterOwnerTransfer.Designer.cs
generated
Normal file
242
RpgRoller/Migrations/20260226124941_AddSkillGroupsAndCharacterOwnerTransfer.Designer.cs
generated
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using RpgRoller.Data;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace RpgRoller.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(RpgRollerDbContext))]
|
||||||
|
[Migration("20260226124941_AddSkillGroupsAndCharacterOwnerTransfer")]
|
||||||
|
partial class AddSkillGroupsAndCharacterOwnerTransfer
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "10.0.2");
|
||||||
|
|
||||||
|
modelBuilder.Entity("RpgRoller.Domain.Campaign", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("GmUserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Ruleset")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("Version")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("GmUserId");
|
||||||
|
|
||||||
|
b.ToTable("Campaigns");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("RpgRoller.Domain.Character", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("CampaignId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("OwnerUserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CampaignId");
|
||||||
|
|
||||||
|
b.HasIndex("OwnerUserId");
|
||||||
|
|
||||||
|
b.ToTable("Characters");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("RpgRoller.Domain.RollLogEntry", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Breakdown")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("CampaignId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("CharacterId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Dice")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("Result")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid>("RollerUserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("SkillId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("TimestampUtc")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Visibility")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CampaignId");
|
||||||
|
|
||||||
|
b.HasIndex("CharacterId");
|
||||||
|
|
||||||
|
b.HasIndex("RollerUserId");
|
||||||
|
|
||||||
|
b.HasIndex("SkillId");
|
||||||
|
|
||||||
|
b.ToTable("RollLogEntries");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("RpgRoller.Domain.Skill", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("AllowFumble")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid>("CharacterId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("DiceRollDefinition")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("SkillGroupId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("WildDice")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CharacterId");
|
||||||
|
|
||||||
|
b.HasIndex("SkillGroupId");
|
||||||
|
|
||||||
|
b.ToTable("Skills");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("RpgRoller.Domain.SkillGroup", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("CharacterId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CharacterId");
|
||||||
|
|
||||||
|
b.ToTable("SkillGroups");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("RpgRoller.Domain.UserAccount", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ActiveCharacterId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Username")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("UsernameNormalized")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("UsernameNormalized")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Users");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("RpgRoller.Domain.UserSession", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Token")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Token");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("Sessions");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace RpgRoller.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddSkillGroupsAndCharacterOwnerTransfer : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "SkillGroupId",
|
||||||
|
table: "Skills",
|
||||||
|
type: "TEXT",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "SkillGroups",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||||
|
CharacterId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||||
|
Name = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_SkillGroups", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Skills_SkillGroupId",
|
||||||
|
table: "Skills",
|
||||||
|
column: "SkillGroupId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_SkillGroups_CharacterId",
|
||||||
|
table: "SkillGroups",
|
||||||
|
column: "CharacterId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "SkillGroups");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Skills_SkillGroupId",
|
||||||
|
table: "Skills");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "SkillGroupId",
|
||||||
|
table: "Skills");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
253
RpgRoller/Migrations/20260226131003_AddSkillGroupPrototypes.Designer.cs
generated
Normal file
253
RpgRoller/Migrations/20260226131003_AddSkillGroupPrototypes.Designer.cs
generated
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using RpgRoller.Data;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace RpgRoller.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(RpgRollerDbContext))]
|
||||||
|
[Migration("20260226131003_AddSkillGroupPrototypes")]
|
||||||
|
partial class AddSkillGroupPrototypes
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "10.0.2");
|
||||||
|
|
||||||
|
modelBuilder.Entity("RpgRoller.Domain.Campaign", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("GmUserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Ruleset")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("Version")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("GmUserId");
|
||||||
|
|
||||||
|
b.ToTable("Campaigns");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("RpgRoller.Domain.Character", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("CampaignId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("OwnerUserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CampaignId");
|
||||||
|
|
||||||
|
b.HasIndex("OwnerUserId");
|
||||||
|
|
||||||
|
b.ToTable("Characters");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("RpgRoller.Domain.RollLogEntry", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Breakdown")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("CampaignId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("CharacterId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Dice")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("Result")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid>("RollerUserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("SkillId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("TimestampUtc")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Visibility")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CampaignId");
|
||||||
|
|
||||||
|
b.HasIndex("CharacterId");
|
||||||
|
|
||||||
|
b.HasIndex("RollerUserId");
|
||||||
|
|
||||||
|
b.HasIndex("SkillId");
|
||||||
|
|
||||||
|
b.ToTable("RollLogEntries");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("RpgRoller.Domain.Skill", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("AllowFumble")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid>("CharacterId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("DiceRollDefinition")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("SkillGroupId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("WildDice")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CharacterId");
|
||||||
|
|
||||||
|
b.HasIndex("SkillGroupId");
|
||||||
|
|
||||||
|
b.ToTable("Skills");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("RpgRoller.Domain.SkillGroup", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("AllowFumble")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid>("CharacterId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("DiceRollDefinition")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("WildDice")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CharacterId");
|
||||||
|
|
||||||
|
b.ToTable("SkillGroups");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("RpgRoller.Domain.UserAccount", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ActiveCharacterId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Username")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("UsernameNormalized")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("UsernameNormalized")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Users");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("RpgRoller.Domain.UserSession", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Token")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Token");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("Sessions");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace RpgRoller.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddSkillGroupPrototypes : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "AllowFumble",
|
||||||
|
table: "SkillGroups",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "DiceRollDefinition",
|
||||||
|
table: "SkillGroups",
|
||||||
|
type: "TEXT",
|
||||||
|
maxLength: 128,
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "WildDice",
|
||||||
|
table: "SkillGroups",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0);
|
||||||
|
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
UPDATE SkillGroups
|
||||||
|
SET DiceRollDefinition = CASE
|
||||||
|
WHEN EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM Characters c
|
||||||
|
INNER JOIN Campaigns cp ON cp.Id = c.CampaignId
|
||||||
|
WHERE c.Id = SkillGroups.CharacterId
|
||||||
|
AND cp.Ruleset = 'D6')
|
||||||
|
THEN '1D'
|
||||||
|
ELSE '1d20'
|
||||||
|
END,
|
||||||
|
WildDice = CASE
|
||||||
|
WHEN EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM Characters c
|
||||||
|
INNER JOIN Campaigns cp ON cp.Id = c.CampaignId
|
||||||
|
WHERE c.Id = SkillGroups.CharacterId
|
||||||
|
AND cp.Ruleset = 'D6')
|
||||||
|
THEN 1
|
||||||
|
ELSE 0
|
||||||
|
END,
|
||||||
|
AllowFumble = CASE
|
||||||
|
WHEN EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM Characters c
|
||||||
|
INNER JOIN Campaigns cp ON cp.Id = c.CampaignId
|
||||||
|
WHERE c.Id = SkillGroups.CharacterId
|
||||||
|
AND cp.Ruleset = 'D6')
|
||||||
|
THEN 1
|
||||||
|
ELSE 0
|
||||||
|
END
|
||||||
|
WHERE DiceRollDefinition = '';
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "AllowFumble",
|
||||||
|
table: "SkillGroups");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "DiceRollDefinition",
|
||||||
|
table: "SkillGroups");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "WildDice",
|
||||||
|
table: "SkillGroups");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
258
RpgRoller/Migrations/20260226160859_AddAuthorizationRolesAndCampaignDeletion.Designer.cs
generated
Normal file
258
RpgRoller/Migrations/20260226160859_AddAuthorizationRolesAndCampaignDeletion.Designer.cs
generated
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using RpgRoller.Data;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace RpgRoller.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(RpgRollerDbContext))]
|
||||||
|
[Migration("20260226160859_AddAuthorizationRolesAndCampaignDeletion")]
|
||||||
|
partial class AddAuthorizationRolesAndCampaignDeletion
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "10.0.2");
|
||||||
|
|
||||||
|
modelBuilder.Entity("RpgRoller.Domain.Campaign", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("GmUserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Ruleset")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("Version")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("GmUserId");
|
||||||
|
|
||||||
|
b.ToTable("Campaigns");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("RpgRoller.Domain.Character", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("CampaignId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("OwnerUserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CampaignId");
|
||||||
|
|
||||||
|
b.HasIndex("OwnerUserId");
|
||||||
|
|
||||||
|
b.ToTable("Characters");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("RpgRoller.Domain.RollLogEntry", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Breakdown")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("CampaignId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("CharacterId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Dice")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("Result")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid>("RollerUserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("SkillId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("TimestampUtc")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Visibility")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CampaignId");
|
||||||
|
|
||||||
|
b.HasIndex("CharacterId");
|
||||||
|
|
||||||
|
b.HasIndex("RollerUserId");
|
||||||
|
|
||||||
|
b.HasIndex("SkillId");
|
||||||
|
|
||||||
|
b.ToTable("RollLogEntries");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("RpgRoller.Domain.Skill", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("AllowFumble")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid>("CharacterId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("DiceRollDefinition")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("SkillGroupId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("WildDice")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CharacterId");
|
||||||
|
|
||||||
|
b.HasIndex("SkillGroupId");
|
||||||
|
|
||||||
|
b.ToTable("Skills");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("RpgRoller.Domain.SkillGroup", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("AllowFumble")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid>("CharacterId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("DiceRollDefinition")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("WildDice")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CharacterId");
|
||||||
|
|
||||||
|
b.ToTable("SkillGroups");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("RpgRoller.Domain.UserAccount", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ActiveCharacterId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Roles")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Username")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("UsernameNormalized")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("UsernameNormalized")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Users");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("RpgRoller.Domain.UserSession", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Token")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Token");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("Sessions");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace RpgRoller.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddAuthorizationRolesAndCampaignDeletion : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Roles",
|
||||||
|
table: "Users",
|
||||||
|
type: "TEXT",
|
||||||
|
maxLength: 256,
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "admin");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<Guid>(
|
||||||
|
name: "CampaignId",
|
||||||
|
table: "Characters",
|
||||||
|
type: "TEXT",
|
||||||
|
nullable: true,
|
||||||
|
oldClrType: typeof(Guid),
|
||||||
|
oldType: "TEXT");
|
||||||
|
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
UPDATE Users
|
||||||
|
SET Roles = 'admin'
|
||||||
|
WHERE Roles IS NULL OR TRIM(Roles) = '';
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Roles",
|
||||||
|
table: "Users");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<Guid>(
|
||||||
|
name: "CampaignId",
|
||||||
|
table: "Characters",
|
||||||
|
type: "TEXT",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
|
||||||
|
oldClrType: typeof(Guid),
|
||||||
|
oldType: "TEXT",
|
||||||
|
oldNullable: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
255
RpgRoller/Migrations/RpgRollerDbContextModelSnapshot.cs
Normal file
255
RpgRoller/Migrations/RpgRollerDbContextModelSnapshot.cs
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using RpgRoller.Data;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace RpgRoller.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(RpgRollerDbContext))]
|
||||||
|
partial class RpgRollerDbContextModelSnapshot : ModelSnapshot
|
||||||
|
{
|
||||||
|
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "10.0.2");
|
||||||
|
|
||||||
|
modelBuilder.Entity("RpgRoller.Domain.Campaign", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("GmUserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Ruleset")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("Version")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("GmUserId");
|
||||||
|
|
||||||
|
b.ToTable("Campaigns");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("RpgRoller.Domain.Character", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("CampaignId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("OwnerUserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CampaignId");
|
||||||
|
|
||||||
|
b.HasIndex("OwnerUserId");
|
||||||
|
|
||||||
|
b.ToTable("Characters");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("RpgRoller.Domain.RollLogEntry", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Breakdown")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("CampaignId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("CharacterId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Dice")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("Result")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid>("RollerUserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("SkillId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("TimestampUtc")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Visibility")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CampaignId");
|
||||||
|
|
||||||
|
b.HasIndex("CharacterId");
|
||||||
|
|
||||||
|
b.HasIndex("RollerUserId");
|
||||||
|
|
||||||
|
b.HasIndex("SkillId");
|
||||||
|
|
||||||
|
b.ToTable("RollLogEntries");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("RpgRoller.Domain.Skill", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("AllowFumble")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid>("CharacterId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("DiceRollDefinition")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("SkillGroupId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("WildDice")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CharacterId");
|
||||||
|
|
||||||
|
b.HasIndex("SkillGroupId");
|
||||||
|
|
||||||
|
b.ToTable("Skills");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("RpgRoller.Domain.SkillGroup", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("AllowFumble")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid>("CharacterId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("DiceRollDefinition")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("WildDice")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CharacterId");
|
||||||
|
|
||||||
|
b.ToTable("SkillGroups");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("RpgRoller.Domain.UserAccount", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ActiveCharacterId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Roles")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Username")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("UsernameNormalized")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("UsernameNormalized")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Users");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("RpgRoller.Domain.UserSession", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Token")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Token");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("Sessions");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,20 +1,44 @@
|
|||||||
|
using Microsoft.AspNetCore.ResponseCompression;
|
||||||
using RpgRoller.Api;
|
using RpgRoller.Api;
|
||||||
|
using RpgRoller.Components;
|
||||||
|
using RpgRoller.Contracts;
|
||||||
using RpgRoller.Hosting;
|
using RpgRoller.Hosting;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
builder.Services.AddRpgRollerCore(builder.Configuration, builder.Environment);
|
builder.Services.AddRpgRollerCore(builder.Configuration, builder.Environment);
|
||||||
builder.Services.AddRazorComponents()
|
builder.Services.AddRazorComponents().AddInteractiveServerComponents();
|
||||||
.AddInteractiveServerComponents();
|
builder.Services.AddResponseCompression(options =>
|
||||||
|
{
|
||||||
|
options.EnableForHttps = true;
|
||||||
|
options.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(["application/json"]);
|
||||||
|
});
|
||||||
|
builder.Services.ConfigureHttpJsonOptions(options => RpgRollerJson.Configure(options.SerializerOptions));
|
||||||
|
builder.Services.AddHttpContextAccessor();
|
||||||
|
builder.Services.AddScoped<RpgRollerApiClient>();
|
||||||
|
builder.Services.AddScoped<WorkspaceSessionTokenAccessor>();
|
||||||
|
builder.Services.AddScoped<WorkspaceQueryService>();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
app.InitializeRpgRollerState();
|
app.InitializeRpgRollerState();
|
||||||
|
|
||||||
|
var configuredPathBase = builder.Configuration["PathBase"];
|
||||||
|
if (!string.IsNullOrWhiteSpace(configuredPathBase))
|
||||||
|
{
|
||||||
|
var normalizedPathBase = configuredPathBase.Trim();
|
||||||
|
if (!normalizedPathBase.StartsWith('/'))
|
||||||
|
normalizedPathBase = $"/{normalizedPathBase}";
|
||||||
|
|
||||||
|
normalizedPathBase = normalizedPathBase.TrimEnd('/');
|
||||||
|
if (normalizedPathBase.Length > 0)
|
||||||
|
app.UsePathBase(normalizedPathBase);
|
||||||
|
}
|
||||||
|
|
||||||
|
app.UseResponseCompression();
|
||||||
app.UseStaticFiles();
|
app.UseStaticFiles();
|
||||||
app.UseAntiforgery();
|
app.UseAntiforgery();
|
||||||
|
|
||||||
app.MapRpgRollerApi();
|
app.MapRpgRollerApi();
|
||||||
app.MapRazorComponents<RpgRoller.Components.App>()
|
app.MapRazorComponents<App>().AddInteractiveServerRenderMode();
|
||||||
.AddInteractiveServerRenderMode();
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
public partial class Program;
|
public partial class Program;
|
||||||
|
|||||||
@@ -7,6 +7,10 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.2">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.2"/>
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.2"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -5,27 +5,13 @@ namespace RpgRoller.Services;
|
|||||||
|
|
||||||
public static partial class DiceRules
|
public static partial class DiceRules
|
||||||
{
|
{
|
||||||
private const int MaxDiceCount = 50;
|
|
||||||
private const int MaxSides = 1000;
|
|
||||||
private const int MaxModifier = 1000;
|
|
||||||
|
|
||||||
public static readonly IReadOnlyList<(RulesetKind Kind, string Id, string Name, string DiceSyntax)> SupportedRulesets =
|
|
||||||
[
|
|
||||||
(RulesetKind.D6, "d6", "D6 System", "countD(+modifier), e.g. 5D+4"),
|
|
||||||
(RulesetKind.Dnd5e, "dnd5e", "D&D 5e", "countdSides(+modifier), e.g. 2d12+2")
|
|
||||||
];
|
|
||||||
|
|
||||||
public static RulesetKind? TryParseRulesetId(string rulesetId)
|
public static RulesetKind? TryParseRulesetId(string rulesetId)
|
||||||
{
|
{
|
||||||
if (string.Equals(rulesetId, "d6", StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(rulesetId, "d6", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
|
||||||
return RulesetKind.D6;
|
return RulesetKind.D6;
|
||||||
}
|
|
||||||
|
|
||||||
if (string.Equals(rulesetId, "dnd5e", StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(rulesetId, "dnd5e", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
|
||||||
return RulesetKind.Dnd5e;
|
return RulesetKind.Dnd5e;
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -43,9 +29,7 @@ public static partial class DiceRules
|
|||||||
public static ServiceResult<DiceExpression> ParseExpression(RulesetKind ruleset, string expression)
|
public static ServiceResult<DiceExpression> ParseExpression(RulesetKind ruleset, string expression)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(expression))
|
if (string.IsNullOrWhiteSpace(expression))
|
||||||
{
|
|
||||||
return ServiceResult<DiceExpression>.Failure("invalid_expression", "Dice expression is required.");
|
return ServiceResult<DiceExpression>.Failure("invalid_expression", "Dice expression is required.");
|
||||||
}
|
|
||||||
|
|
||||||
var trimmed = expression.Trim();
|
var trimmed = expression.Trim();
|
||||||
return ruleset switch
|
return ruleset switch
|
||||||
@@ -60,57 +44,43 @@ public static partial class DiceRules
|
|||||||
{
|
{
|
||||||
var match = D6Regex().Match(expression);
|
var match = D6Regex().Match(expression);
|
||||||
if (!match.Success)
|
if (!match.Success)
|
||||||
{
|
|
||||||
return ServiceResult<DiceExpression>.Failure("invalid_expression", "Expected d6 format like 5D+4.");
|
return ServiceResult<DiceExpression>.Failure("invalid_expression", "Expected d6 format like 5D+4.");
|
||||||
}
|
|
||||||
|
|
||||||
var diceCount = int.Parse(match.Groups["count"].Value);
|
var diceCount = int.Parse(match.Groups["count"].Value);
|
||||||
var modifier = ParseModifier(match.Groups["modifier"].Value);
|
var modifier = ParseModifier(match.Groups["modifier"].Value);
|
||||||
var validation = ValidateDiceParts(diceCount, 6, modifier);
|
var validation = ValidateDiceParts(diceCount, 6, modifier);
|
||||||
if (!validation.Succeeded)
|
if (!validation.Succeeded)
|
||||||
{
|
|
||||||
return ServiceResult<DiceExpression>.Failure(validation.Error!.Code, validation.Error.Message);
|
return ServiceResult<DiceExpression>.Failure(validation.Error!.Code, validation.Error.Message);
|
||||||
}
|
|
||||||
|
|
||||||
return ServiceResult<DiceExpression>.Success(new DiceExpression(diceCount, 6, modifier, $"{diceCount}D{FormatModifier(modifier)}"));
|
return ServiceResult<DiceExpression>.Success(new(diceCount, 6, modifier, $"{diceCount}D{FormatModifier(modifier)}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ServiceResult<DiceExpression> ParseDnd5e(string expression)
|
private static ServiceResult<DiceExpression> ParseDnd5e(string expression)
|
||||||
{
|
{
|
||||||
var match = Dnd5eRegex().Match(expression);
|
var match = Dnd5eRegex().Match(expression);
|
||||||
if (!match.Success)
|
if (!match.Success)
|
||||||
{
|
|
||||||
return ServiceResult<DiceExpression>.Failure("invalid_expression", "Expected dnd5e format like 2d12+2.");
|
return ServiceResult<DiceExpression>.Failure("invalid_expression", "Expected dnd5e format like 2d12+2.");
|
||||||
}
|
|
||||||
|
|
||||||
var diceCount = int.Parse(match.Groups["count"].Value);
|
var diceCount = int.Parse(match.Groups["count"].Value);
|
||||||
var sides = int.Parse(match.Groups["sides"].Value);
|
var sides = int.Parse(match.Groups["sides"].Value);
|
||||||
var modifier = ParseModifier(match.Groups["modifier"].Value);
|
var modifier = ParseModifier(match.Groups["modifier"].Value);
|
||||||
var validation = ValidateDiceParts(diceCount, sides, modifier);
|
var validation = ValidateDiceParts(diceCount, sides, modifier);
|
||||||
if (!validation.Succeeded)
|
if (!validation.Succeeded)
|
||||||
{
|
|
||||||
return ServiceResult<DiceExpression>.Failure(validation.Error!.Code, validation.Error.Message);
|
return ServiceResult<DiceExpression>.Failure(validation.Error!.Code, validation.Error.Message);
|
||||||
}
|
|
||||||
|
|
||||||
return ServiceResult<DiceExpression>.Success(new DiceExpression(diceCount, sides, modifier, $"{diceCount}d{sides}{FormatModifier(modifier)}"));
|
return ServiceResult<DiceExpression>.Success(new(diceCount, sides, modifier, $"{diceCount}d{sides}{FormatModifier(modifier)}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ServiceResult<bool> ValidateDiceParts(int diceCount, int sides, int modifier)
|
private static ServiceResult<bool> ValidateDiceParts(int diceCount, int sides, int modifier)
|
||||||
{
|
{
|
||||||
if (diceCount < 1 || diceCount > MaxDiceCount)
|
if (diceCount < 1 || diceCount > MaxDiceCount)
|
||||||
{
|
|
||||||
return ServiceResult<bool>.Failure("invalid_expression", $"Dice count must be between 1 and {MaxDiceCount}.");
|
return ServiceResult<bool>.Failure("invalid_expression", $"Dice count must be between 1 and {MaxDiceCount}.");
|
||||||
}
|
|
||||||
|
|
||||||
if (sides < 2 || sides > MaxSides)
|
if (sides < 2 || sides > MaxSides)
|
||||||
{
|
|
||||||
return ServiceResult<bool>.Failure("invalid_expression", $"Dice sides must be between 2 and {MaxSides}.");
|
return ServiceResult<bool>.Failure("invalid_expression", $"Dice sides must be between 2 and {MaxSides}.");
|
||||||
}
|
|
||||||
|
|
||||||
if (modifier < 0 || modifier > MaxModifier)
|
if (modifier < 0 || modifier > MaxModifier)
|
||||||
{
|
|
||||||
return ServiceResult<bool>.Failure("invalid_expression", $"Modifier must be between 0 and {MaxModifier}.");
|
return ServiceResult<bool>.Failure("invalid_expression", $"Modifier must be between 0 and {MaxModifier}.");
|
||||||
}
|
|
||||||
|
|
||||||
return ServiceResult<bool>.Success(true);
|
return ServiceResult<bool>.Success(true);
|
||||||
}
|
}
|
||||||
@@ -130,4 +100,14 @@ public static partial class DiceRules
|
|||||||
|
|
||||||
[GeneratedRegex("^(?<count>\\d+)d(?<sides>\\d+)(?:\\+(?<modifier>\\d+))?$", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase)]
|
[GeneratedRegex("^(?<count>\\d+)d(?<sides>\\d+)(?:\\+(?<modifier>\\d+))?$", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase)]
|
||||||
private static partial Regex Dnd5eRegex();
|
private static partial Regex Dnd5eRegex();
|
||||||
|
|
||||||
|
private const int MaxDiceCount = 50;
|
||||||
|
private const int MaxSides = 1000;
|
||||||
|
private const int MaxModifier = 1000;
|
||||||
|
|
||||||
|
public static readonly IReadOnlyList<(RulesetKind Kind, string Id, string Name, string DiceSyntax)> SupportedRulesets =
|
||||||
|
[
|
||||||
|
(RulesetKind.D6, "d6", "D6 System", "countD(+modifier), e.g. 5D+4"),
|
||||||
|
(RulesetKind.Dnd5e, "dnd5e", "D&D 5e", "countdSides(+modifier), e.g. 2d12+2")
|
||||||
|
];
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,4 @@
|
|||||||
using RpgRoller.Contracts;
|
using RpgRoller.Contracts;
|
||||||
using RpgRoller.Domain;
|
|
||||||
|
|
||||||
namespace RpgRoller.Services;
|
namespace RpgRoller.Services;
|
||||||
|
|
||||||
@@ -15,18 +14,32 @@ public interface IGameService
|
|||||||
|
|
||||||
ServiceResult<CampaignSummary> CreateCampaign(string sessionToken, string name, string rulesetId);
|
ServiceResult<CampaignSummary> CreateCampaign(string sessionToken, string name, string rulesetId);
|
||||||
ServiceResult<IReadOnlyList<CampaignSummary>> GetCampaigns(string sessionToken);
|
ServiceResult<IReadOnlyList<CampaignSummary>> GetCampaigns(string sessionToken);
|
||||||
ServiceResult<CampaignDetails> GetCampaign(string sessionToken, Guid campaignId);
|
ServiceResult<IReadOnlyList<CampaignOption>> GetCharacterCampaignOptions(string sessionToken);
|
||||||
|
ServiceResult<CampaignRoster> GetCampaign(string sessionToken, Guid campaignId);
|
||||||
|
ServiceResult<bool> DeleteCampaign(string sessionToken, Guid campaignId);
|
||||||
|
ServiceResult<IReadOnlyList<string>> GetUsernames(string sessionToken);
|
||||||
|
ServiceResult<IReadOnlyList<AdminUserSummary>> GetUsers(string sessionToken);
|
||||||
|
ServiceResult<AdminUserSummary> UpdateUserRoles(string sessionToken, Guid userId, IReadOnlyList<string> roles);
|
||||||
|
ServiceResult<bool> DeleteUser(string sessionToken, Guid userId);
|
||||||
|
|
||||||
ServiceResult<CharacterSummary> CreateCharacter(string sessionToken, string name, Guid campaignId);
|
ServiceResult<CharacterSummary> CreateCharacter(string sessionToken, string name, Guid campaignId);
|
||||||
ServiceResult<CharacterSummary> UpdateCharacter(string sessionToken, Guid characterId, string name, Guid campaignId);
|
ServiceResult<CharacterSummary> UpdateCharacter(string sessionToken, Guid characterId, string name, Guid? campaignId, string? ownerUsername = null);
|
||||||
|
ServiceResult<bool> DeleteCharacter(string sessionToken, Guid characterId);
|
||||||
ServiceResult<bool> ActivateCharacter(string sessionToken, Guid characterId);
|
ServiceResult<bool> ActivateCharacter(string sessionToken, Guid characterId);
|
||||||
ServiceResult<IReadOnlyList<CharacterSummary>> GetCurrentCampaignCharacters(string sessionToken);
|
ServiceResult<IReadOnlyList<CharacterSummary>> GetOwnCharacters(string sessionToken);
|
||||||
|
|
||||||
ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble);
|
ServiceResult<SkillGroupSummary> CreateSkillGroup(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble);
|
||||||
ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble);
|
ServiceResult<SkillGroupSummary> UpdateSkillGroup(string sessionToken, Guid skillGroupId, string name, string diceRollDefinition, int wildDice, bool allowFumble);
|
||||||
|
ServiceResult<bool> DeleteSkillGroup(string sessionToken, Guid skillGroupId);
|
||||||
|
ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null);
|
||||||
|
ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null);
|
||||||
|
ServiceResult<bool> DeleteSkill(string sessionToken, Guid skillId);
|
||||||
|
ServiceResult<CharacterSheet> GetCharacterSheet(string sessionToken, Guid characterId);
|
||||||
|
|
||||||
ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility);
|
ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility);
|
||||||
ServiceResult<IReadOnlyList<CampaignLogEntry>> GetCampaignLog(string sessionToken, Guid campaignId);
|
ServiceResult<IReadOnlyList<CampaignLogEntry>> GetCampaignLog(string sessionToken, Guid campaignId);
|
||||||
|
ServiceResult<CampaignLogPage> GetCampaignLogPage(string sessionToken, Guid campaignId, Guid? afterRollId = null, int? limit = null);
|
||||||
|
ServiceResult<CampaignRollDetail> GetRollDetail(string sessionToken, Guid rollId);
|
||||||
|
|
||||||
ServiceResult<long> GetCampaignVersion(string sessionToken, Guid campaignId);
|
ServiceResult<CampaignStateSnapshot> GetCampaignStateSnapshot(string sessionToken, Guid campaignId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
|
||||||
namespace RpgRoller.Services;
|
namespace RpgRoller.Services;
|
||||||
|
|
||||||
public sealed class RandomDiceRoller : IDiceRoller
|
public sealed class RandomDiceRoller : IDiceRoller
|
||||||
{
|
{
|
||||||
public int Roll(int sides)
|
public int Roll(int sides)
|
||||||
{
|
{
|
||||||
return System.Security.Cryptography.RandomNumberGenerator.GetInt32(1, sides + 1);
|
return RandomNumberGenerator.GetInt32(1, sides + 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1 +1,2 @@
|
|||||||
// Intentionally left blank after removing service command records.
|
// Intentionally left blank after removing service command records.
|
||||||
|
|
||||||
|
|||||||
@@ -14,17 +14,17 @@ public sealed class ServiceResult<T>
|
|||||||
Error = error;
|
Error = error;
|
||||||
}
|
}
|
||||||
|
|
||||||
public T? Value { get; }
|
|
||||||
public ServiceError? Error { get; }
|
|
||||||
public bool Succeeded => Error is null;
|
|
||||||
|
|
||||||
public static ServiceResult<T> Success(T value)
|
public static ServiceResult<T> Success(T value)
|
||||||
{
|
{
|
||||||
return new ServiceResult<T>(value);
|
return new(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static ServiceResult<T> Failure(string code, string message)
|
public static ServiceResult<T> Failure(string code, string message)
|
||||||
{
|
{
|
||||||
return new ServiceResult<T>(new ServiceError(code, message));
|
return new(new ServiceError(code, message));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public T? Value { get; }
|
||||||
|
public ServiceError? Error { get; }
|
||||||
|
public bool Succeeded => Error is null;
|
||||||
}
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user