51 Commits

Author SHA1 Message Date
2c99544baf Changed background images to webp 2026-05-28 19:32:42 +02:00
8e730af85d Fix Windows deploy remote script line endings 2026-05-18 21:13:50 +02:00
66607e51eb Light/Dark theming 2026-05-18 21:00:38 +02:00
ecc799ae7f Move campaign selector into header 2026-05-18 20:13:14 +02:00
ff28f70b51 Add Windows deploy script 2026-05-18 19:11:31 +02:00
20c8868744 remove stale script 2026-05-18 19:06:13 +02:00
b80e9f1aec updated docs 2026-05-18 19:05:39 +02:00
d74f8a65a9 removed stale scripts 2026-05-18 19:00:09 +02:00
c79bea86b6 removed stale doc 2026-05-18 18:59:23 +02:00
e7ae0e00c1 Background image 2026-05-05 02:35:05 +02:00
43bd68e707 Allow GM play roster access 2026-05-05 02:10:26 +02:00
e574b4a37b Cleared TASKS.md 2026-05-05 01:59:19 +02:00
b8bd92e3dc Fix proxied live updates 2026-05-05 01:55:59 +02:00
2be1fc599a Add Linux deploy script 2026-05-05 01:27:48 +02:00
ba9536de12 Fix campaigns rerender after mutations 2026-05-05 01:10:04 +02:00
777befdbf0 fix: sync custom roll visibility 2026-05-05 00:56:41 +02:00
6b18051073 fix: restore visibility select behavior 2026-05-05 00:48:15 +02:00
f8b09be399 fix: unify custom roll visibility 2026-05-05 00:44:54 +02:00
f01d100740 fix: unify workspace header layout 2026-05-05 00:25:58 +02:00
c427e717d5 refactor: remove crash workaround scaffolding 2026-05-05 00:20:37 +02:00
c628957163 fix: use per-page blazor startup 2026-05-04 23:58:26 +02:00
56e0ec1e79 fix: phase authenticated startup 2026-05-04 23:02:39 +02:00
f86ac43153 chore: add pre-blazor crash diagnostics 2026-05-04 22:53:14 +02:00
e60b4b5867 chore: add workspace crash diagnostics 2026-05-04 22:43:57 +02:00
a69c6284d7 fix: stabilize route startup render 2026-05-04 22:27:14 +02:00
12612e05fa fix: scope startup by route 2026-05-04 22:11:20 +02:00
73dc4a9cd4 refactor: finish route-first shell 2026-05-04 21:58:22 +02:00
9c3f7c039e refactor: split workspace routes 2026-05-04 21:45:44 +02:00
def2a3f680 Implement milestone 2 route navigation 2026-05-04 21:23:45 +02:00
c13a2ce7c7 Replace Playwright smoke tests with Selenium 2026-05-04 20:54:10 +02:00
b97437fda3 updated agents 2026-05-04 20:27:16 +02:00
b9fba1bbbc Refactor frontend entry to login and play routes 2026-05-04 20:23:53 +02:00
a7f6163c4b Overhaul frontend rewrite documentation 2026-05-04 20:04:40 +02:00
8d08b857ab Add workspace compatibility postmortem 2026-05-04 19:26:07 +02:00
e0b7d27ba7 Stage workspace controls after bootstrap 2026-05-04 19:03:47 +02:00
da813583bd Delay workspace render until session init completes 2026-05-04 18:12:10 +02:00
231b0ac9a0 Remove workspace session-token coupling 2026-05-03 00:08:47 +02:00
1f19bf7bfd Restore workspace prerender and auth errors 2026-05-02 23:53:40 +02:00
2d2ed561cc Isolate anonymous auth page from Blazor 2026-05-02 23:31:49 +02:00
d3ba75ce42 Added agents 2026-04-26 20:13:56 +02:00
2d5f893963 Added agents 2026-04-26 20:11:58 +02:00
3e1d3746dd Add rolemaster situational modifier modal 2026-04-14 23:53:07 +02:00
368a9a4960 Add rolemaster situational roll modifier backend 2026-04-14 23:42:25 +02:00
9e91fb2719 Add Rolemaster situational modifier execplan 2026-04-14 23:31:07 +02:00
8d59868392 Add retry smoke coverage 2026-04-14 23:15:06 +02:00
d4e72fe5bb Adjust rolemaster retry lower bound 2026-04-14 23:10:50 +02:00
2997247eeb Add rolemaster automatic retry rolls 2026-04-14 23:03:38 +02:00
0c638e8ebe Fix typo 2026-04-14 22:51:40 +02:00
0dff8a275c updated AGENTS.md 2026-04-14 22:50:18 +02:00
d38003a77c Add rolemaster auto retry skill toggle 2026-04-14 22:49:14 +02:00
f63c3f8f28 Updated AGENTS.md 2026-04-14 22:38:09 +02:00
120 changed files with 5678 additions and 2179 deletions

17
AGENTS.linux.md Normal file
View File

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

View File

@@ -1,41 +1,26 @@
# Agent Guide # Agent Guide
Also see the other related technical documentation: README.md. Detect which operating system you're currently running on.
If this is a linux environment, read `AGENTS.linux.md`.
If this is a windows environment, read `AGENTS.windows.md`.
## Rules ## Rules
- This is a Windows environment, WSL is not installed (i.e. sed is not available). You're running under PowerShell 7.6.0. Due to platform restrictions, file deletions are not possible. Replacing the entire file content via a context diff is a viable alternative. - Prefer extracting code to a shared helper to be reused instead of duplicating code. Always keep high maintainability standards.
- 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'. - Keep changes as small as possible, design solutions that achieve the goals with minimal churn.
- web.config in the server is different than locally, it must be exluded from deployment. - Always place each newly created class into its own file. The file name must match the class name.
- Before beginning with the edit phase, always present a plan first. Only begin editing after the user approves the plan. - When asked to begin wor~~~~king on a task, create a detailed implementation plan first, present the plan to the user, and ask for approval before beginning with the actual implementation.
- Don't make assumptions in the plan. If necessary, ask all clarifying questions before presenting the final plan. - Don't make assumptions in the plan. If necessary, ask all clarifying questions before presenting the final plan.
- When an task is finished, perform a code review to evaluate if the change is clean and maintainable with high software engineering standards. Iterate on the code and repeat the review process until satisfied.
- If there's documnentation present, always keep it updated.
- After every iteration, evaluate if the test coverage would fall below 100%, and write tests if necessary. - After every iteration, evaluate if the test coverage would fall below 100%, and write tests if necessary.
- After every iteration, run `jb cleanupcode --build=False $file1;$file2;...` for every file you touched. - When browser verification needs the app running, launch the app against a temporary copy of `src\RolemasterDb.App\rolemaster.db` so verification does not mutate the canonical DB.
- After every iteration, run "scripts/ci-local.ps1" and ensure that nothing broke.
- After every iteration, update all related documentation according to the change. ### Git
- Update the wording of touched concerns instead of introducing incremental change reports
- The documentation should always represent the current state in its entirety and not derail into a historical development log. - Never change the .gitignore file without consent.
- After every frontend change, verify the results using an ephemeral playwright.
- After every iteration, do a git commit with a brief summary of the changes as a commit message.
- 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.
- When multiple commits are necessary, pause after every commit and ask the user to give a command to proceed. - When multiple commits are necessary, pause after every commit and ask the user to give a command to proceed.
- After every iteration, do a git commit with a brief summary of the changes as a commit message.
- If you find unexpected changes in the code (deletions, changes, diff results that were not communicated), never revert them and never restore the old state. Assume that those changes happened with intent. - 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. - Never use `git restore`, `git checkout --`, reset commands, or equivalent rollback actions to discard local changes unless the user explicitly asks for that exact rollback.
- If a required tool is missing (for example `dotnet-ef`), install/configure the tool (prefer repo-local setup such as `dotnet tool manifest`) instead of weakening validations or muting warnings. If installation is blocked, stop and ask before changing validation strictness.
- After changing the database, if your build is blocked by a running dotnet process, feel free to kill the process and retry the operation once.
## Output generation
### User
- For the user, you talk like a caveman. Speak only short grunts. Communicate in english.
- Give no explanations unless explicitly asked.
- No fillers like 'happy to help'. Do task first. Show result. Stop.
- For tools: "Tool work." then output the result and words like "Problem.", "Plan?", "Done."
- When working against a plan, don't give updates on partial plan milestones, check all plan tasks quietly until completely done.
### Reasoning
- Feel free to speak to yourself in whatever verbosity and language suits you best.
## ExecPlans
- When writing complex features or significant refactors, use an ExecPlan (as described in PLANS.md) from design to implementation.

39
AGENTS.windows.md Normal file
View File

@@ -0,0 +1,39 @@
# Agent Guide
Also see the other related technical documentation in the docs folder.
## Tools
These tool paths should be used instead of any entry in the PATH environment variable:
- Python is installed in `C:\Users\frank\AppData\Local\Programs\Python\Python314`.
- MiKTeX portable is installed in `D:\Code\miktex-portable\texmfs\install\miktex\bin\x64`.
- Tesseract is installed in `C:\Program Files\Sejda PDF Desktop\resources\vendor\tesseract-windows-x64`.
## Rules
- After every iteration, run `dotnet jb cleanupcode --build=False '$file1' '$file2' ...` for every C# file you touched.
- After the implementation is finished, verify all changed files, and run `python D:\Code\crlf.py $file1 $file2 ...` only for files you recognize, in order to normalize all line endings of all touched files to CRLF.
- After every frontend change, verify the results using an ephemeral Playwright run.
- For ad hoc verification in this repo, do not default to `npx playwright test` with a temp spec outside the repo.
- Prefer a repo-local ephemeral Node script under `artifacts_verify/` that imports `playwright` with `require('playwright')` and drives the browser directly.
- If using the Playwright test runner, use the repo-local CLI at `node_modules\.bin\playwright.cmd` and keep the spec inside the repo so local `node_modules` resolution works.
- Do not mix the global Playwright CLI with the repo-local `@playwright/test` package.
- When browser verification needs the app running, launch the app against a temporary copy of `src\RolemasterDb.App\rolemaster.db` so verification does not mutate the canonical DB.
### PowerShell
- This is a Windows environment, WSL is not installed (i.e. sed is not available). You're running under PowerShell 7.5.5. Due to platform restrictions, file deletions are not possible. Replacing the entire file content via a context diff is a viable alternative.
- PowerShell doesn't support bash-style heredocs. If complex scripts need to be executed, consider using python as a last resort. Run Python code using python -c with inline commands instead of python - <<'PY'.
- Parallel PowerShell calls are flaky, stick to sequential reads and command execution.
- Commands like `rg` and `Get-Content` are always allowed.
### Dotnet CLI
- If a build fails with 0 errors / 0 warnings:
- Do not keep retrying the same build command
- Consider using --no-restore.
- Consider using `$env:DOTNET_SKIP_FIRST_TIME_EXPERIENCE = '1'`
- Consider using `$env:NUGET_PACKAGES = Join-Path $env:USERPROFILE '.nuget\packages'`
- If you need a separate output directory, use a subfolder under `artifacts`, and clean it up afterwards.
- Avoid running `dotnet build` and `dotnet test` in parallel in this repo; that can cause file-lock failures in `obj\Debug\net10.0`.

204
README.md
View File

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

View File

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

View File

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

View File

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

View File

@@ -81,4 +81,105 @@ public sealed class RolemasterApiTests(WebApplicationFactory<Program> factory) :
Assert.Equal(-12, die.SignedContribution); Assert.Equal(-12, die.SignedContribution);
}); });
} }
[Fact]
public async Task RolemasterAutoRetryRolls_AppearInLogPageAndDetail()
{
using var factory = CreateFactory(66, 42, 90, 32, 65);
using var client = factory.CreateClient(new() { AllowAutoRedirect = false });
await RegisterAsync(client, "rolemaster-retry-api", "Password123", "Rolemaster Retry Api");
await LoginAsync(client, "rolemaster-retry-api", "Password123");
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(client, "/api/campaigns", new("Rolemaster Retry", "rolemaster"));
var character = await PostAsync<CreateCharacterRequest, CharacterSummary>(client, "/api/characters", new("Hero", campaign.Id));
var retryFiveSkill = await PostAsync<CreateSkillRequest, SkillSummary>(client, $"/api/characters/{character.Id}/skills", new("Awareness +5", "d100!+10", 0, false, null, 5, true));
var retryTenSkill = await PostAsync<CreateSkillRequest, SkillSummary>(client, $"/api/characters/{character.Id}/skills", new("Awareness +10", "d100!+1", 0, false, null, 5, true));
var disabledSkill = await PostAsync<CreateSkillRequest, SkillSummary>(client, $"/api/characters/{character.Id}/skills", new("Awareness Off", "d100!+10", 0, false, null, 5));
var retryFiveRoll = await PostAsync<RollSkillRequest, RollResult>(client, $"/api/skills/{retryFiveSkill.Id}/roll", new("public"));
var retryTenRoll = await PostAsync<RollSkillRequest, RollResult>(client, $"/api/skills/{retryTenSkill.Id}/roll", new("public"));
var disabledRoll = await PostAsync<RollSkillRequest, RollResult>(client, $"/api/skills/{disabledSkill.Id}/roll", new("public"));
var logPage = await GetAsync<CampaignLogPage>(client, $"/api/campaigns/{campaign.Id}/log/page?limit=10");
var detail = await GetAsync<CampaignRollDetail>(client, $"/api/rolls/{retryFiveRoll.RollId}");
Assert.Equal(57, retryFiveRoll.Result);
Assert.Equal("66+10=76; retry(+5): 42+10=52; final=57", retryFiveRoll.Breakdown);
Assert.Collection(retryFiveRoll.Dice, die =>
{
Assert.Equal(1, die.Attempt);
Assert.Equal(1, die.Sequence);
}, die =>
{
Assert.Equal(2, die.Attempt);
Assert.Equal(1, die.Sequence);
});
Assert.Equal(43, retryTenRoll.Result);
Assert.Equal("90+1=91; retry(+10): 32+1=33; final=43", retryTenRoll.Breakdown);
Assert.Equal(75, disabledRoll.Result);
Assert.Equal("65+10=75", disabledRoll.Breakdown);
Assert.All(disabledRoll.Dice, die => Assert.Null(die.Attempt));
Assert.Equal(3, logPage.Entries.Length);
Assert.Equal("66 | open-ended | retry +5", logPage.Entries[0].SummaryText);
Assert.Equal(["r66", "rs5"], Assert.IsType<string[]>(logPage.Entries[0].EventBadges));
Assert.Equal("90 | open-ended | retry +10", logPage.Entries[1].SummaryText);
Assert.Equal(["rs10"], Assert.IsType<string[]>(logPage.Entries[1].EventBadges));
Assert.Equal("65 | open-ended", logPage.Entries[2].SummaryText);
Assert.Null(logPage.Entries[2].EventBadges);
Assert.Equal(retryFiveRoll.Breakdown, detail.Breakdown);
Assert.Collection(detail.Dice, die =>
{
Assert.Equal(1, die.Attempt);
Assert.Equal(RollDieKinds.RolemasterOpenEndedInitial, die.Kind);
}, die =>
{
Assert.Equal(2, die.Attempt);
Assert.Equal(RollDieKinds.RolemasterOpenEndedInitial, die.Kind);
});
}
[Fact]
public async Task RolemasterSkillRoll_AcceptsSituationalModifier_AndAppliesItToRetryMath()
{
using var factory = CreateFactory(8, 42);
using var client = factory.CreateClient(new() { AllowAutoRedirect = false });
await RegisterAsync(client, "rolemaster-situational-api", "Password123", "Rolemaster Situational Api");
await LoginAsync(client, "rolemaster-situational-api", "Password123");
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(client, "/api/campaigns", new("Rolemaster Situational", "rolemaster"));
var character = await PostAsync<CreateCharacterRequest, CharacterSummary>(client, "/api/characters", new("Hero", campaign.Id));
var skill = await PostAsync<CreateSkillRequest, SkillSummary>(client, $"/api/characters/{character.Id}/skills", new("Observation", "d100!+50", 0, false, null, 5, true));
var roll = await PostAsync<RollSkillRequest, RollResult>(client, $"/api/skills/{skill.Id}/roll", new("public", 20));
Assert.Equal(117, roll.Result);
Assert.Equal("8+50+20=78; retry(+5): 42+50+20=112; final=117", roll.Breakdown);
Assert.Collection(roll.Dice, die => Assert.Equal(1, die.Attempt), die => Assert.Equal(2, die.Attempt));
}
[Fact]
public async Task SkillRoll_RejectsSituationalModifier_ForNonRolemasterCampaigns()
{
using var factory = CreateFactory(12);
using var client = factory.CreateClient(new() { AllowAutoRedirect = false });
await RegisterAsync(client, "non-rolemaster-situational-api", "Password123", "Non Rolemaster Situational Api");
await LoginAsync(client, "non-rolemaster-situational-api", "Password123");
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(client, "/api/campaigns", new("Dnd Situational", "dnd5e"));
var character = await PostAsync<CreateCharacterRequest, CharacterSummary>(client, "/api/characters", new("Hero", campaign.Id));
var skill = await PostAsync<CreateSkillRequest, SkillSummary>(client, $"/api/characters/{character.Id}/skills", new("Attack", "1d20+5", 0, false));
var response = await client.PostAsJsonAsync($"/api/skills/{skill.Id}/roll", new RollSkillRequest("public", 20));
var error = await response.Content.ReadFromJsonAsync<ApiError>();
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.NotNull(error);
Assert.Equal("invalid_situational_modifier", error.Code);
}
} }

View File

@@ -1,4 +1,4 @@
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.Data.Sqlite; using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -131,6 +131,7 @@ public sealed class HostingCoverageTests
Assert.Contains("WildDice", columns); Assert.Contains("WildDice", columns);
Assert.Contains("AllowFumble", columns); Assert.Contains("AllowFumble", columns);
Assert.Contains("FumbleRange", columns); Assert.Contains("FumbleRange", columns);
Assert.Contains("RolemasterAutoRetry", columns);
using var skillGroupsTableInfoCommand = verifyConnection.CreateCommand(); using var skillGroupsTableInfoCommand = verifyConnection.CreateCommand();
skillGroupsTableInfoCommand.CommandText = "PRAGMA table_info('SkillGroups');"; skillGroupsTableInfoCommand.CommandText = "PRAGMA table_info('SkillGroups');";
@@ -158,6 +159,7 @@ public sealed class HostingCoverageTests
usersColumns.Add(usersTableInfoReader.GetString(1)); usersColumns.Add(usersTableInfoReader.GetString(1));
Assert.Contains("Roles", usersColumns); Assert.Contains("Roles", usersColumns);
Assert.Contains("ThemePreference", usersColumns);
using var usersRoleCommand = verifyConnection.CreateCommand(); using var usersRoleCommand = verifyConnection.CreateCommand();
usersRoleCommand.CommandText = "SELECT Roles FROM Users WHERE UsernameNormalized = 'LEGACY-ADMIN';"; usersRoleCommand.CommandText = "SELECT Roles FROM Users WHERE UsernameNormalized = 'LEGACY-ADMIN';";
@@ -208,6 +210,16 @@ public sealed class HostingCoverageTests
rolemasterHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260402222501_AddRolemasterFumbleRange';"; rolemasterHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260402222501_AddRolemasterFumbleRange';";
var rolemasterHistoryCount = Convert.ToInt32(rolemasterHistoryCommand.ExecuteScalar()); var rolemasterHistoryCount = Convert.ToInt32(rolemasterHistoryCommand.ExecuteScalar());
Assert.Equal(1, rolemasterHistoryCount); Assert.Equal(1, rolemasterHistoryCount);
using var retryHistoryCommand = verifyConnection.CreateCommand();
retryHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260414204309_AddRolemasterAutoRetry';";
var retryHistoryCount = Convert.ToInt32(retryHistoryCommand.ExecuteScalar());
Assert.Equal(1, retryHistoryCount);
using var themeHistoryCommand = verifyConnection.CreateCommand();
themeHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260518183838_AddUserThemePreference';";
var themeHistoryCount = Convert.ToInt32(themeHistoryCommand.ExecuteScalar());
Assert.Equal(1, themeHistoryCount);
} }
[Fact] [Fact]
@@ -348,6 +360,16 @@ public sealed class HostingCoverageTests
rolemasterHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260402222501_AddRolemasterFumbleRange';"; rolemasterHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260402222501_AddRolemasterFumbleRange';";
var rolemasterHistoryCount = Convert.ToInt32(rolemasterHistoryCommand.ExecuteScalar()); var rolemasterHistoryCount = Convert.ToInt32(rolemasterHistoryCommand.ExecuteScalar());
Assert.Equal(1, rolemasterHistoryCount); Assert.Equal(1, rolemasterHistoryCount);
using var retryHistoryCommand = verifyConnection.CreateCommand();
retryHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260414204309_AddRolemasterAutoRetry';";
var retryHistoryCount = Convert.ToInt32(retryHistoryCommand.ExecuteScalar());
Assert.Equal(1, retryHistoryCount);
using var themeHistoryCommand = verifyConnection.CreateCommand();
themeHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260518183838_AddUserThemePreference';";
var themeHistoryCount = Convert.ToInt32(themeHistoryCommand.ExecuteScalar());
Assert.Equal(1, themeHistoryCount);
} }
[Fact] [Fact]
@@ -459,6 +481,7 @@ public sealed class HostingCoverageTests
skillColumns.Add(skillsTableInfoReader.GetString(1)); skillColumns.Add(skillsTableInfoReader.GetString(1));
Assert.Contains("FumbleRange", skillColumns); Assert.Contains("FumbleRange", skillColumns);
Assert.Contains("RolemasterAutoRetry", skillColumns);
using var skillGroupsTableInfoCommand = verifyConnection.CreateCommand(); using var skillGroupsTableInfoCommand = verifyConnection.CreateCommand();
skillGroupsTableInfoCommand.CommandText = "PRAGMA table_info('SkillGroups');"; skillGroupsTableInfoCommand.CommandText = "PRAGMA table_info('SkillGroups');";
@@ -469,9 +492,28 @@ public sealed class HostingCoverageTests
Assert.Contains("FumbleRange", skillGroupColumns); Assert.Contains("FumbleRange", skillGroupColumns);
using var usersTableInfoCommand = verifyConnection.CreateCommand();
usersTableInfoCommand.CommandText = "PRAGMA table_info('Users');";
using var usersTableInfoReader = usersTableInfoCommand.ExecuteReader();
var usersColumns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
while (usersTableInfoReader.Read())
usersColumns.Add(usersTableInfoReader.GetString(1));
Assert.Contains("ThemePreference", usersColumns);
using var authorizationRolesHistoryCommand = verifyConnection.CreateCommand(); using var authorizationRolesHistoryCommand = verifyConnection.CreateCommand();
authorizationRolesHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260226170000_AddAuthorizationRoles';"; authorizationRolesHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260226170000_AddAuthorizationRoles';";
var authorizationRolesHistoryCount = Convert.ToInt32(authorizationRolesHistoryCommand.ExecuteScalar()); var authorizationRolesHistoryCount = Convert.ToInt32(authorizationRolesHistoryCommand.ExecuteScalar());
Assert.Equal(1, authorizationRolesHistoryCount); Assert.Equal(1, authorizationRolesHistoryCount);
using var retryHistoryCommand = verifyConnection.CreateCommand();
retryHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260414204309_AddRolemasterAutoRetry';";
var retryHistoryCount = Convert.ToInt32(retryHistoryCommand.ExecuteScalar());
Assert.Equal(1, retryHistoryCount);
using var themeHistoryCommand = verifyConnection.CreateCommand();
themeHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260518183838_AddUserThemePreference';";
var themeHistoryCount = Convert.ToInt32(themeHistoryCommand.ExecuteScalar());
Assert.Equal(1, themeHistoryCount);
} }
} }

View File

@@ -130,9 +130,9 @@ public sealed class PayloadBudgetTests
} }
[Fact] [Fact]
public void RolemasterRollDetailPayload_StaysWithinBudget_AndRolemasterMetadataRemainsLazy() public void RolemasterRollDetailPayload_StaysWithinBudget_AndRetryMetadataRemainsLazy()
{ {
using var harness = ServiceTestSupport.CreateHarness(96, 100, 100, 100, 100, 97, 12); using var harness = ServiceTestSupport.CreateHarness(66, 42);
var service = harness.Service; var service = harness.Service;
service.Register("gm-rm-detail-budget", "Password123", "GM"); service.Register("gm-rm-detail-budget", "Password123", "GM");
@@ -143,11 +143,12 @@ public sealed class PayloadBudgetTests
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Rolemaster Payload Detail", "rolemaster")); var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Rolemaster Payload Detail", "rolemaster"));
var character = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Detail Hero", campaign.Id)); var character = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Detail Hero", campaign.Id));
var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, character.Id, "Sense", "d100!+85", 0, false, null, 5)); var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, character.Id, "Sense", "d100!+10", 0, false, null, 5, true));
var roll = ServiceTestSupport.GetValue(service.RollSkill(ownerSession, skill.Id, "public")); var roll = ServiceTestSupport.GetValue(service.RollSkill(ownerSession, skill.Id, "public"));
var logPage = ServiceTestSupport.GetValue(service.GetCampaignLogPage(gmSession, campaign.Id, limit: 5)); var logPage = ServiceTestSupport.GetValue(service.GetCampaignLogPage(gmSession, campaign.Id, limit: 5));
var detail = ServiceTestSupport.GetValue(service.GetRollDetail(gmSession, roll.RollId)); var detail = ServiceTestSupport.GetValue(service.GetRollDetail(gmSession, roll.RollId));
Assert.Equal("66 | open-ended | retry +5", Assert.Single(logPage.Entries).SummaryText);
AssertPayloadWithinBudget(detail, 4 * 1024, "rolemaster roll detail"); AssertPayloadWithinBudget(detail, 4 * 1024, "rolemaster roll detail");
@@ -160,8 +161,9 @@ public sealed class PayloadBudgetTests
Assert.DoesNotContain("\"sequence\"", logPageJson, StringComparison.Ordinal); Assert.DoesNotContain("\"sequence\"", logPageJson, StringComparison.Ordinal);
Assert.DoesNotContain("\"breakdown\"", logPageJson, StringComparison.Ordinal); Assert.DoesNotContain("\"breakdown\"", logPageJson, StringComparison.Ordinal);
Assert.Contains("\"kind\":\"rolemaster-open-ended-initial\"", detailJson, StringComparison.Ordinal); Assert.Contains("\"kind\":\"rolemaster-open-ended-initial\"", detailJson, StringComparison.Ordinal);
Assert.Contains("\"signedContribution\":96", detailJson, StringComparison.Ordinal); Assert.Contains("\"signedContribution\":66", detailJson, StringComparison.Ordinal);
Assert.Contains("\"sequence\":6", detailJson, StringComparison.Ordinal); Assert.Contains("\"attempt\":1", detailJson, StringComparison.Ordinal);
Assert.Contains("\"attempt\":2", detailJson, StringComparison.Ordinal);
} }
private static void AssertPayloadWithinBudget<T>(T payload, int maxBytes, string label) private static void AssertPayloadWithinBudget<T>(T payload, int maxBytes, string label)

View File

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

View File

@@ -48,20 +48,38 @@ public sealed class ServiceHelperExtractionTests
public void SkillDefinitionValidator_ValidatesRulesetSpecificOptions() public void SkillDefinitionValidator_ValidatesRulesetSpecificOptions()
{ {
var d6 = SkillDefinitionValidator.Validate(RulesetKind.D6, "2D+1", 1, true, null); var d6 = SkillDefinitionValidator.Validate(RulesetKind.D6, "2D+1", 1, true, null);
var rolemaster = SkillDefinitionValidator.Validate(RulesetKind.Rolemaster, "d100!+15", 0, false, 5); var rolemaster = SkillDefinitionValidator.Validate(RulesetKind.Rolemaster, "d100!+15", 0, false, 5, true);
var invalidD6 = SkillDefinitionValidator.Validate(RulesetKind.D6, "2D+1", 0, true, null); var invalidD6 = SkillDefinitionValidator.Validate(RulesetKind.D6, "2D+1", 0, true, null);
var invalidRolemaster = SkillDefinitionValidator.Validate(RulesetKind.Rolemaster, "d100!+15", 0, false, null); var invalidRolemaster = SkillDefinitionValidator.Validate(RulesetKind.Rolemaster, "d100!+15", 0, false, null);
var invalidRetry = SkillDefinitionValidator.Validate(RulesetKind.Rolemaster, "d100+15", 0, false, null, true);
Assert.True(d6.Succeeded); Assert.True(d6.Succeeded);
Assert.Equal(("2D+1", 1, true, (int?)null), d6.Value); Assert.Equal(("2D+1", 1, true, (int?)null, false), d6.Value);
Assert.True(rolemaster.Succeeded); Assert.True(rolemaster.Succeeded);
Assert.Equal(("d100!+15", 0, false, (int?)5), rolemaster.Value); Assert.Equal(("d100!+15", 0, false, (int?)5, true), rolemaster.Value);
Assert.False(invalidD6.Succeeded); Assert.False(invalidD6.Succeeded);
Assert.Equal("invalid_wild_dice", invalidD6.Error!.Code); Assert.Equal("invalid_wild_dice", invalidD6.Error!.Code);
Assert.False(invalidRolemaster.Succeeded); Assert.False(invalidRolemaster.Succeeded);
Assert.Equal("invalid_fumble_range", invalidRolemaster.Error!.Code); Assert.Equal("invalid_fumble_range", invalidRolemaster.Error!.Code);
Assert.False(invalidRetry.Succeeded);
Assert.Equal("invalid_rolemaster_retry", invalidRetry.Error!.Code);
}
[Fact]
public void RolemasterRetryPolicy_ResolvesRetryBandsAndMarkers()
{
Assert.Equal(5, RolemasterRetryPolicy.ResolveAutoRetryBonus(76));
Assert.Equal(5, RolemasterRetryPolicy.ResolveAutoRetryBonus(90));
Assert.Equal(10, RolemasterRetryPolicy.ResolveAutoRetryBonus(91));
Assert.Equal(10, RolemasterRetryPolicy.ResolveAutoRetryBonus(110));
Assert.Null(RolemasterRetryPolicy.ResolveAutoRetryBonus(75));
Assert.Null(RolemasterRetryPolicy.ResolveAutoRetryBonus(111));
Assert.Equal(5, RolemasterRetryPolicy.TryExtractRetryBonus("68+10=78; retry(+5): 42+10=52; final=57"));
Assert.Equal(10, RolemasterRetryPolicy.TryExtractRetryBonus("90+1=91; retry(+10): 32+1=33; final=43"));
Assert.Null(RolemasterRetryPolicy.TryExtractRetryBonus("68+10=78"));
} }
} }

View File

@@ -1,4 +1,4 @@
namespace RpgRoller.Tests; namespace RpgRoller.Tests;
public sealed class ServicePersistenceTests public sealed class ServicePersistenceTests
{ {
@@ -32,12 +32,16 @@ public sealed class ServicePersistenceTests
Assert.False(service.UpdateCharacter(string.Empty, ownerCharacter.Id, "Renamed", campaign.Id).Succeeded); Assert.False(service.UpdateCharacter(string.Empty, ownerCharacter.Id, "Renamed", campaign.Id).Succeeded);
Assert.False(service.UpdateCharacter(ownerSession, Guid.NewGuid(), "Renamed", campaign.Id).Succeeded); Assert.False(service.UpdateCharacter(ownerSession, Guid.NewGuid(), "Renamed", campaign.Id).Succeeded);
Assert.False(service.ActivateCharacter(string.Empty, ownerCharacter.Id).Succeeded); Assert.False(service.ActivateCharacter(string.Empty, ownerCharacter.Id).Succeeded);
Assert.False(service.ActivateCharacter(gmSession, ownerCharacter.Id).Succeeded); Assert.True(service.ActivateCharacter(gmSession, ownerCharacter.Id).Succeeded);
Assert.False(service.GetOwnCharacters(string.Empty).Succeeded); Assert.False(service.GetOwnCharacters(string.Empty).Succeeded);
Assert.False(service.CreateSkill(string.Empty, ownerCharacter.Id, "Stealth", "2D+1", 1, true).Succeeded); Assert.False(service.CreateSkill(string.Empty, ownerCharacter.Id, "Stealth", "2D+1", 1, true).Succeeded);
Assert.False(service.CreateSkill(ownerSession, Guid.NewGuid(), "Stealth", "2D+1", 1, true).Succeeded); Assert.False(service.CreateSkill(ownerSession, Guid.NewGuid(), "Stealth", "2D+1", 1, true).Succeeded);
Assert.False(service.CreateSkill(otherSession, ownerCharacter.Id, "Stealth", "2D+1", 1, true).Succeeded); Assert.False(service.CreateSkill(otherSession, ownerCharacter.Id, "Stealth", "2D+1", 1, true).Succeeded);
var gmMe = ServiceTestSupport.GetValue(service.GetMe(gmSession));
Assert.Equal(ownerCharacter.Id, gmMe.ActiveCharacterId);
Assert.Equal(campaign.Id, gmMe.CurrentCampaignId);
using (var db = harness.CreateDbContext()) using (var db = harness.CreateDbContext())
{ {
var ownerUser = db.Users.Single(u => u.UsernameNormalized == "OWNER"); var ownerUser = db.Users.Single(u => u.UsernameNormalized == "OWNER");
@@ -94,7 +98,7 @@ public sealed class ServicePersistenceTests
} }
[Fact] [Fact]
public void RolemasterFumbleRange_PersistsAcrossDatabaseReload() public void RolemasterSkillOptions_PersistAcrossDatabaseReload()
{ {
using var harness = ServiceTestSupport.CreateHarness(); using var harness = ServiceTestSupport.CreateHarness();
var service = harness.Service; var service = harness.Service;
@@ -108,7 +112,7 @@ public sealed class ServicePersistenceTests
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Rolemaster Persistence", "rolemaster")); var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Rolemaster Persistence", "rolemaster"));
var character = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Loremaster", campaign.Id)); var character = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Loremaster", campaign.Id));
var group = ServiceTestSupport.GetValue(service.CreateSkillGroup(ownerSession, character.Id, "Perception", "d100!+25", 0, false, 5)); var group = ServiceTestSupport.GetValue(service.CreateSkillGroup(ownerSession, character.Id, "Perception", "d100!+25", 0, false, 5));
var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, character.Id, "Read Runes", "d100!+35", 0, false, group.Id, 3)); var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, character.Id, "Read Runes", "d100!+35", 0, false, group.Id, 3, true));
using var reloadedHarness = ServiceTestSupport.CreateHarnessFromPath(harness.DbPath); using var reloadedHarness = ServiceTestSupport.CreateHarnessFromPath(harness.DbPath);
var reloadedSheet = ServiceTestSupport.GetValue(reloadedHarness.Service.GetCharacterSheet(ownerSession, character.Id)); var reloadedSheet = ServiceTestSupport.GetValue(reloadedHarness.Service.GetCharacterSheet(ownerSession, character.Id));
@@ -118,5 +122,24 @@ public sealed class ServicePersistenceTests
var reloadedSkill = Assert.Single(reloadedSheet.Skills, current => current.Id == skill.Id); var reloadedSkill = Assert.Single(reloadedSheet.Skills, current => current.Id == skill.Id);
Assert.Equal(3, reloadedSkill.FumbleRange); Assert.Equal(3, reloadedSkill.FumbleRange);
Assert.True(reloadedSkill.RolemasterAutoRetry);
}
[Fact]
public void UserThemePreference_PersistsAcrossDatabaseReload()
{
using var harness = ServiceTestSupport.CreateHarness();
var service = harness.Service;
service.Register("theme-persist", "Password123", "Theme Persist");
var session = ServiceTestSupport.GetValue(service.Login("theme-persist", "Password123")).SessionToken;
var updated = ServiceTestSupport.GetValue(service.UpdateThemePreference(session, "dark"));
Assert.Equal("dark", updated.ThemePreference);
using var reloadedHarness = ServiceTestSupport.CreateHarnessFromPath(harness.DbPath);
var me = ServiceTestSupport.GetValue(reloadedHarness.Service.GetMe(session));
Assert.Equal("dark", me.User.ThemePreference);
} }
} }

View File

@@ -62,6 +62,24 @@ public sealed class ServiceRolemasterRollTests
Assert.Equal(73, die.SignedContribution); Assert.Equal(73, die.SignedContribution);
} }
[Fact]
public void RollSkill_RolemasterStandardSingleDie_AppliesSituationalModifier()
{
using var harness = ServiceTestSupport.CreateHarness(73);
var service = harness.Service;
service.Register("gm-percentile-bonus", "Password123", "GM");
var session = ServiceTestSupport.GetValue(service.Login("gm-percentile-bonus", "Password123")).SessionToken;
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(session, "Rolemaster", "rolemaster"));
var character = ServiceTestSupport.GetValue(service.CreateCharacter(session, "Hero", campaign.Id));
var skill = ServiceTestSupport.GetValue(service.CreateSkill(session, character.Id, "Perception", "d100-15", 0, false));
var roll = ServiceTestSupport.GetValue(service.RollSkill(session, skill.Id, "public", 20));
Assert.Equal(78, roll.Result);
Assert.Equal("73-15+20=78", roll.Breakdown);
}
[Fact] [Fact]
public void RollSkill_RolemasterOpenEndedHigh_RecursesAndBuildsReadableLogSummary() public void RollSkill_RolemasterOpenEndedHigh_RecursesAndBuildsReadableLogSummary()
{ {
@@ -174,4 +192,126 @@ public sealed class ServiceRolemasterRollTests
Assert.Equal("r66", badge); Assert.Equal("r66", badge);
Assert.Equal("66 | rolemaster", logEntry.SummaryText); Assert.Equal("66 | rolemaster", logEntry.SummaryText);
} }
[Fact]
public void RollSkill_RolemasterAutoRetryPlusFive_UsesRetryResultAndMarksAttempts()
{
using var harness = ServiceTestSupport.CreateHarness(66, 42);
var service = harness.Service;
service.Register("gm-retry-five", "Password123", "GM");
var session = ServiceTestSupport.GetValue(service.Login("gm-retry-five", "Password123")).SessionToken;
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(session, "Rolemaster Retry", "rolemaster"));
var character = ServiceTestSupport.GetValue(service.CreateCharacter(session, "Hero", campaign.Id));
var skill = ServiceTestSupport.GetValue(service.CreateSkill(session, character.Id, "Awareness", "d100!+10", 0, false, null, 5, true));
var roll = ServiceTestSupport.GetValue(service.RollSkill(session, skill.Id, "public"));
var detail = ServiceTestSupport.GetValue(service.GetRollDetail(session, roll.RollId));
var logEntry = Assert.Single(ServiceTestSupport.GetValue(service.GetCampaignLogPage(session, campaign.Id, limit: 5)).Entries);
Assert.Equal(57, roll.Result);
Assert.Equal("66+10=76; retry(+5): 42+10=52; final=57", roll.Breakdown);
Assert.Equal("66 | open-ended | retry +5", logEntry.SummaryText);
Assert.Equal(["r66", "rs5"], Assert.IsType<string[]>(logEntry.EventBadges));
Assert.Equal(roll.Breakdown, detail.Breakdown);
Assert.Collection(detail.Dice, die =>
{
Assert.Equal(66, die.Roll);
Assert.Equal(1, die.Sequence);
Assert.Equal(1, die.Attempt);
Assert.Equal(RollDieKinds.RolemasterOpenEndedInitial, die.Kind);
Assert.Equal(66, die.SignedContribution);
}, die =>
{
Assert.Equal(42, die.Roll);
Assert.Equal(1, die.Sequence);
Assert.Equal(2, die.Attempt);
Assert.Equal(RollDieKinds.RolemasterOpenEndedInitial, die.Kind);
Assert.Equal(42, die.SignedContribution);
});
}
[Fact]
public void RollSkill_RolemasterAutoRetry_UsesSituationalModifierInBothAttempts()
{
using var harness = ServiceTestSupport.CreateHarness(8, 42);
var service = harness.Service;
service.Register("gm-retry-situational", "Password123", "GM");
var session = ServiceTestSupport.GetValue(service.Login("gm-retry-situational", "Password123")).SessionToken;
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(session, "Rolemaster Retry", "rolemaster"));
var character = ServiceTestSupport.GetValue(service.CreateCharacter(session, "Hero", campaign.Id));
var skill = ServiceTestSupport.GetValue(service.CreateSkill(session, character.Id, "Observation", "d100!+50", 0, false, null, 5, true));
var roll = ServiceTestSupport.GetValue(service.RollSkill(session, skill.Id, "public", 20));
var logEntry = Assert.Single(ServiceTestSupport.GetValue(service.GetCampaignLogPage(session, campaign.Id, limit: 5)).Entries);
Assert.Equal(117, roll.Result);
Assert.Equal("8+50+20=78; retry(+5): 42+50+20=112; final=117", roll.Breakdown);
Assert.Equal("8 | open-ended | retry +5", logEntry.SummaryText);
Assert.Equal(["rs5"], Assert.IsType<string[]>(logEntry.EventBadges));
Assert.All(roll.Dice, die => Assert.True(die.Attempt is 1 or 2));
}
[Fact]
public void RollSkill_RolemasterAutoRetryPlusTen_UsesRetryResultAndMarksAttempts()
{
using var harness = ServiceTestSupport.CreateHarness(90, 32);
var service = harness.Service;
service.Register("gm-retry-ten", "Password123", "GM");
var session = ServiceTestSupport.GetValue(service.Login("gm-retry-ten", "Password123")).SessionToken;
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(session, "Rolemaster Retry", "rolemaster"));
var character = ServiceTestSupport.GetValue(service.CreateCharacter(session, "Hero", campaign.Id));
var skill = ServiceTestSupport.GetValue(service.CreateSkill(session, character.Id, "Awareness", "d100!+1", 0, false, null, 5, true));
var roll = ServiceTestSupport.GetValue(service.RollSkill(session, skill.Id, "public"));
var logEntry = Assert.Single(ServiceTestSupport.GetValue(service.GetCampaignLogPage(session, campaign.Id, limit: 5)).Entries);
Assert.Equal(43, roll.Result);
Assert.Equal("90+1=91; retry(+10): 32+1=33; final=43", roll.Breakdown);
Assert.Equal("90 | open-ended | retry +10", logEntry.SummaryText);
Assert.Equal(["rs10"], Assert.IsType<string[]>(logEntry.EventBadges));
Assert.All(roll.Dice, die => Assert.True(die.Attempt is 1 or 2));
}
[Fact]
public void RollSkill_RolemasterAutoRetryDisabled_KeepsOriginalResult()
{
using var harness = ServiceTestSupport.CreateHarness(65);
var service = harness.Service;
service.Register("gm-retry-off", "Password123", "GM");
var session = ServiceTestSupport.GetValue(service.Login("gm-retry-off", "Password123")).SessionToken;
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(session, "Rolemaster Retry", "rolemaster"));
var character = ServiceTestSupport.GetValue(service.CreateCharacter(session, "Hero", campaign.Id));
var skill = ServiceTestSupport.GetValue(service.CreateSkill(session, character.Id, "Awareness", "d100!+10", 0, false, null, 5));
var roll = ServiceTestSupport.GetValue(service.RollSkill(session, skill.Id, "public"));
var logEntry = Assert.Single(ServiceTestSupport.GetValue(service.GetCampaignLogPage(session, campaign.Id, limit: 5)).Entries);
Assert.Equal(75, roll.Result);
Assert.Equal("65+10=75", roll.Breakdown);
Assert.Equal("65 | open-ended", logEntry.SummaryText);
Assert.Null(logEntry.EventBadges);
Assert.All(roll.Dice, die => Assert.Null(die.Attempt));
}
[Fact]
public void RollSkill_SituationalModifier_IsRejectedForNonRolemasterCampaigns()
{
using var harness = ServiceTestSupport.CreateHarness(12);
var service = harness.Service;
service.Register("gm-non-rolemaster-modifier", "Password123", "GM");
var session = ServiceTestSupport.GetValue(service.Login("gm-non-rolemaster-modifier", "Password123")).SessionToken;
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(session, "DnD", "dnd5e"));
var character = ServiceTestSupport.GetValue(service.CreateCharacter(session, "Hero", campaign.Id));
var skill = ServiceTestSupport.GetValue(service.CreateSkill(session, character.Id, "Attack", "1d20+5", 0, false));
var roll = service.RollSkill(session, skill.Id, "public", 20);
Assert.False(roll.Succeeded);
Assert.Equal("invalid_situational_modifier", roll.Error!.Code);
}
} }

View File

@@ -18,8 +18,12 @@ public sealed class ServiceRollHelperTests
{ {
Assert.Equal("4+5+2=11", RollBreakdownFormatter.BuildBreakdown([4, 5], 2, 11)); Assert.Equal("4+5+2=11", RollBreakdownFormatter.BuildBreakdown([4, 5], 2, 11));
Assert.Equal("0=0", RollBreakdownFormatter.BuildBreakdown([], 0, 0)); Assert.Equal("0=0", RollBreakdownFormatter.BuildBreakdown([], 0, 0));
Assert.Equal("4+5+2+7=18", RollBreakdownFormatter.BuildRolemasterModifierBreakdown([4, 5], 2, 7, 18));
Assert.Equal("97+96+45+85=323", RollBreakdownFormatter.BuildRolemasterOpenEndedBreakdown(97, [96, 45], false, 85, 323)); Assert.Equal("97+96+45+85=323", RollBreakdownFormatter.BuildRolemasterOpenEndedBreakdown(97, [96, 45], false, 85, 323));
Assert.Equal("8+50+20=78", RollBreakdownFormatter.BuildRolemasterOpenEndedBreakdown(8, [], false, 50, 78, 20));
Assert.Equal("(05) -97 -100 -12 +85 = -124", RollBreakdownFormatter.BuildRolemasterOpenEndedBreakdown(5, [97, 100, 12], true, 85, -124)); Assert.Equal("(05) -97 -100 -12 +85 = -124", RollBreakdownFormatter.BuildRolemasterOpenEndedBreakdown(5, [97, 100, 12], true, 85, -124));
Assert.Equal("(05) -97 -100 -12 +85 +20 = -104", RollBreakdownFormatter.BuildRolemasterOpenEndedBreakdown(5, [97, 100, 12], true, 85, -104, 20));
Assert.Equal("66+10=76; retry(+5): 42+10=52; final=57", RollBreakdownFormatter.BuildRolemasterRetryBreakdown("66+10=76", 5, "42+10=52", 57));
Assert.Equal("05", RollBreakdownFormatter.FormatRolemasterTriggerRoll(5)); Assert.Equal("05", RollBreakdownFormatter.FormatRolemasterTriggerRoll(5));
} }
@@ -28,15 +32,19 @@ public sealed class ServiceRollHelperTests
{ {
var d6Dice = new[] { new RollDieResult(6, true, false, true, false, false), new RollDieResult(1, false, true, true, false, false) }; var d6Dice = new[] { new RollDieResult(6, true, false, true, false, false), new RollDieResult(1, false, true, true, false, false) };
var rolemasterDice = new[] { new RollDieResult(5, false, false, false, false, false, 1, RollDieKinds.RolemasterOpenEndedInitial), new RollDieResult(97, false, false, false, false, false, 2, RollDieKinds.RolemasterOpenEndedLowSubtract, -97), new RollDieResult(100, false, false, false, false, false, 3, RollDieKinds.RolemasterOpenEndedLowSubtract, -100) }; var rolemasterDice = new[] { new RollDieResult(5, false, false, false, false, false, 1, RollDieKinds.RolemasterOpenEndedInitial), new RollDieResult(97, false, false, false, false, false, 2, RollDieKinds.RolemasterOpenEndedLowSubtract, -97), new RollDieResult(100, false, false, false, false, false, 3, RollDieKinds.RolemasterOpenEndedLowSubtract, -100) };
var retryDice = new[] { new RollDieResult(66, false, false, false, false, false, 1, RollDieKinds.RolemasterOpenEndedInitial, 66, 1), new RollDieResult(42, false, false, false, false, false, 1, RollDieKinds.RolemasterOpenEndedInitial, 42, 2) };
const string retryBreakdown = "66+10=76; retry(+5): 42+10=52; final=57";
Assert.Equal("1d20+5", CampaignLogSummaryBuilder.ExtractCustomRollExpression("1d20+5 => 20+5=25", " => ")); Assert.Equal("1d20+5", CampaignLogSummaryBuilder.ExtractCustomRollExpression("1d20+5 => 20+5=25", " => "));
Assert.Null(CampaignLogSummaryBuilder.ExtractCustomRollExpression("20+5=25", " => ")); Assert.Null(CampaignLogSummaryBuilder.ExtractCustomRollExpression("20+5=25", " => "));
Assert.Equal("6, 1", CampaignLogSummaryBuilder.BuildCompactLogSummary(d6Dice)); Assert.Equal("6, 1", CampaignLogSummaryBuilder.BuildCompactLogSummary(d6Dice));
Assert.Equal("(05) -97 -100 | open-ended low", CampaignLogSummaryBuilder.BuildCompactLogSummary(rolemasterDice)); Assert.Equal("(05) -97 -100 | open-ended low", CampaignLogSummaryBuilder.BuildCompactLogSummary(rolemasterDice));
Assert.Equal("66 | open-ended | retry +5", CampaignLogSummaryBuilder.BuildCompactLogSummary(retryDice, retryBreakdown));
Assert.Equal("No detail available.", CampaignLogSummaryBuilder.BuildCompactLogSummary([])); Assert.Equal("No detail available.", CampaignLogSummaryBuilder.BuildCompactLogSummary([]));
Assert.Equal(["w6", "w1"], Assert.IsType<string[]>(CampaignLogSummaryBuilder.BuildCompactLogEventBadges(RulesetKind.D6, null, d6Dice))); Assert.Equal(["w6", "w1"], Assert.IsType<string[]>(CampaignLogSummaryBuilder.BuildCompactLogEventBadges(RulesetKind.D6, null, d6Dice)));
Assert.Equal(["n20"], Assert.IsType<string[]>(CampaignLogSummaryBuilder.BuildCompactLogEventBadges(RulesetKind.Dnd5e, "1d20+5", [new(20, false, false, false, false, false)]))); Assert.Equal(["n20"], Assert.IsType<string[]>(CampaignLogSummaryBuilder.BuildCompactLogEventBadges(RulesetKind.Dnd5e, "1d20+5", [new(20, false, false, false, false, false)])));
Assert.Equal(["rf", "r100"], Assert.IsType<string[]>(CampaignLogSummaryBuilder.BuildCompactLogEventBadges(RulesetKind.Rolemaster, "d100!+85", rolemasterDice))); Assert.Equal(["rf", "r100"], Assert.IsType<string[]>(CampaignLogSummaryBuilder.BuildCompactLogEventBadges(RulesetKind.Rolemaster, "d100!+85", rolemasterDice)));
Assert.Equal(["r66", "rs5"], Assert.IsType<string[]>(CampaignLogSummaryBuilder.BuildCompactLogEventBadges(RulesetKind.Rolemaster, "d100!+10", retryDice, retryBreakdown)));
} }
[Fact] [Fact]

View File

@@ -324,7 +324,8 @@ public sealed class ServiceSharedHelperTests
DiceRollDefinition = "d100!+25", DiceRollDefinition = "d100!+25",
WildDice = 0, WildDice = 0,
AllowFumble = false, AllowFumble = false,
FumbleRange = 3 FumbleRange = 3,
RolemasterAutoRetry = true
}; };
store.RebuildCampaignStateLocked(); store.RebuildCampaignStateLocked();
store.TouchRosterLocked(campaignId); store.TouchRosterLocked(campaignId);
@@ -371,6 +372,7 @@ public sealed class ServiceSharedHelperTests
Assert.Single(sheet.Skills); Assert.Single(sheet.Skills);
Assert.Equal(5, groupSummary.FumbleRange); Assert.Equal(5, groupSummary.FumbleRange);
Assert.Equal(3, skillSummary.FumbleRange); Assert.Equal(3, skillSummary.FumbleRange);
Assert.True(skillSummary.RolemasterAutoRetry);
Assert.Equal("private", rollResult.Visibility); Assert.Equal("private", rollResult.Visibility);
Assert.Equal("Owner", logDto.RollerDisplayName); Assert.Equal("Owner", logDto.RollerDisplayName);
Assert.Equal("private-self", logListDto.VisibilityStyle); Assert.Equal("private-self", logListDto.VisibilityStyle);

View File

@@ -224,5 +224,12 @@ public sealed class ServiceSkillGroupAndOwnershipTests
Assert.Equal(0, openEndedSkill.WildDice); Assert.Equal(0, openEndedSkill.WildDice);
Assert.False(openEndedSkill.AllowFumble); Assert.False(openEndedSkill.AllowFumble);
Assert.Equal(5, openEndedSkill.FumbleRange); Assert.Equal(5, openEndedSkill.FumbleRange);
var invalidRetrySkill = service.UpdateSkill(ownerSession, percentileSkill.Id, "Perception", "d100+15", 0, false, rolemasterGroup.Id, null, true);
Assert.False(invalidRetrySkill.Succeeded);
Assert.Equal("invalid_rolemaster_retry", invalidRetrySkill.Error!.Code);
var retrySkill = ServiceTestSupport.GetValue(service.UpdateSkill(ownerSession, percentileSkill.Id, "Perception", "d100!+85", 0, false, rolemasterGroup.Id, 5, true));
Assert.True(retrySkill.RolemasterAutoRetry);
} }
} }

View File

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

View File

@@ -27,20 +27,21 @@ public sealed class WorkspaceStateTests
[Fact] [Fact]
public void SkillDefinitionLabel_FormatsD6RolemasterAndDefaultRulesets() public void SkillDefinitionLabel_FormatsD6RolemasterAndDefaultRulesets()
{ {
var skill = new CharacterSheetSkill(Guid.NewGuid(), null, "Awareness", "d100!+15", 1, true, 5); var skill = new CharacterSheetSkill(Guid.NewGuid(), null, "Awareness", "d100!+15", 1, true, 5, true);
var state = new WorkspaceState { SelectedCampaign = new(Guid.NewGuid(), "Alpha", "d6", new(Guid.NewGuid(), "GM"), []) }; var state = new WorkspaceState
{ SelectedCampaign = new(Guid.NewGuid(), "Alpha", "d6", new(Guid.NewGuid(), "GM"), []) };
Assert.Equal("d100!+15, wild 1, fumble on", state.SkillDefinitionLabel(skill)); Assert.Equal("d100!+15, wild 1, fumble on", state.SkillDefinitionLabel(skill));
state.SelectedCampaign = new(Guid.NewGuid(), "Alpha", "rolemaster", new(Guid.NewGuid(), "GM"), []); state.SelectedCampaign = new(Guid.NewGuid(), "Alpha", "rolemaster", new(Guid.NewGuid(), "GM"), []);
Assert.Equal("Open-ended percentile: d100!+15, fumble <= 5", state.SkillDefinitionLabel(skill)); Assert.Equal("Open-ended percentile: d100!+15, fumble <= 5, auto retry", state.SkillDefinitionLabel(skill));
state.SelectedCampaign = new(Guid.NewGuid(), "Alpha", "dnd5e", new(Guid.NewGuid(), "GM"), []); state.SelectedCampaign = new(Guid.NewGuid(), "Alpha", "dnd5e", new(Guid.NewGuid(), "GM"), []);
Assert.Equal("d100!+15", state.SkillDefinitionLabel(skill)); Assert.Equal("d100!+15", state.SkillDefinitionLabel(skill));
} }
[Fact] [Fact]
public void PlaySelections_FilterToOwnedCharactersAndPreferSelectedThenActive() public void PlaySelections_ForNonGm_FilterToOwnedCharactersAndPreferSelectedThenActive()
{ {
var userId = Guid.NewGuid(); var userId = Guid.NewGuid();
var ownedCharacter = new CharacterSummary(Guid.NewGuid(), "Owned", userId, Guid.NewGuid(), "User"); var ownedCharacter = new CharacterSummary(Guid.NewGuid(), "Owned", userId, Guid.NewGuid(), "User");
@@ -49,10 +50,11 @@ public sealed class WorkspaceStateTests
var state = new WorkspaceState var state = new WorkspaceState
{ {
User = new(userId, "user", "User", []), User = new(userId, "user", "User", []),
SelectedCampaign = new(Guid.NewGuid(), "Alpha", "d6", new(Guid.NewGuid(), "GM"), [ownedCharacter, secondOwnedCharacter, otherCharacter]), SelectedCampaign = new(Guid.NewGuid(), "Alpha", "d6", new(Guid.NewGuid(), "GM"),
[ownedCharacter, secondOwnedCharacter, otherCharacter]),
SelectedCharacterId = secondOwnedCharacter.Id, SelectedCharacterId = secondOwnedCharacter.Id,
ActiveCharacterId = ownedCharacter.Id, ActiveCharacterId = ownedCharacter.Id,
SelectedCharacterSkills = [new(Guid.NewGuid(), null, "Stealth", "2D+1", 1, true, null)], SelectedCharacterSkills = [new(Guid.NewGuid(), null, "Stealth", "2D+1", 1, true, null, false)],
SelectedCharacterSkillGroups = [new(Guid.NewGuid(), "Combat", "2D+1", 1, true, null)] SelectedCharacterSkillGroups = [new(Guid.NewGuid(), "Combat", "2D+1", 1, true, null)]
}; };
@@ -69,33 +71,45 @@ public sealed class WorkspaceStateTests
} }
[Fact] [Fact]
public void ScreenAndConnectionFlags_ReflectCurrentState() public void PlaySelections_ForGm_ExposeEntireCampaignAndKeepNonOwnedSelection()
{
var gmId = Guid.NewGuid();
var otherCharacter = new CharacterSummary(Guid.NewGuid(), "Other", Guid.NewGuid(), Guid.NewGuid(), "Other");
var ownedCharacter = new CharacterSummary(Guid.NewGuid(), "Owned", gmId, Guid.NewGuid(), "GM");
var state = new WorkspaceState
{
User = new(gmId, "gm", "GM", []),
SelectedCampaign = new(Guid.NewGuid(), "Alpha", "d6", new(gmId, "GM"),
[ownedCharacter, otherCharacter]),
SelectedCharacterId = otherCharacter.Id
};
Assert.Equal(2, state.PlaySelectedCampaign!.Characters.Length);
Assert.Equal(otherCharacter.Id, state.PlaySelectedCharacterId);
Assert.Equal(otherCharacter.Id, state.PlaySelectedCharacter!.Id);
}
[Fact]
public void CampaignAndConnectionFlags_ReflectCurrentState()
{ {
var adminId = Guid.NewGuid(); var adminId = Guid.NewGuid();
var state = new WorkspaceState var state = new WorkspaceState
{ {
User = new(adminId, "admin", "Admin", [UserRoles.Admin]), User = new(adminId, "admin", "Admin", [UserRoles.Admin]),
SelectedCampaign = new(Guid.NewGuid(), "Alpha", "d6", new(adminId, "Admin"), []), SelectedCampaign = new(Guid.NewGuid(), "Alpha", "d6", new(adminId, "Admin"), []),
CurrentScreen = "admin",
ConnectionState = "reconnecting" ConnectionState = "reconnecting"
}; };
Assert.True(state.IsAdminScreen);
Assert.False(state.IsPlayScreen);
Assert.True(state.IsCurrentUserAdmin); Assert.True(state.IsCurrentUserAdmin);
Assert.True(state.IsCurrentUserGm); Assert.True(state.IsCurrentUserGm);
Assert.True(state.CanDeleteSelectedCampaign); Assert.True(state.CanDeleteSelectedCampaign);
Assert.True(state.IsSelectedCampaignD6); Assert.True(state.IsSelectedCampaignD6);
Assert.Equal("Reconnecting", state.ConnectionStateLabel); Assert.Equal("Reconnecting", state.ConnectionStateLabel);
Assert.Equal("warn", state.ConnectionStateCssClass); Assert.Equal("warn", state.ConnectionStateCssClass);
Assert.Equal("rr-app", state.AppCssClass);
state.CurrentScreen = "play";
state.ConnectionState = "connected"; state.ConnectionState = "connected";
Assert.True(state.IsPlayScreen);
Assert.Equal("Connected", state.ConnectionStateLabel); Assert.Equal("Connected", state.ConnectionStateLabel);
Assert.Equal("ok", state.ConnectionStateCssClass); Assert.Equal("ok", state.ConnectionStateCssClass);
Assert.Equal("rr-app app-play", state.AppCssClass);
} }
} }

View File

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

View File

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

View File

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

View File

@@ -9,13 +9,13 @@ 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, request.SkillGroupId, request.FumbleRange); var result = game.CreateSkill(context.GetRequiredSessionToken(), characterId, request.Name, request.DiceRollDefinition, request.WildDice, request.AllowFumble, request.SkillGroupId, request.FumbleRange, request.RolemasterAutoRetry);
return ApiResultMapper.ToApiResult(result); 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, request.SkillGroupId, request.FumbleRange); var result = game.UpdateSkill(context.GetRequiredSessionToken(), skillId, request.Name, request.DiceRollDefinition, request.WildDice, request.AllowFumble, request.SkillGroupId, request.FumbleRange, request.RolemasterAutoRetry);
return ApiResultMapper.ToApiResult(result); return ApiResultMapper.ToApiResult(result);
}); });
@@ -45,7 +45,7 @@ internal static class SkillEndpoints
group.MapPost("/skills/{skillId:guid}/roll", (Guid skillId, RollSkillRequest request, HttpContext context, IGameService game) => group.MapPost("/skills/{skillId:guid}/roll", (Guid skillId, RollSkillRequest request, HttpContext context, IGameService game) =>
{ {
var result = game.RollSkill(context.GetRequiredSessionToken(), skillId, request.Visibility); var result = game.RollSkill(context.GetRequiredSessionToken(), skillId, request.Visibility, request.SituationalModifier);
return ApiResultMapper.ToApiResult(result); return ApiResultMapper.ToApiResult(result);
}); });

Binary file not shown.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -48,6 +48,7 @@ public sealed class SkillFormModel
public int WildDice { get; set; } public int WildDice { get; set; }
public bool AllowFumble { get; set; } public bool AllowFumble { get; set; }
public int? FumbleRange { get; set; } public int? FumbleRange { get; set; }
public bool RolemasterAutoRetry { get; set; }
} }
public sealed class SkillGroupFormModel public sealed class SkillGroupFormModel

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
<section class="card character-panel"> <section class="card character-panel">
@if (IsCampaignDataLoading) @if (IsCampaignDataLoading)
{ {
<div class="skeleton-stack"> <div class="skeleton-stack">
@@ -9,7 +9,7 @@
} }
else if (SelectedCampaign is null) else if (SelectedCampaign is null)
{ {
<p class="empty">No campaign selected. Choose one in Campaign Management.</p> <p class="empty">No campaign selected. Choose one in the header.</p>
} }
else if (SelectedCampaign.Characters.Length == 0) else if (SelectedCampaign.Characters.Length == 0)
{ {
@@ -59,7 +59,9 @@
</div> </div>
<div class="chip-toolbar"> <div class="chip-toolbar">
<label class="visibility-control" for="roll-visibility">Visibility</label> <label class="visibility-control" for="roll-visibility">Visibility</label>
<select id="roll-visibility" value="@(RollVisibility == "private" ? "private" : "public")" @onchange="OnRollVisibilityChangedAsync"> <select id="roll-visibility"
value="@(RollVisibility == "private" ? "private" : "public")"
@onchange="OnRollVisibilityChangedAsync">
<option value="public">Public</option> <option value="public">Public</option>
<option value="private">Private</option> <option value="private">Private</option>
</select> </select>

View File

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

View File

@@ -0,0 +1,34 @@
@if (Visible)
{
<div class="modal-overlay" role="presentation" @onclick="HandleOverlayClickAsync">
<section class="modal-card rolemaster-roll-modal"
role="dialog"
aria-modal="true"
aria-label="Rolemaster situational modifier"
tabindex="-1"
@onclick:stopPropagation="true"
@onkeydown="HandleKeyDownAsync">
<h2>Rolemaster skill roll</h2>
<p class="muted">Roll <strong>@SkillName</strong> using <code>@Expression</code>.</p>
@if (!string.IsNullOrWhiteSpace(ErrorMessage))
{
<p class="form-error">@ErrorMessage</p>
}
<form class="form-grid" @onsubmit="SubmitAsync" @onsubmit:preventDefault>
<label for="@ModifierInputId">Situational modifier</label>
<input id="@ModifierInputId"
@ref="ModifierInputElement"
value="@CurrentModifierText"
@oninput="OnModifierInput"
placeholder="Blank = 0"
inputmode="numeric"
autocomplete="off"/>
<p class="field-help">Optional one-shot bonus or penalty. Examples: <code>20</code>, <code>-15</code>, or blank for <code>0</code>.</p>
<div class="inline-actions">
<button type="submit" disabled="@(IsMutating || IsSubmitting)">Roll</button>
<button type="button" class="ghost" disabled="@(IsMutating || IsSubmitting)" @onclick="CancelRequested">Cancel</button>
</div>
</form>
</section>
</div>
}

View File

@@ -0,0 +1,85 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
namespace RpgRoller.Components.Pages.HomeControls;
[ExcludeFromCodeCoverage]
public partial class RolemasterSkillRollModal
{
protected override void OnParametersSet()
{
CurrentModifierText = ModifierText;
if (!Visible || WasVisible)
{
WasVisible = Visible;
return;
}
PendingFocus = true;
WasVisible = true;
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!Visible || !PendingFocus)
return;
PendingFocus = false;
await ModifierInputElement.FocusAsync();
}
private Task OnModifierInput(ChangeEventArgs args)
{
CurrentModifierText = args.Value?.ToString() ?? string.Empty;
return ModifierTextChanged.InvokeAsync(CurrentModifierText);
}
private Task SubmitAsync()
{
return ConfirmRequested.InvokeAsync(CurrentModifierText);
}
private Task HandleOverlayClickAsync()
{
if (IsMutating || IsSubmitting)
return Task.CompletedTask;
return CancelRequested.InvokeAsync();
}
private Task HandleKeyDownAsync(KeyboardEventArgs args)
{
if ((IsMutating || IsSubmitting) || !string.Equals(args.Key, "Escape", StringComparison.Ordinal))
return Task.CompletedTask;
return CancelRequested.InvokeAsync();
}
private bool PendingFocus { get; set; }
private bool WasVisible { get; set; }
private string CurrentModifierText { get; set; } = string.Empty;
private ElementReference ModifierInputElement { get; set; }
[Parameter] public bool Visible { get; set; }
[Parameter] public string SkillName { get; set; } = string.Empty;
[Parameter] public string Expression { get; set; } = string.Empty;
[Parameter] public string ModifierText { get; set; } = string.Empty;
[Parameter] public EventCallback<string> ModifierTextChanged { get; set; }
[Parameter] public string? ErrorMessage { get; set; }
[Parameter] public bool IsMutating { get; set; }
[Parameter] public bool IsSubmitting { get; set; }
[Parameter] public string ModifierInputId { get; set; } = "rolemaster-situational-modifier";
[Parameter] public EventCallback<string> ConfirmRequested { get; set; }
[Parameter] public EventCallback CancelRequested { get; set; }
}

View File

@@ -90,6 +90,9 @@ public partial class RollDiceStrip
if (die.Sequence.HasValue) if (die.Sequence.HasValue)
labels.Add($"step {die.Sequence.Value}"); labels.Add($"step {die.Sequence.Value}");
if (die.Attempt.HasValue)
labels.Add(die.Attempt.Value == 1 ? "attempt 1" : $"retry attempt {die.Attempt.Value}");
if (die.Wild) if (die.Wild)
labels.Add("wild"); labels.Add("wild");

View File

@@ -30,7 +30,7 @@ internal static class RulesetFormHelpers
return parseResult.Succeeded && parseResult.Value is not null && parseResult.Value.Kind == DiceExpressionKind.RolemasterOpenEndedPercentile; return parseResult.Succeeded && parseResult.Value is not null && parseResult.Value.Kind == DiceExpressionKind.RolemasterOpenEndedPercentile;
} }
public static string DescribeRolemasterExpression(string expression, int? fumbleRange) public static string DescribeRolemasterExpression(string expression, int? fumbleRange, bool rolemasterAutoRetry = false)
{ {
var parseResult = TryParseRolemasterExpression(expression); var parseResult = TryParseRolemasterExpression(expression);
if (!parseResult.Succeeded || parseResult.Value is null) if (!parseResult.Succeeded || parseResult.Value is null)
@@ -38,7 +38,7 @@ internal static class RulesetFormHelpers
return parseResult.Value.Kind switch return parseResult.Value.Kind switch
{ {
DiceExpressionKind.RolemasterOpenEndedPercentile => fumbleRange.HasValue ? $"Open-ended percentile: {parseResult.Value.Canonical}, fumble <= {fumbleRange.Value}" : $"Open-ended percentile: {parseResult.Value.Canonical}", DiceExpressionKind.RolemasterOpenEndedPercentile => DescribeOpenEndedExpression(parseResult.Value.Canonical, fumbleRange, rolemasterAutoRetry),
_ => $"Rolemaster: {parseResult.Value.Canonical}" _ => $"Rolemaster: {parseResult.Value.Canonical}"
}; };
} }
@@ -55,4 +55,17 @@ internal static class RulesetFormHelpers
return DiceRules.ParseExpression(RulesetKind.Rolemaster, expression); return DiceRules.ParseExpression(RulesetKind.Rolemaster, expression);
} }
private static string DescribeOpenEndedExpression(string canonicalExpression, int? fumbleRange, bool rolemasterAutoRetry)
{
var parts = new List<string> { $"Open-ended percentile: {canonicalExpression}" };
if (fumbleRange.HasValue)
parts.Add($"fumble <= {fumbleRange.Value}");
if (rolemasterAutoRetry)
parts.Add("auto retry");
return string.Join(", ", parts);
}
} }

View File

@@ -56,6 +56,10 @@
{ {
<p class="field-error">@fumbleRangeError</p> <p class="field-error">@fumbleRangeError</p>
} }
<label for="skill-auto-retry">Automatic retry</label>
<input id="skill-auto-retry" type="checkbox" @bind="FormState.Model.RolemasterAutoRetry"/>
<p class="field-help">When later enabled in rolling, retry bands are 76-90 and 91-110.</p>
} }
} }
<div class="inline-actions"> <div class="inline-actions">

View File

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

View File

@@ -52,7 +52,7 @@
class="chip-button" class="chip-button"
title="Roll skill" title="Roll skill"
disabled="@(IsMutating)" disabled="@(IsMutating)"
@onclick="() => RollSkillRequested.InvokeAsync(skill.Id)"> @onclick="() => RollSkillRequested.InvokeAsync(skill)">
<span aria-hidden="true" class="emoji">🎲</span> <span aria-hidden="true" class="emoji">🎲</span>
<span class="sr-only">Roll @skill.Name</span> <span class="sr-only">Roll @skill.Name</span>
</button> </button>

View File

@@ -47,7 +47,7 @@ public partial class SkillGroupBlock
public EventCallback<CharacterSheetSkill> EditSkillRequested { get; set; } public EventCallback<CharacterSheetSkill> EditSkillRequested { get; set; }
[Parameter] [Parameter]
public EventCallback<Guid> RollSkillRequested { get; set; } public EventCallback<CharacterSheetSkill> RollSkillRequested { get; set; }
[Parameter] [Parameter]
public EventCallback<Guid> DeleteSkillRequested { get; set; } public EventCallback<Guid> DeleteSkillRequested { get; set; }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,14 +1,22 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using RpgRoller.Components.Pages.HomeControls;
using RpgRoller.Contracts; using RpgRoller.Contracts;
namespace RpgRoller.Components.Pages; namespace RpgRoller.Components.Pages;
[ExcludeFromCodeCoverage] [ExcludeFromCodeCoverage]
public sealed class WorkspacePlayCoordinator(WorkspaceState state, WorkspaceFeedbackService feedback, RpgRollerApiClient apiClient, WorkspaceQueryService workspaceQuery, Func<CharacterSummary, bool> canEditCharacter, Func<Task> requestRefreshAsync) public sealed class WorkspacePlayCoordinator(
WorkspaceState state,
WorkspaceFeedbackService feedback,
Func<bool> isPlayRoute,
RpgRollerApiClient apiClient,
WorkspaceQueryService workspaceQuery,
Func<CharacterSummary, bool> canEditCharacter,
Func<Task> requestRefreshAsync)
{ {
public async Task RefreshCampaignLogAsync(Guid? afterRollId = null) public async Task RefreshCampaignLogAsync(Guid? afterRollId = null)
{ {
if (!state.SelectedCampaignId.HasValue || !state.IsPlayScreen) if (!state.SelectedCampaignId.HasValue || !isPlayRoute())
{ {
state.CampaignLog = []; state.CampaignLog = [];
state.CampaignLogCursor = null; state.CampaignLogCursor = null;
@@ -17,7 +25,8 @@ public sealed class WorkspacePlayCoordinator(WorkspaceState state, WorkspaceFeed
} }
var previousLogCount = state.CampaignLog.Count; var previousLogCount = state.CampaignLog.Count;
var page = await workspaceQuery.GetCampaignLogPageAsync(state.SelectedCampaignId.Value, afterRollId, CampaignLogWindowSize); var page = await workspaceQuery.GetCampaignLogPageAsync(state.SelectedCampaignId.Value, afterRollId,
CampaignLogWindowSize);
Guid? newestRollId = null; Guid? newestRollId = null;
if (!afterRollId.HasValue || page.ResetRequired) if (!afterRollId.HasValue || page.ResetRequired)
state.CampaignLog = page.Entries.ToList(); state.CampaignLog = page.Entries.ToList();
@@ -29,7 +38,8 @@ public sealed class WorkspacePlayCoordinator(WorkspaceState state, WorkspaceFeed
} }
var shouldAutoExpandNewest = afterRollId.HasValue && page.Entries.Length > 0; var shouldAutoExpandNewest = afterRollId.HasValue && page.Entries.Length > 0;
if (!shouldAutoExpandNewest && !afterRollId.HasValue && state.CurrentCampaignState is not null && previousLogCount == 0 && page.Entries.Length > 0) if (!shouldAutoExpandNewest && !afterRollId.HasValue && state.CurrentCampaignState is not null &&
previousLogCount == 0 && page.Entries.Length > 0)
shouldAutoExpandNewest = true; shouldAutoExpandNewest = true;
if (shouldAutoExpandNewest) if (shouldAutoExpandNewest)
@@ -57,7 +67,7 @@ public sealed class WorkspacePlayCoordinator(WorkspaceState state, WorkspaceFeed
public async Task RefreshSelectedCharacterSheetAsync() public async Task RefreshSelectedCharacterSheetAsync()
{ {
if (!state.SelectedCharacterId.HasValue || state.SelectedCampaign is null || !state.IsPlayScreen) if (!state.SelectedCharacterId.HasValue || state.SelectedCampaign is null || !isPlayRoute())
{ {
state.SelectedCharacterSkills = []; state.SelectedCharacterSkills = [];
state.SelectedCharacterSkillGroups = []; state.SelectedCharacterSkillGroups = [];
@@ -65,8 +75,10 @@ public sealed class WorkspacePlayCoordinator(WorkspaceState state, WorkspaceFeed
} }
var sheet = await workspaceQuery.GetCharacterSheetAsync(state.SelectedCharacterId.Value); var sheet = await workspaceQuery.GetCharacterSheetAsync(state.SelectedCharacterId.Value);
state.SelectedCharacterSkillGroups = sheet.SkillGroups.OrderBy(group => group.Name, StringComparer.OrdinalIgnoreCase).ToList(); state.SelectedCharacterSkillGroups =
state.SelectedCharacterSkills = sheet.Skills.OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase).ToList(); sheet.SkillGroups.OrderBy(group => group.Name, StringComparer.OrdinalIgnoreCase).ToList();
state.SelectedCharacterSkills =
sheet.Skills.OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase).ToList();
} }
public Task EnsureSelectedCharacterActiveAsync() public Task EnsureSelectedCharacterActiveAsync()
@@ -143,22 +155,73 @@ public sealed class WorkspacePlayCoordinator(WorkspaceState state, WorkspaceFeed
return Task.CompletedTask; return Task.CompletedTask;
} }
public async Task RollSkillAsync(Guid skillId) public Task RollSkillAsync(CharacterSheetSkill skill)
{ {
if (state.SelectedCampaign is null) if (state.SelectedCampaign is null)
{ {
feedback.SetStatus("No campaign selected.", true); feedback.SetStatus("No campaign selected.", true);
return Task.CompletedTask;
}
if (string.Equals(state.SelectedCampaign.RulesetId, RulesetFormHelpers.RulesetIds.Rolemaster,
StringComparison.OrdinalIgnoreCase))
{
OpenRolemasterSkillRollModal(skill);
return Task.CompletedTask;
}
return ExecuteSkillRollAsync(skill.Id, 0);
}
public async Task SubmitRolemasterSkillRollAsync(string situationalModifierText)
{
if (state.PendingRolemasterSkillRoll is null)
return;
if (!TryParseSituationalModifier(situationalModifierText, out var situationalModifier, out var errorMessage))
{
state.PendingRolemasterSkillRollError = errorMessage;
return; return;
} }
state.PendingRolemasterSituationalModifier = situationalModifierText;
state.PendingRolemasterSkillRollError = null;
state.IsSubmittingRolemasterSkillRoll = true;
try
{
await ExecuteSkillRollAsync(state.PendingRolemasterSkillRoll.Id, situationalModifier,
keepModalOpenOnError: true);
}
finally
{
state.IsSubmittingRolemasterSkillRoll = false;
}
}
public Task CancelRolemasterSkillRollAsync()
{
if (state.IsSubmittingRolemasterSkillRoll)
return Task.CompletedTask;
CloseRolemasterSkillRollModal();
return Task.CompletedTask;
}
private async Task ExecuteSkillRollAsync(Guid skillId, int situationalModifier, bool keepModalOpenOnError = false)
{
state.IsMutating = true; state.IsMutating = true;
try try
{ {
var roll = await apiClient.RequestAsync<RollResult>("POST", $"/api/skills/{skillId}/roll", new RollSkillRequest(state.RollVisibility)); var roll = await apiClient.RequestAsync<RollResult>("POST", $"/api/skills/{skillId}/roll",
new RollSkillRequest(state.RollVisibility, situationalModifier));
CloseRolemasterSkillRollModal();
await HandleRecordedRollAsync(roll); await HandleRecordedRollAsync(roll);
} }
catch (ApiRequestException ex) catch (ApiRequestException ex)
{ {
if (keepModalOpenOnError)
state.PendingRolemasterSkillRollError = ex.Message;
else
feedback.SetStatus(ex.Message, true); feedback.SetStatus(ex.Message, true);
} }
finally finally
@@ -217,13 +280,56 @@ public sealed class WorkspacePlayCoordinator(WorkspaceState state, WorkspaceFeed
state.CurrentCampaignState = null; state.CurrentCampaignState = null;
} }
private void OpenRolemasterSkillRollModal(CharacterSheetSkill skill)
{
state.PendingRolemasterSkillRoll = skill;
state.PendingRolemasterSituationalModifier = string.Empty;
state.PendingRolemasterSkillRollError = null;
state.ShowRolemasterSkillRollModal = true;
}
private void CloseRolemasterSkillRollModal()
{
state.ShowRolemasterSkillRollModal = false;
state.PendingRolemasterSkillRoll = null;
state.PendingRolemasterSituationalModifier = string.Empty;
state.PendingRolemasterSkillRollError = null;
state.IsSubmittingRolemasterSkillRoll = false;
}
private static bool TryParseSituationalModifier(string? text, out int situationalModifier, out string? errorMessage)
{
if (string.IsNullOrWhiteSpace(text))
{
situationalModifier = 0;
errorMessage = null;
return true;
}
if (!int.TryParse(text.Trim(), out situationalModifier))
{
errorMessage = "Enter a whole number like 20, -15, or leave blank for 0.";
return false;
}
if (situationalModifier is < -MaxSituationalModifier or > MaxSituationalModifier)
{
errorMessage = $"Enter a whole number between {-MaxSituationalModifier} and {MaxSituationalModifier}.";
return false;
}
errorMessage = null;
return true;
}
private async Task EnsureSelectedCharacterActiveCoreAsync() private async Task EnsureSelectedCharacterActiveCoreAsync()
{ {
if (!state.SelectedCharacterId.HasValue || state.SelectedCampaign is null) if (!state.SelectedCharacterId.HasValue || state.SelectedCampaign is null)
return; return;
var character = state.SelectedCampaign.Characters.FirstOrDefault(c => c.Id == state.SelectedCharacterId.Value); var character = state.SelectedCampaign.Characters.FirstOrDefault(c => c.Id == state.SelectedCharacterId.Value);
if (character is null || !CanActivateCharacter(character, state.User) || state.ActiveCharacterId == character.Id) if (character is null || !CanActivateCharacter(character) ||
state.ActiveCharacterId == character.Id)
return; return;
try try
@@ -254,13 +360,16 @@ public sealed class WorkspacePlayCoordinator(WorkspaceState state, WorkspaceFeed
{ {
var visibleRollIds = state.CampaignLog.Select(entry => entry.RollId).ToHashSet(); var visibleRollIds = state.CampaignLog.Select(entry => entry.RollId).ToHashSet();
foreach (var rollId in state.CampaignLogDetails.Keys.Where(rollId => !visibleRollIds.Contains(rollId)).ToArray()) foreach (var rollId in state.CampaignLogDetails.Keys.Where(rollId => !visibleRollIds.Contains(rollId))
.ToArray())
state.CampaignLogDetails.Remove(rollId); state.CampaignLogDetails.Remove(rollId);
foreach (var rollId in state.CampaignLogDetailsLoading.Where(rollId => !visibleRollIds.Contains(rollId)).ToArray()) foreach (var rollId in state.CampaignLogDetailsLoading.Where(rollId => !visibleRollIds.Contains(rollId))
.ToArray())
state.CampaignLogDetailsLoading.Remove(rollId); state.CampaignLogDetailsLoading.Remove(rollId);
foreach (var rollId in state.CampaignLogDetailErrors.Keys.Where(rollId => !visibleRollIds.Contains(rollId)).ToArray()) foreach (var rollId in state.CampaignLogDetailErrors.Keys.Where(rollId => !visibleRollIds.Contains(rollId))
.ToArray())
state.CampaignLogDetailErrors.Remove(rollId); state.CampaignLogDetailErrors.Remove(rollId);
if (state.ExpandedCampaignLogRollId.HasValue && !visibleRollIds.Contains(state.ExpandedCampaignLogRollId.Value)) if (state.ExpandedCampaignLogRollId.HasValue && !visibleRollIds.Contains(state.ExpandedCampaignLogRollId.Value))
@@ -301,9 +410,10 @@ public sealed class WorkspacePlayCoordinator(WorkspaceState state, WorkspaceFeed
state.FreshCampaignLogRollId = rollId; state.FreshCampaignLogRollId = rollId;
} }
private static bool CanActivateCharacter(CharacterSummary character, UserSummary? user) private bool CanActivateCharacter(CharacterSummary character)
{ {
return user is not null && character.OwnerUserId == user.Id; return state.User is not null &&
(character.OwnerUserId == state.User.Id || state.IsCurrentUserGm);
} }
private static CampaignRollDetail ToCampaignRollDetail(RollResult roll) private static CampaignRollDetail ToCampaignRollDetail(RollResult roll)
@@ -312,4 +422,5 @@ public sealed class WorkspacePlayCoordinator(WorkspaceState state, WorkspaceFeed
} }
private const int CampaignLogWindowSize = 25; private const int CampaignLogWindowSize = 25;
private const int MaxSituationalModifier = 1000;
} }

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
using RpgRoller.Components.Pages.HomeControls; using RpgRoller.Components.Pages.HomeControls;
using RpgRoller.Contracts; using RpgRoller.Contracts;
using RpgRoller.Domain; using RpgRoller.Domain;
@@ -27,7 +27,7 @@ public sealed class WorkspaceState
if (!string.Equals(SelectedCampaign?.RulesetId, "d6", StringComparison.OrdinalIgnoreCase)) if (!string.Equals(SelectedCampaign?.RulesetId, "d6", StringComparison.OrdinalIgnoreCase))
{ {
if (string.Equals(SelectedCampaign?.RulesetId, RulesetFormHelpers.RulesetIds.Rolemaster, StringComparison.OrdinalIgnoreCase)) if (string.Equals(SelectedCampaign?.RulesetId, RulesetFormHelpers.RulesetIds.Rolemaster, StringComparison.OrdinalIgnoreCase))
return RulesetFormHelpers.DescribeRolemasterExpression(skill.DiceRollDefinition, skill.FumbleRange); return RulesetFormHelpers.DescribeRolemasterExpression(skill.DiceRollDefinition, skill.FumbleRange, skill.RolemasterAutoRetry);
return skill.DiceRollDefinition; return skill.DiceRollDefinition;
} }
@@ -51,6 +51,7 @@ public sealed class WorkspaceState
public RollResult? LastRoll { get; set; } public RollResult? LastRoll { get; set; }
public List<string> KnownUsernames { get; set; } = []; public List<string> KnownUsernames { get; set; } = [];
public string RollVisibility { get; set; } = "public"; public string RollVisibility { get; set; } = "public";
public string ThemePreference { get; set; } = ThemePreferences.Light;
public bool IsMutating { get; set; } public bool IsMutating { get; set; }
public bool IsCampaignDataLoading { get; set; } public bool IsCampaignDataLoading { get; set; }
@@ -59,7 +60,6 @@ public sealed class WorkspaceState
public bool HasHealthIssue { get; set; } public bool HasHealthIssue { get; set; }
public string HealthIssueMessage { get; set; } = "Retry to restore the API connection."; public string HealthIssueMessage { get; set; } = "Retry to restore the API connection.";
public List<WorkspaceToast> Toasts { get; } = []; public List<WorkspaceToast> Toasts { get; } = [];
public string CurrentScreen { get; set; } = "play";
public string MobilePanel { get; set; } = "character"; public string MobilePanel { get; set; } = "character";
public string ConnectionState { get; set; } = "offline"; public string ConnectionState { get; set; } = "offline";
public string LiveAnnouncement { get; set; } = string.Empty; public string LiveAnnouncement { get; set; } = string.Empty;
@@ -67,8 +67,13 @@ public sealed class WorkspaceState
public bool ShowCreateCharacterModal { get; set; } public bool ShowCreateCharacterModal { get; set; }
public bool ShowEditCharacterModal { get; set; } public bool ShowEditCharacterModal { get; set; }
public bool ShowRolemasterSkillRollModal { get; set; }
public bool CanEditCharacterOwner { get; set; } public bool CanEditCharacterOwner { get; set; }
public Guid? EditingCharacterId { get; set; } public Guid? EditingCharacterId { get; set; }
public CharacterSheetSkill? PendingRolemasterSkillRoll { get; set; }
public string PendingRolemasterSituationalModifier { get; set; } = string.Empty;
public string? PendingRolemasterSkillRollError { get; set; }
public bool IsSubmittingRolemasterSkillRoll { get; set; }
public CharacterFormModel CreateCharacterInitialModel { get; set; } = new(); public CharacterFormModel CreateCharacterInitialModel { get; set; } = new();
public CharacterFormModel EditCharacterInitialModel { get; set; } = new(); public CharacterFormModel EditCharacterInitialModel { get; set; } = new();
public int CreateCharacterFormVersion { get; set; } public int CreateCharacterFormVersion { get; set; }
@@ -83,8 +88,6 @@ public sealed class WorkspaceState
public HashSet<Guid> CampaignLogDetailsLoading { get; } = []; public HashSet<Guid> CampaignLogDetailsLoading { get; } = [];
public Dictionary<Guid, string> CampaignLogDetailErrors { get; } = []; public Dictionary<Guid, string> CampaignLogDetailErrors { get; } = [];
public string? SelectedCampaignName => SelectedCampaign?.Name ?? Campaigns.FirstOrDefault(campaign => campaign.Id == SelectedCampaignId)?.Name;
public CharacterSummary? SelectedCharacter => public CharacterSummary? SelectedCharacter =>
SelectedCampaign?.Characters.FirstOrDefault(character => character.Id == SelectedCharacterId); SelectedCampaign?.Characters.FirstOrDefault(character => character.Id == SelectedCharacterId);
@@ -98,6 +101,9 @@ public sealed class WorkspaceState
if (User is null) if (User is null)
return new(SelectedCampaign.Id, SelectedCampaign.Name, SelectedCampaign.RulesetId, SelectedCampaign.Gm, []); return new(SelectedCampaign.Id, SelectedCampaign.Name, SelectedCampaign.RulesetId, SelectedCampaign.Gm, []);
if (IsCurrentUserGm)
return SelectedCampaign;
var ownedCharacters = SelectedCampaign.Characters.Where(character => character.OwnerUserId == User.Id).ToArray(); var ownedCharacters = SelectedCampaign.Characters.Where(character => character.OwnerUserId == User.Id).ToArray();
return new(SelectedCampaign.Id, SelectedCampaign.Name, SelectedCampaign.RulesetId, SelectedCampaign.Gm, ownedCharacters); return new(SelectedCampaign.Id, SelectedCampaign.Name, SelectedCampaign.RulesetId, SelectedCampaign.Gm, ownedCharacters);
@@ -152,10 +158,6 @@ public sealed class WorkspaceState
public bool IsSelectedCampaignD6 => public bool IsSelectedCampaignD6 =>
string.Equals(SelectedCampaign?.RulesetId, "d6", StringComparison.OrdinalIgnoreCase); string.Equals(SelectedCampaign?.RulesetId, "d6", StringComparison.OrdinalIgnoreCase);
public bool IsPlayScreen => string.Equals(CurrentScreen, "play", StringComparison.OrdinalIgnoreCase);
public bool IsManagementScreen => string.Equals(CurrentScreen, "management", StringComparison.OrdinalIgnoreCase);
public bool IsAdminScreen => string.Equals(CurrentScreen, "admin", StringComparison.OrdinalIgnoreCase);
public string ConnectionStateLabel => ConnectionState switch public string ConnectionStateLabel => ConnectionState switch
{ {
"connected" => "Connected", "connected" => "Connected",
@@ -170,5 +172,8 @@ public sealed class WorkspaceState
_ => "offline" _ => "offline"
}; };
public string AppCssClass => IsPlayScreen ? "rr-app app-play" : "rr-app"; public string ThemeToggleLabel => ThemePreference == ThemePreferences.Dark ? "⏾" : "☀️";
public string NextThemePreference =>
ThemePreference == ThemePreferences.Dark ? ThemePreferences.Light : ThemePreferences.Dark;
} }

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
namespace RpgRoller.Contracts; namespace RpgRoller.Contracts;
@@ -10,10 +10,12 @@ public sealed record RegisterRequest(string Username, string Password, string Di
public sealed record LoginRequest(string Username, string Password); public sealed record LoginRequest(string Username, string Password);
public sealed record UserSummary(Guid Id, string Username, string DisplayName, IReadOnlyList<string> Roles); public sealed record UserSummary(Guid Id, string Username, string DisplayName, IReadOnlyList<string> Roles, string? ThemePreference = null);
public sealed record MeResponse(UserSummary User, Guid? ActiveCharacterId, Guid? CurrentCampaignId); public sealed record MeResponse(UserSummary User, Guid? ActiveCharacterId, Guid? CurrentCampaignId);
public sealed record UpdateThemePreferenceRequest(string ThemePreference);
public sealed record AdminUserSummary(Guid Id, string Username, string DisplayName, IReadOnlyList<string> Roles); public sealed record AdminUserSummary(Guid Id, string Username, string DisplayName, IReadOnlyList<string> Roles);
public sealed record UpdateUserRolesRequest(IReadOnlyList<string> Roles); public sealed record UpdateUserRolesRequest(IReadOnlyList<string> Roles);
@@ -36,9 +38,9 @@ public sealed record UpdateCharacterRequest(string Name, Guid? CampaignId, strin
public sealed record CharacterSummary(Guid Id, string Name, Guid OwnerUserId, Guid? CampaignId, string OwnerDisplayName); 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, int? FumbleRange = null); public sealed record CreateSkillRequest(string Name, string DiceRollDefinition, int WildDice, bool AllowFumble, Guid? SkillGroupId = null, int? FumbleRange = null, bool RolemasterAutoRetry = false);
public sealed record UpdateSkillRequest(string Name, string DiceRollDefinition, int WildDice, bool AllowFumble, Guid? SkillGroupId = null, int? FumbleRange = null); public sealed record UpdateSkillRequest(string Name, string DiceRollDefinition, int WildDice, bool AllowFumble, Guid? SkillGroupId = null, int? FumbleRange = null, bool RolemasterAutoRetry = false);
public sealed record SkillGroupSummary(Guid Id, Guid CharacterId, string Name, string DiceRollDefinition, int WildDice, bool AllowFumble, int? FumbleRange); public sealed record SkillGroupSummary(Guid Id, Guid CharacterId, string Name, string DiceRollDefinition, int WildDice, bool AllowFumble, int? FumbleRange);
@@ -46,9 +48,9 @@ public sealed record CreateSkillGroupRequest(string Name, string DiceRollDefinit
public sealed record UpdateSkillGroupRequest(string Name, string DiceRollDefinition, int WildDice, bool AllowFumble, int? FumbleRange = null); public sealed record UpdateSkillGroupRequest(string Name, string DiceRollDefinition, int WildDice, bool AllowFumble, int? FumbleRange = null);
public sealed record SkillSummary(Guid Id, Guid CharacterId, Guid? SkillGroupId, string Name, string DiceRollDefinition, int WildDice, bool AllowFumble, int? FumbleRange); public sealed record SkillSummary(Guid Id, Guid CharacterId, Guid? SkillGroupId, string Name, string DiceRollDefinition, int WildDice, bool AllowFumble, int? FumbleRange, bool RolemasterAutoRetry);
public sealed record RollSkillRequest(string Visibility); public sealed record RollSkillRequest(string Visibility, int SituationalModifier = 0);
public sealed record CustomRollRequest(string Expression, string Visibility); public sealed record CustomRollRequest(string Expression, string Visibility);
@@ -66,7 +68,7 @@ public sealed record RollDieResult
{ {
} }
public RollDieResult(int roll, bool crit, bool fumble, bool wild, bool removed, bool added, int? sequence = null, string? kind = null, int? signedContribution = null) public RollDieResult(int roll, bool crit, bool fumble, bool wild, bool removed, bool added, int? sequence = null, string? kind = null, int? signedContribution = null, int? attempt = null)
{ {
Roll = roll; Roll = roll;
Crit = crit; Crit = crit;
@@ -77,6 +79,7 @@ public sealed record RollDieResult
Sequence = sequence; Sequence = sequence;
Kind = kind; Kind = kind;
SignedContribution = signedContribution; SignedContribution = signedContribution;
Attempt = attempt;
} }
public int Roll { get; init; } public int Roll { get; init; }
@@ -94,13 +97,16 @@ public sealed record RollDieResult
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? SignedContribution { get; init; } public int? SignedContribution { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? Attempt { get; init; }
} }
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 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 CharacterSheetSkillGroup(Guid Id, string Name, string DiceRollDefinition, int WildDice, bool AllowFumble, int? FumbleRange); public sealed record CharacterSheetSkillGroup(Guid Id, string Name, string DiceRollDefinition, int WildDice, bool AllowFumble, int? FumbleRange);
public sealed record CharacterSheetSkill(Guid Id, Guid? SkillGroupId, string Name, string DiceRollDefinition, int WildDice, bool AllowFumble, int? FumbleRange); public sealed record CharacterSheetSkill(Guid Id, Guid? SkillGroupId, string Name, string DiceRollDefinition, int WildDice, bool AllowFumble, int? FumbleRange, bool RolemasterAutoRetry);
public sealed record CharacterSheet(Guid CharacterId, CharacterSheetSkillGroup[] SkillGroups, CharacterSheetSkill[] Skills); public sealed record CharacterSheet(Guid CharacterId, CharacterSheetSkillGroup[] SkillGroups, CharacterSheetSkill[] Skills);

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
namespace RpgRoller.Domain; namespace RpgRoller.Domain;
public enum RulesetKind public enum RulesetKind
{ {
@@ -22,6 +22,7 @@ public sealed class UserAccount
public required string DisplayName { get; set; } public required string DisplayName { get; set; }
public required string Roles { get; set; } public required string Roles { get; set; }
public Guid? ActiveCharacterId { get; set; } public Guid? ActiveCharacterId { get; set; }
public string? ThemePreference { get; set; }
} }
public static class UserRoles public static class UserRoles
@@ -74,6 +75,7 @@ public sealed class Skill
public required int WildDice { get; set; } public required int WildDice { get; set; }
public required bool AllowFumble { get; set; } public required bool AllowFumble { get; set; }
public int? FumbleRange { get; set; } public int? FumbleRange { get; set; }
public bool RolemasterAutoRetry { get; set; }
} }
public sealed class RollLogEntry public sealed class RollLogEntry

View File

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

View File

@@ -0,0 +1,269 @@
// <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("20260414204309_AddRolemasterAutoRetry")]
partial class AddRolemasterAutoRetry
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.2");
modelBuilder.Entity("RpgRoller.Domain.Campaign", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("GmUserId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Ruleset")
.IsRequired()
.HasColumnType("TEXT");
b.Property<long>("Version")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("GmUserId");
b.ToTable("Campaigns");
});
modelBuilder.Entity("RpgRoller.Domain.Character", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid?>("CampaignId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<Guid>("OwnerUserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CampaignId");
b.HasIndex("OwnerUserId");
b.ToTable("Characters");
});
modelBuilder.Entity("RpgRoller.Domain.RollLogEntry", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Breakdown")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<Guid>("CampaignId")
.HasColumnType("TEXT");
b.Property<Guid>("CharacterId")
.HasColumnType("TEXT");
b.Property<string>("Dice")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Result")
.HasColumnType("INTEGER");
b.Property<Guid>("RollerUserId")
.HasColumnType("TEXT");
b.Property<Guid>("SkillId")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("TimestampUtc")
.HasColumnType("TEXT");
b.Property<string>("Visibility")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CampaignId");
b.HasIndex("CharacterId");
b.HasIndex("RollerUserId");
b.HasIndex("SkillId");
b.ToTable("RollLogEntries");
});
modelBuilder.Entity("RpgRoller.Domain.Skill", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<bool>("AllowFumble")
.HasColumnType("INTEGER");
b.Property<Guid>("CharacterId")
.HasColumnType("TEXT");
b.Property<string>("DiceRollDefinition")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int?>("FumbleRange")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<bool>("RolemasterAutoRetry")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<Guid?>("SkillGroupId")
.HasColumnType("TEXT");
b.Property<int>("WildDice")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CharacterId");
b.HasIndex("SkillGroupId");
b.ToTable("Skills");
});
modelBuilder.Entity("RpgRoller.Domain.SkillGroup", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<bool>("AllowFumble")
.HasColumnType("INTEGER");
b.Property<Guid>("CharacterId")
.HasColumnType("TEXT");
b.Property<string>("DiceRollDefinition")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int?>("FumbleRange")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("WildDice")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CharacterId");
b.ToTable("SkillGroups");
});
modelBuilder.Entity("RpgRoller.Domain.UserAccount", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid?>("ActiveCharacterId")
.HasColumnType("TEXT");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Roles")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("UsernameNormalized")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UsernameNormalized")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("RpgRoller.Domain.UserSession", b =>
{
b.Property<string>("Token")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Token");
b.HasIndex("UserId");
b.ToTable("Sessions");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace RpgRoller.Migrations
{
/// <inheritdoc />
public partial class AddRolemasterAutoRetry : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "RolemasterAutoRetry",
table: "Skills",
type: "INTEGER",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "RolemasterAutoRetry",
table: "Skills");
}
}
}

View File

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

View File

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

View File

@@ -146,6 +146,11 @@ namespace RpgRoller.Migrations
.HasMaxLength(128) .HasMaxLength(128)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<bool>("RolemasterAutoRetry")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<Guid?>("SkillGroupId") b.Property<Guid?>("SkillGroupId")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
@@ -219,6 +224,10 @@ namespace RpgRoller.Migrations
.HasMaxLength(256) .HasMaxLength(256)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("ThemePreference")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("Username") b.Property<string>("Username")
.IsRequired() .IsRequired()
.HasMaxLength(64) .HasMaxLength(64)

View File

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

View File

@@ -5,18 +5,18 @@ namespace RpgRoller.Services;
public static class CampaignLogSummaryBuilder public static class CampaignLogSummaryBuilder
{ {
public static string BuildCompactLogSummary(IReadOnlyList<RollDieResult> dice) public static string BuildCompactLogSummary(IReadOnlyList<RollDieResult> dice, string? breakdown = null)
{ {
if (dice.Count == 0) if (dice.Count == 0)
return "No detail available."; return "No detail available.";
if (dice.Any(die => IsRolemasterDieKind(die.Kind))) if (dice.Any(die => IsRolemasterDieKind(die.Kind)))
return BuildRolemasterCompactLogSummary(dice); return BuildRolemasterCompactLogSummary(dice, breakdown);
return string.Join(", ", dice.Select(die => die.Roll.ToString())); return string.Join(", ", dice.Select(die => die.Roll.ToString()));
} }
public static string[]? BuildCompactLogEventBadges(RulesetKind ruleset, string? expression, IReadOnlyList<RollDieResult> dice) public static string[]? BuildCompactLogEventBadges(RulesetKind ruleset, string? expression, IReadOnlyList<RollDieResult> dice, string? breakdown = null)
{ {
var badges = new List<string>(); var badges = new List<string>();
@@ -38,6 +38,7 @@ public static class CampaignLogSummaryBuilder
AddBadgeIfMissing(badges, dice.Any(die => string.Equals(die.Kind, RollDieKinds.RolemasterOpenEndedInitial, StringComparison.Ordinal) && !die.SignedContribution.HasValue), "rf"); AddBadgeIfMissing(badges, dice.Any(die => string.Equals(die.Kind, RollDieKinds.RolemasterOpenEndedInitial, StringComparison.Ordinal) && !die.SignedContribution.HasValue), "rf");
AddBadgeIfMissing(badges, dice.Any(die => die.Roll == 100), "r100"); AddBadgeIfMissing(badges, dice.Any(die => die.Roll == 100), "r100");
AddBadgeIfMissing(badges, dice.Any(die => die.Roll == 66), "r66"); AddBadgeIfMissing(badges, dice.Any(die => die.Roll == 66), "r66");
AddRetryBadgeIfPresent(badges, breakdown);
break; break;
} }
@@ -53,29 +54,31 @@ public static class CampaignLogSummaryBuilder
return breakdown[..separatorIndex]; return breakdown[..separatorIndex];
} }
private static string BuildRolemasterCompactLogSummary(IReadOnlyList<RollDieResult> dice) private static string BuildRolemasterCompactLogSummary(IReadOnlyList<RollDieResult> dice, string? breakdown)
{ {
var openEndedInitial = dice.FirstOrDefault(die => string.Equals(die.Kind, RollDieKinds.RolemasterOpenEndedInitial, StringComparison.Ordinal)); var retryBonus = RolemasterRetryPolicy.TryExtractRetryBonus(breakdown);
var summaryDice = retryBonus.HasValue ? dice.Where(die => die.Attempt != 2).ToArray() : dice;
var openEndedInitial = summaryDice.FirstOrDefault(die => string.Equals(die.Kind, RollDieKinds.RolemasterOpenEndedInitial, StringComparison.Ordinal));
if (openEndedInitial is not null) if (openEndedInitial is not null)
{ {
var highFollowUps = dice.Where(die => string.Equals(die.Kind, RollDieKinds.RolemasterOpenEndedHigh, StringComparison.Ordinal)).Select(die => die.Roll.ToString()).ToArray(); var highFollowUps = summaryDice.Where(die => string.Equals(die.Kind, RollDieKinds.RolemasterOpenEndedHigh, StringComparison.Ordinal)).Select(die => die.Roll.ToString()).ToArray();
if (highFollowUps.Length > 0) if (highFollowUps.Length > 0)
return $"{openEndedInitial.Roll} + {string.Join(" + ", highFollowUps)} | open-ended high"; return AppendRetryNote($"{openEndedInitial.Roll} + {string.Join(" + ", highFollowUps)} | open-ended high", retryBonus);
var lowFollowUps = dice.Where(die => string.Equals(die.Kind, RollDieKinds.RolemasterOpenEndedLowSubtract, StringComparison.Ordinal)).Select(die => die.Roll.ToString()).ToArray(); var lowFollowUps = summaryDice.Where(die => string.Equals(die.Kind, RollDieKinds.RolemasterOpenEndedLowSubtract, StringComparison.Ordinal)).Select(die => die.Roll.ToString()).ToArray();
if (lowFollowUps.Length > 0) if (lowFollowUps.Length > 0)
return $"({RollBreakdownFormatter.FormatRolemasterTriggerRoll(openEndedInitial.Roll)}) {string.Join(" ", lowFollowUps.Select(roll => $"-{roll}"))} | open-ended low"; return AppendRetryNote($"({RollBreakdownFormatter.FormatRolemasterTriggerRoll(openEndedInitial.Roll)}) {string.Join(" ", lowFollowUps.Select(roll => $"-{roll}"))} | open-ended low", retryBonus);
return $"{openEndedInitial.Roll} | open-ended"; return AppendRetryNote($"{openEndedInitial.Roll} | open-ended", retryBonus);
} }
if (dice.Any(die => string.Equals(die.Kind, RollDieKinds.RolemasterStandard, StringComparison.Ordinal))) if (summaryDice.Any(die => string.Equals(die.Kind, RollDieKinds.RolemasterStandard, StringComparison.Ordinal)))
{ {
var preview = string.Join(" + ", dice.Select(die => die.Roll.ToString())); var preview = string.Join(" + ", summaryDice.Select(die => die.Roll.ToString()));
return $"{preview} | rolemaster"; return AppendRetryNote($"{preview} | rolemaster", retryBonus);
} }
return string.Join(", ", dice.Select(die => die.Roll.ToString())); return AppendRetryNote(string.Join(", ", summaryDice.Select(die => die.Roll.ToString())), retryBonus);
} }
private static bool IsRolemasterDieKind(string? kind) private static bool IsRolemasterDieKind(string? kind)
@@ -91,6 +94,20 @@ public static class CampaignLogSummaryBuilder
badges.Add(code); badges.Add(code);
} }
private static void AddRetryBadgeIfPresent(List<string> badges, string? breakdown)
{
var retryBonus = RolemasterRetryPolicy.TryExtractRetryBonus(breakdown);
if (!retryBonus.HasValue)
return;
AddBadgeIfMissing(badges, true, retryBonus.Value == 5 ? "rs5" : "rs10");
}
private static string AppendRetryNote(string summary, int? retryBonus)
{
return retryBonus.HasValue ? $"{summary} | retry +{retryBonus.Value}" : summary;
}
private static bool IsSingleD20Expression(string expression) private static bool IsSingleD20Expression(string expression)
{ {
var parsedExpression = DiceRules.ParseExpression(RulesetKind.Dnd5e, expression); var parsedExpression = DiceRules.ParseExpression(RulesetKind.Dnd5e, expression);

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
using RpgRoller.Contracts; using RpgRoller.Contracts;
using RpgRoller.Domain; using RpgRoller.Domain;
namespace RpgRoller.Services; namespace RpgRoller.Services;
@@ -7,7 +7,7 @@ public static class GameDtoMapper
{ {
public static UserSummary ToUserSummary(UserAccount user) public static UserSummary ToUserSummary(UserAccount user)
{ {
return new(user.Id, user.Username, user.DisplayName, RoleSerializer.Parse(user.Roles)); return new(user.Id, user.Username, user.DisplayName, RoleSerializer.Parse(user.Roles), user.ThemePreference);
} }
public static AdminUserSummary ToAdminUserSummary(UserAccount user) public static AdminUserSummary ToAdminUserSummary(UserAccount user)
@@ -55,7 +55,7 @@ public static class GameDtoMapper
public static SkillSummary ToSkillSummary(Skill skill) public static SkillSummary ToSkillSummary(Skill skill)
{ {
return new(skill.Id, skill.CharacterId, skill.SkillGroupId, skill.Name, skill.DiceRollDefinition, skill.WildDice, skill.AllowFumble, skill.FumbleRange); return new(skill.Id, skill.CharacterId, skill.SkillGroupId, skill.Name, skill.DiceRollDefinition, skill.WildDice, skill.AllowFumble, skill.FumbleRange, skill.RolemasterAutoRetry);
} }
public static RollResult ToRollResult(RollLogEntry entry, IReadOnlyList<RollDieResult> dice) public static RollResult ToRollResult(RollLogEntry entry, IReadOnlyList<RollDieResult> dice)
@@ -98,6 +98,6 @@ public static class GameDtoMapper
private static CharacterSheetSkill ToCharacterSheetSkill(Skill skill) private static CharacterSheetSkill ToCharacterSheetSkill(Skill skill)
{ {
return new(skill.Id, skill.SkillGroupId, skill.Name, skill.DiceRollDefinition, skill.WildDice, skill.AllowFumble, skill.FumbleRange); return new(skill.Id, skill.SkillGroupId, skill.Name, skill.DiceRollDefinition, skill.WildDice, skill.AllowFumble, skill.FumbleRange, skill.RolemasterAutoRetry);
} }
} }

View File

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

View File

@@ -6,7 +6,7 @@ namespace RpgRoller.Services;
public sealed class GameRollService(GameStateStore stateStore, GamePersistenceService persistenceService, IDiceRoller diceRoller) public sealed class GameRollService(GameStateStore stateStore, GamePersistenceService persistenceService, IDiceRoller diceRoller)
{ {
public ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility) public ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility, int situationalModifier = 0)
{ {
lock (stateStore.Gate) lock (stateStore.Gate)
{ {
@@ -28,11 +28,17 @@ public sealed class GameRollService(GameStateStore stateStore, GamePersistenceSe
if (!parsedExpression.Succeeded) if (!parsedExpression.Succeeded)
return ServiceResult<RollResult>.Failure(parsedExpression.Error!.Code, parsedExpression.Error.Message); return ServiceResult<RollResult>.Failure(parsedExpression.Error!.Code, parsedExpression.Error.Message);
if (situationalModifier != 0 && campaign.Ruleset != RulesetKind.Rolemaster)
return ServiceResult<RollResult>.Failure("invalid_situational_modifier", "Situational modifiers are supported only for Rolemaster skill rolls.");
if (campaign.Ruleset == RulesetKind.Rolemaster && (situationalModifier < -MaxSituationalModifier || situationalModifier > MaxSituationalModifier))
return ServiceResult<RollResult>.Failure("invalid_situational_modifier", $"Situational modifier must be between {-MaxSituationalModifier} and {MaxSituationalModifier}.");
var parsedVisibility = RollVisibilityParser.Parse(visibility); var parsedVisibility = RollVisibilityParser.Parse(visibility);
if (!parsedVisibility.Succeeded) if (!parsedVisibility.Succeeded)
return ServiceResult<RollResult>.Failure(parsedVisibility.Error!.Code, parsedVisibility.Error.Message); return ServiceResult<RollResult>.Failure(parsedVisibility.Error!.Code, parsedVisibility.Error.Message);
var roll = m_RollEngine.Roll(campaign.Ruleset, parsedExpression.Value!, skill.WildDice, skill.AllowFumble, skill.FumbleRange); var roll = m_RollEngine.Roll(campaign.Ruleset, parsedExpression.Value!, skill.WildDice, skill.AllowFumble, skill.FumbleRange, skill.RolemasterAutoRetry, situationalModifier);
return RecordRollLocked(user, campaign, character, skill.Id, parsedVisibility.Value, roll, parsedExpression.Value!.Canonical); return RecordRollLocked(user, campaign, character, skill.Id, parsedVisibility.Value, roll, parsedExpression.Value!.Canonical);
} }
} }
@@ -203,9 +209,9 @@ public sealed class GameRollService(GameStateStore stateStore, GamePersistenceSe
var characterName = stateStore.CharactersById.TryGetValue(entry.CharacterId, out var character) ? character.Name : "Unknown character"; var characterName = stateStore.CharactersById.TryGetValue(entry.CharacterId, out var character) ? character.Name : "Unknown character";
var skillName = ResolveLoggedSkillName(entry); var skillName = ResolveLoggedSkillName(entry);
var loggedExpression = ResolveLoggedExpression(entry); var loggedExpression = ResolveLoggedExpression(entry);
var eventBadges = CampaignLogSummaryBuilder.BuildCompactLogEventBadges(campaign.Ruleset, loggedExpression, dice); var eventBadges = CampaignLogSummaryBuilder.BuildCompactLogEventBadges(campaign.Ruleset, loggedExpression, dice, entry.Breakdown);
return GameDtoMapper.ToCampaignLogListEntry(entry, characterName, skillName, ResolveLogRollerLabel(user, campaign, entry), ResolveLogVisibilityLabel(user, campaign, entry), ResolveLogVisibilityStyle(user, campaign, entry), CampaignLogSummaryBuilder.BuildCompactLogSummary(dice), eventBadges); return GameDtoMapper.ToCampaignLogListEntry(entry, characterName, skillName, ResolveLogRollerLabel(user, campaign, entry), ResolveLogVisibilityLabel(user, campaign, entry), ResolveLogVisibilityStyle(user, campaign, entry), CampaignLogSummaryBuilder.BuildCompactLogSummary(dice, entry.Breakdown), eventBadges);
} }
private static string SerializeDice(IReadOnlyList<RollDieResult> dice) private static string SerializeDice(IReadOnlyList<RollDieResult> dice)
@@ -287,6 +293,7 @@ public sealed class GameRollService(GameStateStore stateStore, GamePersistenceSe
private const int CampaignLogHistoryWindowSize = 100; private const int CampaignLogHistoryWindowSize = 100;
private const int CampaignLogLivePageSize = 25; private const int CampaignLogLivePageSize = 25;
private const int MaxSituationalModifier = 1000;
private const string CustomRollBreakdownSeparator = " => "; private const string CustomRollBreakdownSeparator = " => ";
private const string CustomRollLabel = "Custom roll"; private const string CustomRollLabel = "Custom roll";
private static readonly Guid CustomRollSkillId = Guid.Empty; private static readonly Guid CustomRollSkillId = Guid.Empty;

View File

@@ -1,4 +1,4 @@
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using RpgRoller.Contracts; using RpgRoller.Contracts;
using RpgRoller.Data; using RpgRoller.Data;
@@ -55,6 +55,11 @@ public sealed class GameService : IGameService
return m_AuthService.GetMe(sessionToken); return m_AuthService.GetMe(sessionToken);
} }
public ServiceResult<UserSummary> UpdateThemePreference(string sessionToken, string themePreference)
{
return m_AuthService.UpdateThemePreference(sessionToken, themePreference);
}
public ServiceResult<CampaignSummary> CreateCampaign(string sessionToken, string name, string rulesetId) public ServiceResult<CampaignSummary> CreateCampaign(string sessionToken, string name, string rulesetId)
{ {
return m_CampaignService.CreateCampaign(sessionToken, name, rulesetId); return m_CampaignService.CreateCampaign(sessionToken, name, rulesetId);
@@ -140,14 +145,14 @@ public sealed class GameService : IGameService
return m_SkillService.DeleteSkillGroup(sessionToken, skillGroupId); return m_SkillService.DeleteSkillGroup(sessionToken, skillGroupId);
} }
public ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null) public ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null, bool rolemasterAutoRetry = false)
{ {
return m_SkillService.CreateSkill(sessionToken, characterId, name, diceRollDefinition, wildDice, allowFumble, skillGroupId, fumbleRange); return m_SkillService.CreateSkill(sessionToken, characterId, name, diceRollDefinition, wildDice, allowFumble, skillGroupId, fumbleRange, rolemasterAutoRetry);
} }
public ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null) public ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null, bool rolemasterAutoRetry = false)
{ {
return m_SkillService.UpdateSkill(sessionToken, skillId, name, diceRollDefinition, wildDice, allowFumble, skillGroupId, fumbleRange); return m_SkillService.UpdateSkill(sessionToken, skillId, name, diceRollDefinition, wildDice, allowFumble, skillGroupId, fumbleRange, rolemasterAutoRetry);
} }
public ServiceResult<bool> DeleteSkill(string sessionToken, Guid skillId) public ServiceResult<bool> DeleteSkill(string sessionToken, Guid skillId)
@@ -160,9 +165,9 @@ public sealed class GameService : IGameService
return m_SkillService.GetCharacterSheet(sessionToken, characterId); return m_SkillService.GetCharacterSheet(sessionToken, characterId);
} }
public ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility) public ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility, int situationalModifier = 0)
{ {
return m_RollService.RollSkill(sessionToken, skillId, visibility); return m_RollService.RollSkill(sessionToken, skillId, visibility, situationalModifier);
} }
public ServiceResult<RollResult> RollCustom(string sessionToken, Guid characterId, string expression, string visibility) public ServiceResult<RollResult> RollCustom(string sessionToken, Guid characterId, string expression, string visibility)

View File

@@ -114,7 +114,7 @@ public sealed class GameSkillService(GameStateStore stateStore, GamePersistenceS
} }
} }
public ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null) public ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null, bool rolemasterAutoRetry = false)
{ {
if (string.IsNullOrWhiteSpace(name)) if (string.IsNullOrWhiteSpace(name))
return ServiceResult<SkillSummary>.Failure("invalid_skill_name", "Skill name is required."); return ServiceResult<SkillSummary>.Failure("invalid_skill_name", "Skill name is required.");
@@ -134,7 +134,7 @@ public sealed class GameSkillService(GameStateStore stateStore, GamePersistenceS
if (!GameAuthorization.CanEditCharacter(user.Id, character, campaign)) if (!GameAuthorization.CanEditCharacter(user.Id, character, campaign))
return ServiceResult<SkillSummary>.Failure("forbidden", "Only the owner or GM can edit skills."); return ServiceResult<SkillSummary>.Failure("forbidden", "Only the owner or GM can edit skills.");
var skillValidation = SkillDefinitionValidator.Validate(campaign.Ruleset, diceRollDefinition, wildDice, allowFumble, fumbleRange); var skillValidation = SkillDefinitionValidator.Validate(campaign.Ruleset, diceRollDefinition, wildDice, allowFumble, fumbleRange, rolemasterAutoRetry);
if (!skillValidation.Succeeded) if (!skillValidation.Succeeded)
return ServiceResult<SkillSummary>.Failure(skillValidation.Error!.Code, skillValidation.Error.Message); return ServiceResult<SkillSummary>.Failure(skillValidation.Error!.Code, skillValidation.Error.Message);
@@ -151,7 +151,8 @@ public sealed class GameSkillService(GameStateStore stateStore, GamePersistenceS
DiceRollDefinition = skillValidation.Value.CanonicalExpression, DiceRollDefinition = skillValidation.Value.CanonicalExpression,
WildDice = skillValidation.Value.WildDice, WildDice = skillValidation.Value.WildDice,
AllowFumble = skillValidation.Value.AllowFumble, AllowFumble = skillValidation.Value.AllowFumble,
FumbleRange = skillValidation.Value.FumbleRange FumbleRange = skillValidation.Value.FumbleRange,
RolemasterAutoRetry = skillValidation.Value.RolemasterAutoRetry
}; };
stateStore.SkillsById[skill.Id] = skill; stateStore.SkillsById[skill.Id] = skill;
@@ -162,7 +163,7 @@ public sealed class GameSkillService(GameStateStore stateStore, GamePersistenceS
} }
} }
public ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null) public ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null, bool rolemasterAutoRetry = false)
{ {
if (string.IsNullOrWhiteSpace(name)) if (string.IsNullOrWhiteSpace(name))
return ServiceResult<SkillSummary>.Failure("invalid_skill_name", "Skill name is required."); return ServiceResult<SkillSummary>.Failure("invalid_skill_name", "Skill name is required.");
@@ -183,7 +184,7 @@ public sealed class GameSkillService(GameStateStore stateStore, GamePersistenceS
if (!GameAuthorization.CanEditCharacter(user.Id, character, campaign)) if (!GameAuthorization.CanEditCharacter(user.Id, character, campaign))
return ServiceResult<SkillSummary>.Failure("forbidden", "Only the owner or GM can edit skills."); return ServiceResult<SkillSummary>.Failure("forbidden", "Only the owner or GM can edit skills.");
var skillValidation = SkillDefinitionValidator.Validate(campaign.Ruleset, diceRollDefinition, wildDice, allowFumble, fumbleRange); var skillValidation = SkillDefinitionValidator.Validate(campaign.Ruleset, diceRollDefinition, wildDice, allowFumble, fumbleRange, rolemasterAutoRetry);
if (!skillValidation.Succeeded) if (!skillValidation.Succeeded)
return ServiceResult<SkillSummary>.Failure(skillValidation.Error!.Code, skillValidation.Error.Message); return ServiceResult<SkillSummary>.Failure(skillValidation.Error!.Code, skillValidation.Error.Message);
@@ -196,6 +197,7 @@ public sealed class GameSkillService(GameStateStore stateStore, GamePersistenceS
skill.WildDice = skillValidation.Value.WildDice; skill.WildDice = skillValidation.Value.WildDice;
skill.AllowFumble = skillValidation.Value.AllowFumble; skill.AllowFumble = skillValidation.Value.AllowFumble;
skill.FumbleRange = skillValidation.Value.FumbleRange; skill.FumbleRange = skillValidation.Value.FumbleRange;
skill.RolemasterAutoRetry = skillValidation.Value.RolemasterAutoRetry;
skill.SkillGroupId = resolvedSkillGroupId.Value; skill.SkillGroupId = resolvedSkillGroupId.Value;
stateStore.TouchCharacterLocked(campaign.Id, character.Id); stateStore.TouchCharacterLocked(campaign.Id, character.Id);

View File

@@ -1,4 +1,4 @@
using RpgRoller.Domain; using RpgRoller.Domain;
namespace RpgRoller.Services; namespace RpgRoller.Services;
@@ -14,7 +14,8 @@ public static class GameStateCloneFactory
PasswordHash = user.PasswordHash, PasswordHash = user.PasswordHash,
DisplayName = user.DisplayName, DisplayName = user.DisplayName,
Roles = user.Roles, Roles = user.Roles,
ActiveCharacterId = user.ActiveCharacterId ActiveCharacterId = user.ActiveCharacterId,
ThemePreference = user.ThemePreference
}; };
} }
@@ -62,7 +63,8 @@ public static class GameStateCloneFactory
DiceRollDefinition = skill.DiceRollDefinition, DiceRollDefinition = skill.DiceRollDefinition,
WildDice = skill.WildDice, WildDice = skill.WildDice,
AllowFumble = skill.AllowFumble, AllowFumble = skill.AllowFumble,
FumbleRange = skill.FumbleRange FumbleRange = skill.FumbleRange,
RolemasterAutoRetry = skill.RolemasterAutoRetry
}; };
} }

View File

@@ -1,4 +1,4 @@
using RpgRoller.Contracts; using RpgRoller.Contracts;
namespace RpgRoller.Services; namespace RpgRoller.Services;
@@ -11,6 +11,7 @@ public interface IGameService
void Logout(string sessionToken); void Logout(string sessionToken);
UserSummary? GetUserBySession(string sessionToken); UserSummary? GetUserBySession(string sessionToken);
ServiceResult<MeResponse> GetMe(string sessionToken); ServiceResult<MeResponse> GetMe(string sessionToken);
ServiceResult<UserSummary> UpdateThemePreference(string sessionToken, string themePreference);
ServiceResult<CampaignSummary> CreateCampaign(string sessionToken, string name, string rulesetId); ServiceResult<CampaignSummary> CreateCampaign(string sessionToken, string name, string rulesetId);
ServiceResult<IReadOnlyList<CampaignSummary>> GetCampaigns(string sessionToken); ServiceResult<IReadOnlyList<CampaignSummary>> GetCampaigns(string sessionToken);
@@ -31,12 +32,12 @@ public interface IGameService
ServiceResult<SkillGroupSummary> CreateSkillGroup(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, int? fumbleRange = null); ServiceResult<SkillGroupSummary> CreateSkillGroup(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, int? fumbleRange = null);
ServiceResult<SkillGroupSummary> UpdateSkillGroup(string sessionToken, Guid skillGroupId, string name, string diceRollDefinition, int wildDice, bool allowFumble, int? fumbleRange = null); ServiceResult<SkillGroupSummary> UpdateSkillGroup(string sessionToken, Guid skillGroupId, string name, string diceRollDefinition, int wildDice, bool allowFumble, int? fumbleRange = null);
ServiceResult<bool> DeleteSkillGroup(string sessionToken, Guid skillGroupId); 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, int? fumbleRange = null); ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null, bool rolemasterAutoRetry = false);
ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null); ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null, bool rolemasterAutoRetry = false);
ServiceResult<bool> DeleteSkill(string sessionToken, Guid skillId); ServiceResult<bool> DeleteSkill(string sessionToken, Guid skillId);
ServiceResult<CharacterSheet> GetCharacterSheet(string sessionToken, Guid characterId); 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, int situationalModifier = 0);
ServiceResult<RollResult> RollCustom(string sessionToken, Guid characterId, string expression, string visibility); ServiceResult<RollResult> RollCustom(string sessionToken, Guid characterId, string expression, 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<CampaignLogPage> GetCampaignLogPage(string sessionToken, Guid campaignId, Guid? afterRollId = null, int? limit = null);

View File

@@ -0,0 +1,32 @@
namespace RpgRoller.Services;
public static class RolemasterRetryPolicy
{
public static int? ResolveAutoRetryBonus(int firstResult)
{
if (firstResult is >= 76 and <= 90)
return 5;
if (firstResult is >= 91 and <= 110)
return 10;
return null;
}
public static int? TryExtractRetryBonus(string? breakdown)
{
if (string.IsNullOrWhiteSpace(breakdown))
return null;
if (breakdown.Contains(RetryPlusFiveMarker, StringComparison.Ordinal))
return 5;
if (breakdown.Contains(RetryPlusTenMarker, StringComparison.Ordinal))
return 10;
return null;
}
public const string RetryPlusFiveMarker = "; retry(+5):";
public const string RetryPlusTenMarker = "; retry(+10):";
}

View File

@@ -5,58 +5,73 @@ namespace RpgRoller.Services;
public sealed class RolemasterRollEngine(IDiceRoller diceRoller) public sealed class RolemasterRollEngine(IDiceRoller diceRoller)
{ {
public (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) Roll(DiceExpression expression, int? fumbleRange) public (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) Roll(DiceExpression expression, int? fumbleRange, bool rolemasterAutoRetry, int situationalModifier = 0)
{ {
return expression.Kind switch return expression.Kind switch
{ {
DiceExpressionKind.RolemasterOpenEndedPercentile => RollOpenEnded(expression, fumbleRange.GetValueOrDefault()), DiceExpressionKind.RolemasterOpenEndedPercentile => RollOpenEnded(expression, fumbleRange.GetValueOrDefault(), rolemasterAutoRetry, situationalModifier),
_ => RollStandard(expression) _ => RollStandard(expression, situationalModifier)
}; };
} }
private (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) RollStandard(DiceExpression expression) private (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) RollStandard(DiceExpression expression, int situationalModifier, int? attempt = null)
{ {
var diceValues = new int[expression.DiceCount]; var diceValues = new int[expression.DiceCount];
var dice = new RollDieResult[expression.DiceCount]; var dice = new RollDieResult[expression.DiceCount];
var total = expression.Modifier; var total = expression.Modifier + situationalModifier;
for (var i = 0; i < expression.DiceCount; i += 1) for (var i = 0; i < expression.DiceCount; i += 1)
{ {
var value = diceRoller.Roll(expression.Sides); var value = diceRoller.Roll(expression.Sides);
diceValues[i] = value; diceValues[i] = value;
dice[i] = CreateRolemasterDie(value, i + 1, RollDieKinds.RolemasterStandard, value); dice[i] = CreateRolemasterDie(value, i + 1, RollDieKinds.RolemasterStandard, value, attempt);
total += value; total += value;
} }
return (total, RollBreakdownFormatter.BuildBreakdown(diceValues, expression.Modifier, total), dice); return (total, RollBreakdownFormatter.BuildRolemasterModifierBreakdown(diceValues, expression.Modifier, situationalModifier, total), dice);
} }
private (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) RollOpenEnded(DiceExpression expression, int fumbleRange) private (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) RollOpenEnded(DiceExpression expression, int fumbleRange, bool rolemasterAutoRetry, int situationalModifier)
{
var firstAttempt = RollOpenEndedAttempt(expression, fumbleRange, situationalModifier);
var retryBonus = rolemasterAutoRetry ? RolemasterRetryPolicy.ResolveAutoRetryBonus(firstAttempt.Total) : null;
if (!retryBonus.HasValue)
return firstAttempt;
var retryAttempt = RollOpenEndedAttempt(expression, fumbleRange, situationalModifier, 2);
var finalTotal = retryAttempt.Total + retryBonus.Value;
var breakdown = RollBreakdownFormatter.BuildRolemasterRetryBreakdown(firstAttempt.Breakdown, retryBonus.Value, retryAttempt.Breakdown, finalTotal);
var dice = AddAttemptMarker(firstAttempt.Dice, 1).Concat(retryAttempt.Dice).ToArray();
return (finalTotal, breakdown, dice);
}
private (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) RollOpenEndedAttempt(DiceExpression expression, int fumbleRange, int situationalModifier, int? attempt = null)
{ {
var initialRoll = diceRoller.Roll(expression.Sides); var initialRoll = diceRoller.Roll(expression.Sides);
var followUpRolls = new List<int>(); var followUpRolls = new List<int>();
int? initialContribution = initialRoll <= fumbleRange ? null : initialRoll; int? initialContribution = initialRoll <= fumbleRange ? null : initialRoll;
var dice = new List<RollDieResult> { CreateRolemasterDie(initialRoll, 1, RollDieKinds.RolemasterOpenEndedInitial, initialContribution) }; var dice = new List<RollDieResult> { CreateRolemasterDie(initialRoll, 1, RollDieKinds.RolemasterOpenEndedInitial, initialContribution, attempt) };
var baseTotal = initialRoll <= fumbleRange ? 0 : initialRoll; var baseTotal = initialRoll <= fumbleRange ? 0 : initialRoll;
var subtractFollowUps = false; var subtractFollowUps = false;
if (initialRoll >= 96) if (initialRoll >= 96)
{ {
followUpRolls.AddRange(RollHighOpenEndedChain(dice, 2, false)); followUpRolls.AddRange(RollHighOpenEndedChain(dice, 2, false, attempt));
baseTotal += followUpRolls.Sum(); baseTotal += followUpRolls.Sum();
} }
else if (initialRoll <= fumbleRange) else if (initialRoll <= fumbleRange)
{ {
subtractFollowUps = true; subtractFollowUps = true;
followUpRolls.AddRange(RollHighOpenEndedChain(dice, 2, true)); followUpRolls.AddRange(RollHighOpenEndedChain(dice, 2, true, attempt));
baseTotal -= followUpRolls.Sum(); baseTotal -= followUpRolls.Sum();
} }
var total = baseTotal + expression.Modifier; var total = baseTotal + expression.Modifier + situationalModifier;
var breakdown = RollBreakdownFormatter.BuildRolemasterOpenEndedBreakdown(initialRoll, followUpRolls, subtractFollowUps, expression.Modifier, total); var breakdown = RollBreakdownFormatter.BuildRolemasterOpenEndedBreakdown(initialRoll, followUpRolls, subtractFollowUps, expression.Modifier, total, situationalModifier);
return (total, breakdown, dice); return (total, breakdown, dice);
} }
private IEnumerable<int> RollHighOpenEndedChain(List<RollDieResult> dice, int sequenceStart, bool subtract) private IEnumerable<int> RollHighOpenEndedChain(List<RollDieResult> dice, int sequenceStart, bool subtract, int? attempt)
{ {
var followUpRolls = new List<int>(); var followUpRolls = new List<int>();
var sequence = sequenceStart; var sequence = sequenceStart;
@@ -65,7 +80,7 @@ public sealed class RolemasterRollEngine(IDiceRoller diceRoller)
{ {
var roll = diceRoller.Roll(100); var roll = diceRoller.Roll(100);
followUpRolls.Add(roll); followUpRolls.Add(roll);
dice.Add(CreateRolemasterDie(roll, sequence, subtract ? RollDieKinds.RolemasterOpenEndedLowSubtract : RollDieKinds.RolemasterOpenEndedHigh, subtract ? -roll : roll)); dice.Add(CreateRolemasterDie(roll, sequence, subtract ? RollDieKinds.RolemasterOpenEndedLowSubtract : RollDieKinds.RolemasterOpenEndedHigh, subtract ? -roll : roll, attempt));
sequence += 1; sequence += 1;
if (roll < 96) if (roll < 96)
@@ -75,8 +90,13 @@ public sealed class RolemasterRollEngine(IDiceRoller diceRoller)
return followUpRolls; return followUpRolls;
} }
private static RollDieResult CreateRolemasterDie(int roll, int sequence, string kind, int? signedContribution) private static IReadOnlyList<RollDieResult> AddAttemptMarker(IReadOnlyList<RollDieResult> dice, int attempt)
{ {
return new(roll, false, false, false, false, kind == RollDieKinds.RolemasterOpenEndedHigh, sequence, kind, signedContribution); return dice.Select(die => die with { Attempt = attempt }).ToArray();
}
private static RollDieResult CreateRolemasterDie(int roll, int sequence, string kind, int? signedContribution, int? attempt)
{
return new(roll, false, false, false, false, kind == RollDieKinds.RolemasterOpenEndedHigh, sequence, kind, signedContribution, attempt);
} }
} }

View File

@@ -12,15 +12,17 @@ public static class RollBreakdownFormatter
} }
public static string BuildRolemasterOpenEndedBreakdown(int initialRoll, IReadOnlyList<int> followUpRolls, bool subtractFollowUps, int modifier, int total) public static string BuildRolemasterOpenEndedBreakdown(int initialRoll, IReadOnlyList<int> followUpRolls, bool subtractFollowUps, int modifier, int total)
{
return BuildRolemasterOpenEndedBreakdown(initialRoll, followUpRolls, subtractFollowUps, modifier, total, 0);
}
public static string BuildRolemasterOpenEndedBreakdown(int initialRoll, IReadOnlyList<int> followUpRolls, bool subtractFollowUps, int modifier, int total, int situationalModifier)
{ {
if (subtractFollowUps) if (subtractFollowUps)
{ {
var segments = new List<string> { $"({FormatRolemasterTriggerRoll(initialRoll)})" }; var segments = new List<string> { $"({FormatRolemasterTriggerRoll(initialRoll)})" };
segments.AddRange(followUpRolls.Select(roll => $"-{roll}")); segments.AddRange(followUpRolls.Select(roll => $"-{roll}"));
if (modifier > 0) AddRolemasterModifierSegments(segments, modifier, situationalModifier);
segments.Add($"+{modifier}");
else if (modifier < 0)
segments.Add(modifier.ToString());
return $"{string.Join(" ", segments)} = {total}"; return $"{string.Join(" ", segments)} = {total}";
} }
@@ -32,7 +34,12 @@ public static class RollBreakdownFormatter
core = $"{core}+{followUpBreakdown}"; core = $"{core}+{followUpBreakdown}";
} }
return BuildModifierBreakdown(core, modifier, total); return BuildRolemasterModifierBreakdown(core, modifier, situationalModifier, total);
}
public static string BuildRolemasterRetryBreakdown(string firstAttemptBreakdown, int retryBonus, string retryAttemptBreakdown, int finalTotal)
{
return $"{firstAttemptBreakdown}; retry(+{retryBonus}): {retryAttemptBreakdown}; final={finalTotal}";
} }
public static string FormatRolemasterTriggerRoll(int roll) public static string FormatRolemasterTriggerRoll(int roll)
@@ -49,4 +56,40 @@ public static class RollBreakdownFormatter
_ => $"{core}={total}" _ => $"{core}={total}"
}; };
} }
public static string BuildRolemasterModifierBreakdown(IEnumerable<int> diceValues, int modifier, int situationalModifier, int total)
{
var dicePart = string.Join("+", diceValues);
if (string.IsNullOrWhiteSpace(dicePart))
dicePart = "0";
return BuildRolemasterModifierBreakdown(dicePart, modifier, situationalModifier, total);
}
public static string BuildRolemasterModifierBreakdown(string core, int modifier, int situationalModifier, int total)
{
if (situationalModifier == 0)
return BuildModifierBreakdown(core, modifier, total);
return $"{core}{FormatSignedModifier(modifier)}{FormatSignedModifier(situationalModifier)}={total}";
}
private static void AddRolemasterModifierSegments(List<string> segments, int modifier, int situationalModifier)
{
if (modifier != 0)
segments.Add(FormatSignedModifier(modifier));
if (situationalModifier != 0)
segments.Add(FormatSignedModifier(situationalModifier));
}
private static string FormatSignedModifier(int modifier)
{
return modifier switch
{
> 0 => $"+{modifier}",
< 0 => modifier.ToString(),
_ => string.Empty
};
}
} }

View File

@@ -5,13 +5,13 @@ namespace RpgRoller.Services;
public sealed class RollEngine(StandardRollEngine standardRollEngine, D6RollEngine d6RollEngine, RolemasterRollEngine rolemasterRollEngine) public sealed class RollEngine(StandardRollEngine standardRollEngine, D6RollEngine d6RollEngine, RolemasterRollEngine rolemasterRollEngine)
{ {
public (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) Roll(RulesetKind ruleset, DiceExpression expression, int wildDice, bool allowFumble, int? fumbleRange) public (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) Roll(RulesetKind ruleset, DiceExpression expression, int wildDice, bool allowFumble, int? fumbleRange, bool rolemasterAutoRetry = false, int situationalModifier = 0)
{ {
if (ruleset == RulesetKind.D6) if (ruleset == RulesetKind.D6)
return d6RollEngine.Roll(expression, wildDice, allowFumble); return d6RollEngine.Roll(expression, wildDice, allowFumble);
if (ruleset == RulesetKind.Rolemaster) if (ruleset == RulesetKind.Rolemaster)
return rolemasterRollEngine.Roll(expression, fumbleRange); return rolemasterRollEngine.Roll(expression, fumbleRange, rolemasterAutoRetry, situationalModifier);
return standardRollEngine.Roll(expression); return standardRollEngine.Roll(expression);
} }

View File

@@ -4,63 +4,72 @@ namespace RpgRoller.Services;
public static class SkillDefinitionValidator public static class SkillDefinitionValidator
{ {
public static ServiceResult<(string CanonicalExpression, int WildDice, bool AllowFumble, int? FumbleRange)> Validate(RulesetKind ruleset, string diceRollDefinition, int wildDice, bool allowFumble, int? fumbleRange) public static ServiceResult<(string CanonicalExpression, int WildDice, bool AllowFumble, int? FumbleRange, bool RolemasterAutoRetry)> Validate(RulesetKind ruleset, string diceRollDefinition, int wildDice, bool allowFumble, int? fumbleRange, bool rolemasterAutoRetry = false)
{ {
var expressionValidation = DiceRules.ParseExpression(ruleset, diceRollDefinition); var expressionValidation = DiceRules.ParseExpression(ruleset, diceRollDefinition);
if (!expressionValidation.Succeeded) if (!expressionValidation.Succeeded)
return ServiceResult<(string CanonicalExpression, int WildDice, bool AllowFumble, int? FumbleRange)>.Failure(expressionValidation.Error!.Code, expressionValidation.Error.Message); return ServiceResult<(string CanonicalExpression, int WildDice, bool AllowFumble, int? FumbleRange, bool RolemasterAutoRetry)>.Failure(expressionValidation.Error!.Code, expressionValidation.Error.Message);
var optionsValidation = ValidateOptions(ruleset, expressionValidation.Value!, wildDice, allowFumble, fumbleRange); var optionsValidation = ValidateOptions(ruleset, expressionValidation.Value!, wildDice, allowFumble, fumbleRange, rolemasterAutoRetry);
if (!optionsValidation.Succeeded) if (!optionsValidation.Succeeded)
return ServiceResult<(string CanonicalExpression, int WildDice, bool AllowFumble, int? FumbleRange)>.Failure(optionsValidation.Error!.Code, optionsValidation.Error.Message); return ServiceResult<(string CanonicalExpression, int WildDice, bool AllowFumble, int? FumbleRange, bool RolemasterAutoRetry)>.Failure(optionsValidation.Error!.Code, optionsValidation.Error.Message);
return ServiceResult<(string CanonicalExpression, int WildDice, bool AllowFumble, int? FumbleRange)>.Success((expressionValidation.Value!.Canonical, optionsValidation.Value.WildDice, optionsValidation.Value.AllowFumble, optionsValidation.Value.FumbleRange)); return ServiceResult<(string CanonicalExpression, int WildDice, bool AllowFumble, int? FumbleRange, bool RolemasterAutoRetry)>.Success((expressionValidation.Value!.Canonical, optionsValidation.Value.WildDice, optionsValidation.Value.AllowFumble, optionsValidation.Value.FumbleRange, optionsValidation.Value.RolemasterAutoRetry));
} }
private static ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)> ValidateOptions(RulesetKind ruleset, DiceExpression expression, int wildDice, bool allowFumble, int? fumbleRange) private static ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange, bool RolemasterAutoRetry)> ValidateOptions(RulesetKind ruleset, DiceExpression expression, int wildDice, bool allowFumble, int? fumbleRange, bool rolemasterAutoRetry)
{ {
if (wildDice < 0 || wildDice > 50) if (wildDice < 0 || wildDice > 50)
return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)>.Failure("invalid_wild_dice", "Wild dice must be between 0 and 50."); return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange, bool RolemasterAutoRetry)>.Failure("invalid_wild_dice", "Wild dice must be between 0 and 50.");
if (ruleset == RulesetKind.D6) if (ruleset == RulesetKind.D6)
{ {
if (wildDice < 1) if (wildDice < 1)
return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)>.Failure("invalid_wild_dice", "D6 skills require at least one wild die."); return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange, bool RolemasterAutoRetry)>.Failure("invalid_wild_dice", "D6 skills require at least one wild die.");
if (fumbleRange.HasValue) if (fumbleRange.HasValue)
return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)>.Failure("invalid_fumble_range", "Fumble range is only supported for Rolemaster open-ended percentile skills."); return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange, bool RolemasterAutoRetry)>.Failure("invalid_fumble_range", "Fumble range is only supported for Rolemaster open-ended percentile skills.");
return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)>.Success((wildDice, allowFumble, null)); if (rolemasterAutoRetry)
return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange, bool RolemasterAutoRetry)>.Failure("invalid_rolemaster_retry", "Automatic retry is only supported for Rolemaster open-ended percentile skills.");
return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange, bool RolemasterAutoRetry)>.Success((wildDice, allowFumble, null, false));
} }
if (ruleset == RulesetKind.Rolemaster) if (ruleset == RulesetKind.Rolemaster)
{ {
if (wildDice != 0) if (wildDice != 0)
return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)>.Failure("invalid_wild_dice", "Rolemaster skills do not support wild dice."); return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange, bool RolemasterAutoRetry)>.Failure("invalid_wild_dice", "Rolemaster skills do not support wild dice.");
if (allowFumble) if (allowFumble)
return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)>.Failure("invalid_allow_fumble", "Rolemaster skills do not use the D6 allow-fumble option."); return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange, bool RolemasterAutoRetry)>.Failure("invalid_allow_fumble", "Rolemaster skills do not use the D6 allow-fumble option.");
if (expression.Kind == DiceExpressionKind.RolemasterOpenEndedPercentile) if (expression.Kind == DiceExpressionKind.RolemasterOpenEndedPercentile)
{ {
if (!fumbleRange.HasValue) if (!fumbleRange.HasValue)
return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)>.Failure("invalid_fumble_range", "Rolemaster open-ended percentile skills require a fumble range."); return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange, bool RolemasterAutoRetry)>.Failure("invalid_fumble_range", "Rolemaster open-ended percentile skills require a fumble range.");
if (fumbleRange < 0 || fumbleRange >= 96) if (fumbleRange < 0 || fumbleRange >= 96)
return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)>.Failure("invalid_fumble_range", "Fumble range must be between 0 and 95."); return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange, bool RolemasterAutoRetry)>.Failure("invalid_fumble_range", "Fumble range must be between 0 and 95.");
return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)>.Success((0, false, fumbleRange)); return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange, bool RolemasterAutoRetry)>.Success((0, false, fumbleRange, rolemasterAutoRetry));
} }
if (fumbleRange.HasValue) if (fumbleRange.HasValue)
return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)>.Failure("invalid_fumble_range", "Fumble range is only valid for Rolemaster open-ended percentile skills."); return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange, bool RolemasterAutoRetry)>.Failure("invalid_fumble_range", "Fumble range is only valid for Rolemaster open-ended percentile skills.");
return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)>.Success((0, false, null)); if (rolemasterAutoRetry)
return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange, bool RolemasterAutoRetry)>.Failure("invalid_rolemaster_retry", "Automatic retry is only supported for Rolemaster open-ended percentile skills.");
return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange, bool RolemasterAutoRetry)>.Success((0, false, null, false));
} }
if (fumbleRange.HasValue) if (fumbleRange.HasValue)
return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)>.Failure("invalid_fumble_range", "Fumble range is only supported for Rolemaster open-ended percentile skills."); return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange, bool RolemasterAutoRetry)>.Failure("invalid_fumble_range", "Fumble range is only supported for Rolemaster open-ended percentile skills.");
return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)>.Success((0, false, null)); if (rolemasterAutoRetry)
return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange, bool RolemasterAutoRetry)>.Failure("invalid_rolemaster_retry", "Automatic retry is only supported for Rolemaster open-ended percentile skills.");
return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange, bool RolemasterAutoRetry)>.Success((0, false, null, false));
} }
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

View File

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

View File

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

241
TASKS.md
View File

@@ -1,241 +0,0 @@
# Rolemaster Automatic Skipp Retry
This ExecPlan is a living document. The sections `Progress`, `Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds.
`PLANS.md` is checked into the repo at `PLANS.md`. This document must be maintained in accordance with that file.
## Purpose / Big Picture
After this change, a Rolemaster skill can opt into an automatic retry when its first result lands in specific “skipp” bands. The player will be able to toggle that behavior while creating or editing a Rolemaster open-ended skill, roll the skill, and then see the retry clearly in the campaign log card through a special badge and readable summary text. The detailed roll view will still show enough information to explain why the retry happened and what final result was recorded.
For this feature, a “skipp” means a Rolemaster open-ended percentile skill roll whose first fully evaluated result, including the skill expression modifier and any low-end subtraction chain, lands in one of the retry windows before any retry bonus is applied. This plan preserves the user-provided thresholds exactly: results `77` through `90` grant a retry with `+5`; results `91` through `110` grant a retry with `+10`.
## Progress
- [x] (2026-04-04 23:52Z) Reviewed `PLANS.md` and the current Rolemaster roll, skill-form, API, and log-card code paths.
- [x] (2026-04-04 23:52Z) Authored this ExecPlan in `TASKS.md`.
- [ ] Add a persisted per-skill toggle for Rolemaster automatic skipp retry and thread it through API contracts, DTOs, in-memory state, and EF Core migration paths.
- [ ] Implement retry-aware Rolemaster roll execution, readable breakdown formatting, and compact log badge/summary output.
- [ ] Update Blazor skill create/edit flows so the toggle is shown only when it is valid and stale values are cleared when it is not.
- [ ] Add or update unit, API, persistence, payload-budget, and browser tests that prove the feature end to end.
- [ ] Update `README.md`, run `pwsh ./scripts/ci-local.ps1`, and commit the finished implementation.
## Surprises & Discoveries
- Observation: The current Rolemaster engine has only two special open-ended branches: high open-ended chaining and low-end subtraction. There is no second-attempt concept today.
Evidence: `RpgRoller/Services/RolemasterRollEngine.cs` calls `RollHighOpenEndedChain` for high rolls and for low-end subtraction, then immediately formats the final total.
- Observation: Compact log badges are not stored anywhere. They are recalculated when a log page is read, so any retry signal must be derivable from persisted roll data.
Evidence: `RpgRoller/Services/GameRollService.cs` calls `CampaignLogSummaryBuilder.BuildCompactLogEventBadges(...)` while building `CampaignLogListEntry`; `RollLogEntry` does not persist badges.
- Observation: The Rolemaster-specific skill UI already hides and normalizes invalid options when the expression is not open-ended percentile, which gives this feature a natural home.
Evidence: `RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor.cs` and `CharacterPanel.razor.cs` already clear `FumbleRange` when the selected Rolemaster expression is not open-ended.
## Decision Log
- Decision: The retry toggle is per skill, not per skill group, and custom rolls do not participate.
Rationale: The request explicitly asks for “a toggle for a rolemaster skill.” A skill-group default would widen scope into template inheritance and create unclear behavior for ad hoc custom rolls.
Date/Author: 2026-04-04 / Codex
- Decision: The retry windows are interpreted literally from the request: `77-90 => +5`, `91-110 => +10`. A result of `111` counts as success and doesn't need to be retried.
Rationale: The user gave concrete inclusive and exclusive bounds. Preserving those exact bounds avoids silently changing game rules inside the plan.
Date/Author: 2026-04-04 / Codex
- Decision: The retry window is evaluated from the first complete Rolemaster skill result, after the original expression modifier and any low-end subtraction chain are applied, but before any retry bonus is applied.
Rationale: The request speaks about “if the result is ...” rather than about a raw trigger die. Using the user-visible result keeps the rule understandable in the UI and in tests.
Date/Author: 2026-04-04 / Codex
- Decision: An eligible roll triggers exactly one automatic retry. The retry does not recurse again even if the retry result also lands in a retry band.
Rationale: Recursive retries would make the feature hard to explain, hard to test, and far removed from the users “automatic retry” request.
Date/Author: 2026-04-04 / Codex
- Decision: The final stored roll result becomes the retried result plus the retry bonus, while the original skipp result remains visible in the breakdown and log summary.
Rationale: An automatic retry should materially change the outcome, not merely annotate the failed first attempt. Keeping the first attempt visible preserves auditability.
Date/Author: 2026-04-04 / Codex
## Outcomes & Retrospective
Implementation has not started yet. Success for this plan means that a user can enable the toggle on a Rolemaster open-ended skill, produce a first result in the retry band, see the final roll automatically retried with the correct bonus, and recognize that special case directly in the log card without opening the detail view.
## Context and Orientation
`RpgRoller` is an ASP.NET Core plus Blazor Server application. The gameplay state lives in memory as plain domain objects under `RpgRoller/Domain`, is persisted through EF Core and SQLite under `RpgRoller/Data` and `RpgRoller/Hosting`, and is exposed through Minimal API endpoints under `RpgRoller/Api`. Blazor UI components under `RpgRoller/Components` render the authenticated workspace and call those APIs.
The Rolemaster roll implementation is isolated well enough that this feature can be added without disturbing D6 or D&D 5e. `RpgRoller/Services/RolemasterRollEngine.cs` currently handles both ordinary Rolemaster rolls and open-ended percentile rolls. `RpgRoller/Services/RollEngine.cs` dispatches by ruleset. `RpgRoller/Services/GameRollService.cs` records rolls, persists them, and builds campaign-log list entries.
Skill configuration flows through several layers. The domain model types are `RpgRoller/Domain/GameModels.cs`. EF Core maps them in `RpgRoller/Data/RpgRollerDbContext.cs`. API request and response records live in `RpgRoller/Contracts/ApiContracts.cs`. The backend service methods that validate and save skill edits live in `RpgRoller/Services/GameSkillService.cs` and `RpgRoller/Services/SkillDefinitionValidator.cs`. Blazor form state models live in `RpgRoller/Components/Pages/Home.Models.cs`. The create and edit skill modal lives in `RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor` and `.razor.cs`. Character-panel group editing lives in `RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor` and `.razor.cs`.
Campaign log cards are compact list entries, not full detail records. The compact card summary text and badge codes are composed in `RpgRoller/Services/CampaignLogSummaryBuilder.cs` and rendered in `RpgRoller/Components/Pages/HomeControls/CampaignLogPanel.razor` and `.razor.cs`. Roll detail dice chips are rendered by `RpgRoller/Components/Pages/HomeControls/RollDiceStrip.razor.cs`. If retry metadata needs to survive app restarts, it must either be encoded in the persisted dice JSON or in the persisted breakdown string because `RollLogEntry` currently stores only `Result`, `Breakdown`, and serialized `Dice`.
## Plan of Work
Start by extending the skill model so the retry toggle has a place to live. Add a non-nullable Boolean property named `RolemasterRetryOnSkipp` to `Skill` in `RpgRoller/Domain/GameModels.cs`. Thread that property through the DTO surface in `RpgRoller/Contracts/ApiContracts.cs` by extending `CreateSkillRequest`, `UpdateSkillRequest`, `SkillSummary`, and `CharacterSheetSkill`. Keep skill groups unchanged. Update `RpgRoller/Services/GameDtoMapper.cs` so summaries and character sheets include the new flag. Update cloning or state-copy helpers that copy `Skill` objects, including `RpgRoller/Services/GameStateCloneFactory.cs`, so the flag persists through load/save cycles.
Add a database migration for the new column. Update `RpgRoller/Data/RpgRollerDbContext.cs` so EF Core maps `RolemasterRetryOnSkipp` as a required Boolean with a default value of `false`. Generate a migration under `RpgRoller/Migrations` that adds the column to `Skills` with a safe default. Do not widen this migration to unrelated schema changes. If existing migration coverage fixtures or history assertions mention the newest migration id, update them so startup migration tests remain accurate.
Once the property exists, tighten validation. Extend `RpgRoller/Services/SkillDefinitionValidator.cs` so its return value includes the retry flag. Validation must accept `RolemasterRetryOnSkipp = true` only when the ruleset is Rolemaster and the parsed expression kind is `RolemasterOpenEndedPercentile`. For every other ruleset or expression kind, the backend must reject `true` with a specific validation error such as `invalid_rolemaster_retry`. When the flag is false, behavior must remain unchanged. Update `RpgRoller/Services/GameSkillService.cs`, `RpgRoller/Services/IGameService.cs`, and `RpgRoller/Api/SkillEndpoints.cs` so skill creation and update calls carry the extra argument all the way through.
Implement the retry rule in a dedicated helper instead of burying threshold math inside the roll engine. Add a new backend helper file, for example `RpgRoller/Services/RolemasterRetryPolicy.cs`, with a small API such as `public static int? ResolveSkippRetryBonus(int firstResult)`. This helper must return `5`, `10`, or `null` according to the exact windows described earlier. Put the thresholds here so both tests and the roll engine read the same source of truth.
Then extend the Rolemaster roll engine. Change `RpgRoller/Services/RollEngine.cs` so Rolemaster rolls can receive the new per-skill toggle. Change `RpgRoller/Services/RolemasterRollEngine.cs` so open-ended percentile rolls do the following: compute the first attempt exactly as today; evaluate `RolemasterRetryPolicy.ResolveSkippRetryBonus(firstResult)`; if the toggle is off or the policy returns `null`, return the original result unchanged; otherwise perform one more full roll of the same parsed expression, calculate that second attempts result exactly as a normal Rolemaster open-ended roll, add the retry bonus to that second attempt, and store that number as the final roll result.
Make the retry understandable in persisted output. Introduce an optional `Attempt` property on `RollDieResult` in `RpgRoller/Contracts/ApiContracts.cs` so dice from the original roll can be tagged as attempt `1` and retry dice as attempt `2`. Keep the existing `Sequence` property semantics within each attempt. Update `RolemasterRollEngine` to set `Attempt = 1` for the original dice and `Attempt = 2` for retry dice. Update `RpgRoller/Components/Pages/HomeControls/RollDiceStrip.razor.cs` so the generated title text mentions “attempt 1” or “retry attempt” when `Attempt` is present. This keeps the detail view legible without redesigning the dice strip layout.
The breakdown string must become parseable and human-readable. Extend `RpgRoller/Services/RollBreakdownFormatter.cs` with a formatter dedicated to retry output. Preserve the current breakdown text for each individual attempt, then combine them into a single breakdown string shaped like:
<first-attempt-breakdown>; retry(+5): <retry-attempt-breakdown>; final=<final-result>
or
<first-attempt-breakdown>; retry(+10): <retry-attempt-breakdown>; final=<final-result>
This format is intentionally simple. It is readable in the UI, survives persistence without new tables, and gives `CampaignLogSummaryBuilder` a stable marker to detect retry badges later. Do not change non-retry breakdown formatting.
Update the compact campaign-log helpers to surface the new special result. `RpgRoller/Services/CampaignLogSummaryBuilder.cs` should accept the stored breakdown when building badges and compact summaries. Add badge codes `rs5` and `rs10` for “Retry +5” and “Retry +10”. When a retry marker is present in the breakdown, append a short retry note to the Rolemaster compact summary, for example `| retry +5` or `| retry +10`, while keeping existing `rf`, `r66`, and `r100` behavior intact. Update `RpgRoller/Components/Pages/HomeControls/CampaignLogPanel.razor.cs` so those new badge codes render as visible labels on the log card.
After the backend shape is stable, wire the UI toggle. Extend `SkillFormModel` in `RpgRoller/Components/Pages/Home.Models.cs` with `RolemasterRetryOnSkipp`. Update `SkillFormModal.razor.cs` so the form copies the value from `InitialModel`, validates it only for Rolemaster open-ended expressions, and clears it automatically when the skill expression becomes invalid for retry. Update `SkillFormModal.razor` to show a checkbox only in the Rolemaster open-ended branch, near the fumble-range input, with concise help text that explains the exact windows. Update `CharacterPanel.razor.cs` so create and edit skill dialogs pass the property in their initial models and request payloads. Do not add a corresponding group-level control.
Expose the setting in the workspace read model so the user can see it again after save. Extend `WorkspaceState.SkillDefinitionLabel(...)` and `RulesetFormHelpers.DescribeRolemasterExpression(...)` as needed so a Rolemaster open-ended skill with retry enabled renders a label that includes the retry rule in compact form, for example `Open-ended percentile: d100!+15, fumble <= 5, retry skipp`. Keep the label short enough that existing layout remains intact.
Finally, update documentation. `README.md` must describe the new Rolemaster skill option, the retry windows, and the fact that the campaign log now shows retry badges. If an example command or screenshot-free narrative is needed, keep it textual and current rather than writing a historical change note.
## Milestones
### Milestone 1: Persist and validate the skill toggle
At the end of this milestone, the repository can create, read, update, persist, and reload a Rolemaster skill with the retry flag, but no roll behavior changes yet. The new property exists in the domain model, EF Core schema, API contracts, service signatures, DTOs, and Blazor form state. Validation rejects impossible combinations such as D6 plus retry enabled or Rolemaster `d10` plus retry enabled.
Run the service and API tests that cover skill validation and persistence from `D:\Code\RpgRoller`:
dotnet test RpgRoller.Tests/RpgRoller.Tests.csproj --filter "FullyQualifiedName~ServiceHelperExtractionTests|FullyQualifiedName~ServicePersistenceTests|FullyQualifiedName~WorkspaceQueryServiceTests|FullyQualifiedName~CampaignApiTests"
Acceptance is that new tests prove the flag survives round trips and that stale invalid values are rejected before any roll logic is touched.
### Milestone 2: Execute and record automatic retry
At the end of this milestone, an eligible Rolemaster open-ended skill roll automatically performs one retry and stores a final result based on the retry attempt plus the policy bonus. Non-eligible or disabled skills still behave exactly as before. The detailed breakdown string explains the first attempt, the retry bonus, the retry attempt, and the final result. The detail dice strip distinguishes original and retry attempts through titles.
Run targeted Rolemaster service and API tests from `D:\Code\RpgRoller`:
dotnet test RpgRoller.Tests/RpgRoller.Tests.csproj --filter "FullyQualifiedName~ServiceRolemasterRollTests|FullyQualifiedName~RolemasterApiTests|FullyQualifiedName~PayloadBudgetTests"
Acceptance is that there are explicit tests for a `+5` retry case, a `+10` retry case, and a disabled-skill case that proves the old result path remains unchanged.
### Milestone 3: Surface the retry in the workspace and lock the behavior
At the end of this milestone, the create and edit skill UI exposes the toggle only when valid, saved skills show the toggle again when reopened, log cards display retry badges, and browser smoke coverage proves the experience without manual clicking. Documentation is updated and the full local parity script passes.
Run from `D:\Code\RpgRoller`:
pwsh ./scripts/ci-local.ps1
Acceptance is that the run finishes successfully, the new browser test proves the checkbox and badge behavior, and no unrelated payload-budget or smoke-test regressions appear.
## Concrete Steps
Work from `D:\Code\RpgRoller`.
1. Edit the domain model and contracts first so every later compiler error points toward the remaining call sites. Update `RpgRoller/Domain/GameModels.cs`, `RpgRoller/Contracts/ApiContracts.cs`, `RpgRoller/Services/GameDtoMapper.cs`, `RpgRoller/Services/GameStateCloneFactory.cs`, `RpgRoller/Services/IGameService.cs`, `RpgRoller/Services/GameSkillService.cs`, and `RpgRoller/Api/SkillEndpoints.cs`.
2. Add the EF Core schema update in `RpgRoller/Data/RpgRollerDbContext.cs`, generate the migration in `RpgRoller/Migrations`, and update any migration-history assertions in `RpgRoller.Tests/HostingCoverageTests.cs` if they depend on the latest migration name.
3. Add backend validation and retry policy code. Extend `RpgRoller/Services/SkillDefinitionValidator.cs`. Add `RpgRoller/Services/RolemasterRetryPolicy.cs`. Update `RpgRoller/Services/RollEngine.cs`, `RpgRoller/Services/RolemasterRollEngine.cs`, and `RpgRoller/Services/RollBreakdownFormatter.cs`.
4. Update compact log helpers and UI consumers. Change `RpgRoller/Services/CampaignLogSummaryBuilder.cs`, `RpgRoller/Services/GameRollService.cs`, `RpgRoller/Components/Pages/HomeControls/CampaignLogPanel.razor.cs`, and `RpgRoller/Components/Pages/HomeControls/RollDiceStrip.razor.cs`.
5. Update form models and skill forms. Change `RpgRoller/Components/Pages/Home.Models.cs`, `RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor`, `RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor.cs`, `RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor.cs`, `RpgRoller/Components/Pages/WorkspaceState.cs`, and `RpgRoller/Components/Pages/HomeControls/RulesetFormHelpers.cs`.
6. Add or update tests before running the full parity script. The expected primary test files are `RpgRoller.Tests/Services/ServiceRolemasterRollTests.cs`, `RpgRoller.Tests/Services/ServiceHelperExtractionTests.cs`, `RpgRoller.Tests/Services/ServicePersistenceTests.cs`, `RpgRoller.Tests/Services/ServiceRollHelperTests.cs`, `RpgRoller.Tests/Services/WorkspaceStateTests.cs`, `RpgRoller.Tests/Api/RolemasterApiTests.cs`, and `tests/e2e/smoke.spec.js`.
7. Update `README.md`, run the full local parity script, inspect `git status`, and commit with a brief message only after all validations pass.
Expected final command transcript:
==> Run tests
Passed! - Failed: 0, Passed: <updated count>, Skipped: 0, Total: <updated count>
==> Enforce coverage thresholds
Line coverage: <at least 90%>
Branch coverage: <at least 70%>
==> Run Playwright smoke test
<smoke tests all pass>
CI checks passed.
## Validation and Acceptance
Validation is complete only when all of the following are true.
The backend proves rule correctness. There must be a unit test where the first attempt result is `78` and the stored final result comes from a retry with `+5`. There must be another where the first attempt result is `96` or another value inside the second band and the stored final result comes from a retry with `+10`. There must be a test where the skill toggle is disabled and an otherwise eligible first result still does not retry.
The persistence layer proves round-trip safety. A test must create a skill with the toggle enabled, persist the database, reload state, and confirm the skill still exposes `RolemasterRetryOnSkipp = true`.
The API layer proves contract shape. A test must create and update a Rolemaster open-ended skill through HTTP and confirm the toggle round-trips through `SkillSummary`, `CharacterSheet`, and roll results. Invalid combinations must return a concrete API error rather than silently coercing the value.
The compact log proves user-visible behavior. A service or API test must show that a retried roll produces `rs5` or `rs10` in the log entry badges and that the summary text includes a retry marker. A browser test must create or use a retry-enabled Rolemaster skill, roll it, and verify that the log card shows the retry badge without expanding the detail row first.
The whole repo must still pass the local parity script:
pwsh ./scripts/ci-local.ps1
## Idempotence and Recovery
This plan is safe to execute incrementally. Compiler errors after the first contract changes are expected because the repository is strongly typed; fix the next call site rather than backing out partial edits.
If the EF Core migration step is blocked because the development app or tests are holding the SQLite file open, stop the running `dotnet` process, retry the migration command once, and continue. This repo explicitly allows that recovery step after database changes.
If a partial implementation leaves the feature half-wired, the safe recovery path is to keep the new property and validation in place, set the toggle to default `false`, and continue forward until tests pass. Do not delete unrelated user changes from the worktree to recover.
## Artifacts and Notes
The key code paths to revisit while implementing are these:
RpgRoller/Services/RolemasterRollEngine.cs
RpgRoller/Services/CampaignLogSummaryBuilder.cs
RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor.cs
RpgRoller/Components/Pages/HomeControls/CampaignLogPanel.razor.cs
The intended retry badge mapping is:
rs5 -> Retry +5
rs10 -> Retry +10
The intended breakdown marker is:
; retry(+5):
; retry(+10):
These marker strings are chosen so they can be detected cheaply without adding another persisted table or JSON blob.
## Interfaces and Dependencies
At the end of the implementation, these interfaces and shapes must exist.
In `RpgRoller/Domain/GameModels.cs`, `Skill` must expose:
public bool RolemasterRetryOnSkipp { get; set; }
In `RpgRoller/Contracts/ApiContracts.cs`, these records must include the new Boolean:
public sealed record CreateSkillRequest(..., int? FumbleRange = null, bool RolemasterRetryOnSkipp = false);
public sealed record UpdateSkillRequest(..., int? FumbleRange = null, bool RolemasterRetryOnSkipp = false);
public sealed record SkillSummary(..., int? FumbleRange, bool RolemasterRetryOnSkipp);
public sealed record CharacterSheetSkill(..., int? FumbleRange, bool RolemasterRetryOnSkipp);
`RollDieResult` must gain an optional attempt marker:
public int? Attempt { get; init; }
In `RpgRoller/Services/RolemasterRetryPolicy.cs`, define:
public static int? ResolveSkippRetryBonus(int firstResult)
In `RpgRoller/Services/SkillDefinitionValidator.cs`, the validation result tuple must carry the retry flag so backend callers do not need to re-derive validity from raw request data.
In `RpgRoller/Services/RollEngine.cs`, the Rolemaster dispatch signature must carry the skill toggle through to `RolemasterRollEngine`.
In `RpgRoller/Services/RolemasterRollEngine.cs`, the open-ended roll path must still return:
(int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice)
but it must now build that tuple from one or two attempts depending on the retry policy.
Revision note: created this ExecPlan on 2026-04-04 because the user requested a repository-local execution plan in `TASKS.md` before implementation.

Some files were not shown because too many files have changed in this diff Show More