Compare commits
73 Commits
feature/pa
...
3e1d3746dd
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e1d3746dd | |||
| 368a9a4960 | |||
| 9e91fb2719 | |||
| 8d59868392 | |||
| d4e72fe5bb | |||
| 2997247eeb | |||
| 0c638e8ebe | |||
| 0dff8a275c | |||
| d38003a77c | |||
| f63c3f8f28 | |||
| 5c62fb5bbb | |||
| 9278e59825 | |||
| 42a9164ddd | |||
| 600ea6770d | |||
| b135203318 | |||
| a290ff87dd | |||
| 938d8a5eba | |||
| 12b8aaee26 | |||
| 46a63f9e06 | |||
| 305999e4b7 | |||
| b291d0531f | |||
| 6cdd29ed93 | |||
| 6f9acdc165 | |||
| a2e130abb1 | |||
| 97ddb4b136 | |||
| 4690c8b5e1 | |||
| 25040d7824 | |||
| 93c19f0705 | |||
| 4d5d112168 | |||
| ec40baa107 | |||
| abee1729c5 | |||
| b3cde614e7 | |||
| 4af1c87639 | |||
| 0124325c20 | |||
| 4f77d4a702 | |||
| 17b049d2ca | |||
| 3d7f3d1ee4 | |||
| ad4241aaaf | |||
| 9479b2e2f3 | |||
| f6046e65f8 | |||
| 951ce9f1fe | |||
| a9558a16fc | |||
| 8961c75305 | |||
| fa5bad23a7 | |||
| 7ec2887df2 | |||
| 8c5a88afc5 | |||
| a5f8421aa8 | |||
| 8c413a8ded | |||
| 2e6951e695 | |||
| 22ee512cb7 | |||
| 9e6e6fe8c7 | |||
| 7248b60395 | |||
| b26d58cea4 | |||
| 9581442cab | |||
| 7d91e7c900 | |||
| 923c6ae26d | |||
| f0dd79e589 | |||
| e5f00fa693 | |||
| 61ea310179 | |||
| 960197354a | |||
| 0059fde74f | |||
| 9b9927084b | |||
| 48439fd21d | |||
| 90afe3b06b | |||
| 13c6215c89 | |||
| da9dc24d8e | |||
| f750f5adc4 | |||
| 31dcb0c4a9 | |||
| ac586b0e55 | |||
| fadb7efd64 | |||
| bf6113f790 | |||
| 0ac1bda10b | |||
| f04f4aa08a |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -11,6 +11,8 @@ artifacts/
|
||||
!.vscode/launch.json
|
||||
!.vscode/tasks.json
|
||||
node_modules/
|
||||
playwright-report/
|
||||
test-results/
|
||||
|
||||
# User secrets / configs
|
||||
appsettings.Development.json
|
||||
|
||||
25
AGENTS.md
25
AGENTS.md
@@ -8,13 +8,34 @@ Also see the other related technical documentation: README.md.
|
||||
- PowerShell doesn't support bash-style heredocs. If complex scripts need to be executed, consider using python. Run Python code using python -c with inline commands instead of python - <<'PY'.
|
||||
- web.config in the server is different than locally, it must be exluded from deployment.
|
||||
- Before beginning with the edit phase, always present a plan first. Only begin editing after the user approves the plan.
|
||||
- Don't make assumptions in the plan. If necessary, ask all clarifying questions before presenting the final plan.
|
||||
- After every iteration, evaluate if the test coverage would fall below 100%, and write tests if necessary.
|
||||
- After every iteration, run `jb cleanupcode --build=False '$file1' '$file2'` for every file you touched.
|
||||
- After every iteration, run "scripts/ci-local.ps1" and ensure that nothing broke.
|
||||
- After every iteration, update all related documentation according to the change, and evaluate if a FAQ entry would help the users, serving as public documentation for this project.
|
||||
- After every frontend change, verify the results using playwright.
|
||||
- After every iteration, update all related documentation according to the change.
|
||||
- Update the wording of touched concerns instead of introducing incremental change reports
|
||||
- The documentation should always represent the current state in its entirety and not derail into a historical development log.
|
||||
- After every frontend change, verify the results using an ephemeral playwright.
|
||||
- After every iteration, do a git commit with a brief summary of the changes as a commit message.
|
||||
- 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.
|
||||
- If you find unexpected changes in the code (deletions, changes, diff results that were not communicated), never revert them and never restore the old state. Assume that those changes happened with intent.
|
||||
- Never use `git restore`, `git checkout --`, reset commands, or equivalent rollback actions to discard local changes unless the user explicitly asks for that exact rollback.
|
||||
- If a required tool is missing (for example `dotnet-ef`), install/configure the tool (prefer repo-local setup such as `dotnet tool manifest`) instead of weakening validations or muting warnings. If installation is blocked, stop and ask before changing validation strictness.
|
||||
- After changing the database, if your build is blocked by a running dotnet process, feel free to kill the process and retry the operation once.
|
||||
|
||||
## Output generation
|
||||
|
||||
### User
|
||||
- For the user, you talk like a caveman. Speak only short grunts. Communicate in english.
|
||||
- Give no explanations unless explicitly asked.
|
||||
- No fillers like 'happy to help'. Do task first. Show result. Stop.
|
||||
- For tools: "Tool work." then output the result and words like "Problem.", "Plan?", "Done."
|
||||
- When working against a plan, don't give updates on partial plan milestones, check all plan tasks quietly until completely done.
|
||||
|
||||
### Reasoning
|
||||
- Feel free to speak to yourself in whatever language suits you best, focusing on concise, compact and essential information exchange.
|
||||
|
||||
## ExecPlans
|
||||
|
||||
- When writing complex features or significant refactors, use an ExecPlan (as described in PLANS.md) from design to implementation.
|
||||
150
PLANS.md
Normal file
150
PLANS.md
Normal file
@@ -0,0 +1,150 @@
|
||||
# Codex Execution Plans (ExecPlans):
|
||||
|
||||
This document describes the requirements for an execution plan ("ExecPlan"), a design document that a coding agent can follow to deliver a working feature or system change. Treat the reader as a complete beginner to this repository: they have only the current working tree and the single ExecPlan file you provide. There is no memory of prior plans and no external context.
|
||||
|
||||
## How to use ExecPlans and PLANS.md
|
||||
|
||||
When authoring an executable specification (ExecPlan), follow PLANS.md _to the letter_. If it is not in your context, refresh your memory by reading the entire PLANS.md file. Be thorough in reading (and re-reading) source material to produce an accurate specification. When creating a spec, start from the skeleton and flesh it out as you do your research.
|
||||
|
||||
When implementing an executable specification (ExecPlan), do not prompt the user for "next steps"; simply proceed to the next milestone. Keep all sections up to date, add or split entries in the list at every stopping point to affirmatively state the progress made and next steps. Resolve ambiguities autonomously, and commit frequently.
|
||||
|
||||
When discussing an executable specification (ExecPlan), record decisions in a log in the spec for posterity; it should be unambiguously clear why any change to the specification was made. ExecPlans are living documents, and it should always be possible to restart from _only_ the ExecPlan and no other work.
|
||||
|
||||
When researching a design with challenging requirements or significant unknowns, use milestones to implement proof of concepts, "toy implementations", etc., that allow validating whether the user's proposal is feasible. Read the source code of libraries by finding or acquiring them, research deeply, and include prototypes to guide a fuller implementation.
|
||||
|
||||
## Requirements
|
||||
|
||||
NON-NEGOTIABLE REQUIREMENTS:
|
||||
|
||||
* Every ExecPlan must be fully self-contained. Self-contained means that in its current form it contains all knowledge and instructions needed for a novice to succeed.
|
||||
* Every ExecPlan is a living document. Contributors are required to revise it as progress is made, as discoveries occur, and as design decisions are finalized. Each revision must remain fully self-contained.
|
||||
* Every ExecPlan must enable a complete novice to implement the feature end-to-end without prior knowledge of this repo.
|
||||
* Every ExecPlan must produce a demonstrably working behavior, not merely code changes to "meet a definition".
|
||||
* Every ExecPlan must define every term of art in plain language or do not use it.
|
||||
|
||||
Purpose and intent come first. Begin by explaining, in a few sentences, why the work matters from a user's perspective: what someone can do after this change that they could not do before, and how to see it working. Then guide the reader through the exact steps to achieve that outcome, including what to edit, what to run, and what they should observe.
|
||||
|
||||
The agent executing your plan can list files, read files, search, run the project, and run tests. It does not know any prior context and cannot infer what you meant from earlier milestones. Repeat any assumption you rely on. Do not point to external blogs or docs; if knowledge is required, embed it in the plan itself in your own words. If an ExecPlan builds upon a prior ExecPlan and that file is checked in, incorporate it by reference. If it is not, you must include all relevant context from that plan.
|
||||
|
||||
## Formatting
|
||||
|
||||
Format and envelope are simple and strict. Each ExecPlan must be one single fenced code block labeled as `md` that begins and ends with triple backticks. Do not nest additional triple-backtick code fences inside; when you need to show commands, transcripts, diffs, or code, present them as indented blocks within that single fence. Use indentation for clarity rather than code fences inside an ExecPlan to avoid prematurely closing the ExecPlan's code fence. Use two newlines after every heading, use # and ## and so on, and correct syntax for ordered and unordered lists.
|
||||
|
||||
When writing an ExecPlan to a Markdown (.md) file where the content of the file *is only* the single ExecPlan, you should omit the triple backticks.
|
||||
|
||||
Write in plain prose. Prefer sentences over lists. Avoid checklists, tables, and long enumerations unless brevity would obscure meaning. Checklists are permitted only in the `Progress` section, where they are mandatory. Narrative sections must remain prose-first.
|
||||
|
||||
## Guidelines
|
||||
|
||||
Self-containment and plain language are paramount. If you introduce a phrase that is not ordinary English ("daemon", "middleware", "RPC gateway", "filter graph"), define it immediately and remind the reader how it manifests in this repository (for example, by naming the files or commands where it appears). Do not say "as defined previously" or "according to the architecture doc." Include the needed explanation here, even if you repeat yourself.
|
||||
|
||||
Avoid common failure modes. Do not rely on undefined jargon. Do not describe "the letter of a feature" so narrowly that the resulting code compiles but does nothing meaningful. Do not outsource key decisions to the reader. When ambiguity exists, resolve it in the plan itself and explain why you chose that path. Err on the side of over-explaining user-visible effects and under-specifying incidental implementation details.
|
||||
|
||||
Anchor the plan with observable outcomes. State what the user can do after implementation, the commands to run, and the outputs they should see. Acceptance should be phrased as behavior a human can verify ("after starting the server, navigating to [http://localhost:8080/health](http://localhost:8080/health) returns HTTP 200 with body OK") rather than internal attributes ("added a HealthCheck struct"). If a change is internal, explain how its impact can still be demonstrated (for example, by running tests that fail before and pass after, and by showing a scenario that uses the new behavior).
|
||||
|
||||
Specify repository context explicitly. Name files with full repository-relative paths, name functions and modules precisely, and describe where new files should be created. If touching multiple areas, include a short orientation paragraph that explains how those parts fit together so a novice can navigate confidently. When running commands, show the working directory and exact command line. When outcomes depend on environment, state the assumptions and provide alternatives when reasonable.
|
||||
|
||||
Be idempotent and safe. Write the steps so they can be run multiple times without causing damage or drift. If a step can fail halfway, include how to retry or adapt. If a migration or destructive operation is necessary, spell out backups or safe fallbacks. Prefer additive, testable changes that can be validated as you go.
|
||||
|
||||
Validation is not optional. Include instructions to run tests, to start the system if applicable, and to observe it doing something useful. Describe comprehensive testing for any new features or capabilities. Include expected outputs and error messages so a novice can tell success from failure. Where possible, show how to prove that the change is effective beyond compilation (for example, through a small end-to-end scenario, a CLI invocation, or an HTTP request/response transcript). State the exact test commands appropriate to the project’s toolchain and how to interpret their results.
|
||||
|
||||
Capture evidence. When your steps produce terminal output, short diffs, or logs, include them inside the single fenced block as indented examples. Keep them concise and focused on what proves success. If you need to include a patch, prefer file-scoped diffs or small excerpts that a reader can recreate by following your instructions rather than pasting large blobs.
|
||||
|
||||
## Milestones
|
||||
|
||||
Milestones are narrative, not bureaucracy. If you break the work into milestones, introduce each with a brief paragraph that describes the scope, what will exist at the end of the milestone that did not exist before, the commands to run, and the acceptance you expect to observe. Keep it readable as a story: goal, work, result, proof. Progress and milestones are distinct: milestones tell the story, progress tracks granular work. Both must exist. Never abbreviate a milestone merely for the sake of brevity, do not leave out details that could be crucial to a future implementation.
|
||||
|
||||
Each milestone must be independently verifiable and incrementally implement the overall goal of the execution plan.
|
||||
|
||||
## Living plans and design decisions
|
||||
|
||||
* ExecPlans are living documents. As you make key design decisions, update the plan to record both the decision and the thinking behind it. Record all decisions in the `Decision Log` section.
|
||||
* ExecPlans must contain and maintain a `Progress` section, a `Surprises & Discoveries` section, a `Decision Log`, and an `Outcomes & Retrospective` section. These are not optional.
|
||||
* When you discover optimizer behavior, performance tradeoffs, unexpected bugs, or inverse/unapply semantics that shaped your approach, capture those observations in the `Surprises & Discoveries` section with short evidence snippets (test output is ideal).
|
||||
* If you change course mid-implementation, document why in the `Decision Log` and reflect the implications in `Progress`. Plans are guides for the next contributor as much as checklists for you.
|
||||
* At completion of a major task or the full plan, write an `Outcomes & Retrospective` entry summarizing what was achieved, what remains, and lessons learned.
|
||||
|
||||
# Prototyping milestones and parallel implementations
|
||||
|
||||
It is acceptable—-and often encouraged—-to include explicit prototyping milestones when they de-risk a larger change. Examples: adding a low-level operator to a dependency to validate feasibility, or exploring two composition orders while measuring optimizer effects. Keep prototypes additive and testable. Clearly label the scope as “prototyping”; describe how to run and observe results; and state the criteria for promoting or discarding the prototype.
|
||||
|
||||
Prefer additive code changes followed by subtractions that keep tests passing. Parallel implementations (e.g., keeping an adapter alongside an older path during migration) are fine when they reduce risk or enable tests to continue passing during a large migration. Describe how to validate both paths and how to retire one safely with tests. When working with multiple new libraries or feature areas, consider creating spikes that evaluate the feasibility of these features _independently_ of one another, proving that the external library performs as expected and implements the features we need in isolation.
|
||||
|
||||
## Skeleton of a Good ExecPlan
|
||||
|
||||
# <Short, action-oriented description>
|
||||
|
||||
This ExecPlan is a living document. The sections `Progress`, `Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds.
|
||||
|
||||
If PLANS.md file is checked into the repo, reference the path to that file here from the repository root and note that this document must be maintained in accordance with PLANS.md.
|
||||
|
||||
## Purpose / Big Picture
|
||||
|
||||
Explain in a few sentences what someone gains after this change and how they can see it working. State the user-visible behavior you will enable.
|
||||
|
||||
## Progress
|
||||
|
||||
Use a list with checkboxes to summarize granular steps. Every stopping point must be documented here, even if it requires splitting a partially completed task into two (“done” vs. “remaining”). This section must always reflect the actual current state of the work.
|
||||
|
||||
- [x] (2025-10-01 13:00Z) Example completed step.
|
||||
- [ ] Example incomplete step.
|
||||
- [ ] Example partially completed step (completed: X; remaining: Y).
|
||||
|
||||
Use timestamps to measure rates of progress.
|
||||
|
||||
## Surprises & Discoveries
|
||||
|
||||
Document unexpected behaviors, bugs, optimizations, or insights discovered during implementation. Provide concise evidence.
|
||||
|
||||
- Observation: …
|
||||
Evidence: …
|
||||
|
||||
## Decision Log
|
||||
|
||||
Record every decision made while working on the plan in the format:
|
||||
|
||||
- Decision: …
|
||||
Rationale: …
|
||||
Date/Author: …
|
||||
|
||||
## Outcomes & Retrospective
|
||||
|
||||
Summarize outcomes, gaps, and lessons learned at major milestones or at completion. Compare the result against the original purpose.
|
||||
|
||||
## Context and Orientation
|
||||
|
||||
Describe the current state relevant to this task as if the reader knows nothing. Name the key files and modules by full path. Define any non-obvious term you will use. Do not refer to prior plans.
|
||||
|
||||
## Plan of Work
|
||||
|
||||
Describe, in prose, the sequence of edits and additions. For each edit, name the file and location (function, module) and what to insert or change. Keep it concrete and minimal.
|
||||
|
||||
## Concrete Steps
|
||||
|
||||
State the exact commands to run and where to run them (working directory). When a command generates output, show a short expected transcript so the reader can compare. This section must be updated as work proceeds.
|
||||
|
||||
## Validation and Acceptance
|
||||
|
||||
Describe how to start or exercise the system and what to observe. Phrase acceptance as behavior, with specific inputs and outputs. If tests are involved, say "run <project’s test command> and expect <N> passed; the new test <name> fails before the change and passes after>".
|
||||
|
||||
## Idempotence and Recovery
|
||||
|
||||
If steps can be repeated safely, say so. If a step is risky, provide a safe retry or rollback path. Keep the environment clean after completion.
|
||||
|
||||
## Artifacts and Notes
|
||||
|
||||
Include the most important transcripts, diffs, or snippets as indented examples. Keep them concise and focused on what proves success.
|
||||
|
||||
## Interfaces and Dependencies
|
||||
|
||||
Be prescriptive. Name the libraries, modules, and services to use and why. Specify the types, traits/interfaces, and function signatures that must exist at the end of the milestone. Prefer stable names and paths such as `crate::module::function` or `package.submodule.Interface`. E.g.:
|
||||
|
||||
In crates/foo/planner.rs, define:
|
||||
|
||||
pub trait Planner {
|
||||
fn plan(&self, observed: &Observed) -> Vec<Action>;
|
||||
}
|
||||
|
||||
If you follow the guidance above, a single, stateless agent -- or a human novice -- can read your ExecPlan from top to bottom and produce a working, observable result. That is the bar: SELF-CONTAINED, SELF-SUFFICIENT, NOVICE-GUIDING, OUTCOME-FOCUSED.
|
||||
|
||||
When you revise a plan, you must ensure your changes are comprehensively reflected across all sections, including the living document sections, and you must write a note at the bottom of the plan describing the change and the reason why. ExecPlans must describe not just the what but the why for almost everything.
|
||||
193
README.md
193
README.md
@@ -1,121 +1,176 @@
|
||||
# RpgRoller
|
||||
|
||||
Fresh full-stack starter scaffold:
|
||||
RpgRoller is an ASP.NET Core + Blazor Server app for lightweight tabletop campaign play, character sheets, and dice workflows.
|
||||
|
||||
- `RpgRoller/`: ASP.NET Core backend + Blazor frontend host (`Components` + `wwwroot`)
|
||||
- `RpgRoller.Tests/`: xUnit integration-heavy test project
|
||||
- `RpgRoller.sln`: solution used by local CI script
|
||||
- `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.sln`: solution used by local development and repo scripts
|
||||
|
||||
Test layout:
|
||||
|
||||
- `RpgRoller.Tests/Api/`: API integration tests grouped by feature concern
|
||||
- `RpgRoller.Tests/Services/`: service-level tests grouped by domain concern
|
||||
- `RpgRoller.Tests/Support/`: shared test harnesses/builders/helpers
|
||||
- `RpgRoller.Tests/Api/`: endpoint and host-facing integration tests
|
||||
- `RpgRoller.Tests/Services/`: service and rules-engine tests
|
||||
- `RpgRoller.Tests/Support/`: shared harnesses, builders, and test host helpers
|
||||
|
||||
## Code Organization
|
||||
|
||||
Backend:
|
||||
|
||||
- `RpgRoller/Program.cs`: thin app bootstrap only
|
||||
- `RpgRoller/Hosting/`: service registration + startup initialization
|
||||
- `RpgRoller/Api/`: endpoint mapping modules and auth/session filter helpers
|
||||
- `RpgRoller/Services/`: game workflows with explicit method parameters (no API DTO dependencies)
|
||||
- `RpgRoller/Program.cs`: app bootstrap, JSON options, compression, API/component mapping, and optional `PathBase`
|
||||
- `RpgRoller/Hosting/`: service registration, startup initialization, SQLite path resolution, and schema upgrades
|
||||
- `RpgRoller/Api/`: minimal API endpoint groups, request mappings, cookie/session helpers, and result mapping
|
||||
- `RpgRoller/Services/`: gameplay and account workflows behind `IGameService`
|
||||
- `RpgRoller/Services/GameService.cs`: facade over composed domain services
|
||||
- `RpgRoller/Services/GameAuthService.cs`: registration, login, logout, session lookup, and `GetMe`
|
||||
- `RpgRoller/Services/GameCampaignService.cs`: campaign creation, listing, roster reads, campaign options, and deletion
|
||||
- `RpgRoller/Services/GameCharacterService.cs`: character creation, updates, activation, deletion, transfer, and owner-scoped reads
|
||||
- `RpgRoller/Services/GameSkillService.cs`: skill-group CRUD, skill CRUD, sheet shaping, and ruleset validation
|
||||
- `RpgRoller/Services/GameRollService.cs`: skill/custom rolls, compact log pages, roll detail, and campaign state snapshots
|
||||
- `RpgRoller/Services/GameUserAdministrationService.cs`: username reads, admin user listing, role updates, and account deletion
|
||||
- `RpgRoller/Services/GameStateStore.cs`, `GameStateCloneFactory.cs`, and `GamePersistenceService.cs`: in-memory runtime state, campaign-state version tracking, and SQLite load/save boundaries
|
||||
- `RpgRoller/Services/GameAuthorization.cs`, `GameContextResolver.cs`, and `GameDtoMapper.cs`: shared authorization, session/campaign resolution, and backend read-model mapping
|
||||
- `RpgRoller/Services/RollEngine.cs`, `StandardRollEngine.cs`, `D6RollEngine.cs`, `RolemasterRollEngine.cs`, `RollBreakdownFormatter.cs`, and `CampaignLogSummaryBuilder.cs`: ruleset-specific dice execution, breakdown formatting, and compact campaign-log summaries
|
||||
- `RpgRoller/Services/SkillDefinitionValidator.cs`, `RoleSerializer.cs`, `RollVisibilityParser.cs`, and `CustomRollOptionsResolver.cs`: shared rules and parsing helpers
|
||||
|
||||
Frontend:
|
||||
|
||||
- `RpgRoller/Components/`: Blazor root app, routes, layout and page components
|
||||
- `RpgRoller/Components/Pages/Home.razor`: minimal gateway page (loading/auth/workspace switch)
|
||||
- `RpgRoller/Components/Pages/Home.razor.cs`: single `Home` code-behind with only gateway/session-view orchestration
|
||||
- `RpgRoller/Components/Pages/Workspace.razor`: authenticated workspace UI and workspace-specific state/logic
|
||||
- `RpgRoller/Components/**/*.razor.cs`: component code-behind classes (state, handlers, parameters, injected dependencies); `.razor` files remain markup-focused
|
||||
- `RpgRoller/Components/Pages/Home.Models.cs`: reusable `FormState<TModel>` + page form models
|
||||
- `RpgRoller/Components/Pages/HomeControls/`: auth, campaign management, play-screen, and modal controls extracted from `Home.razor`
|
||||
- Form ownership model: controls own transient form/error state and execute their concern-specific API mutations directly
|
||||
- Skill create/edit workflow ownership: `CharacterPanel` (characters own skills in UI and behavior)
|
||||
- `RpgRoller/Components/RpgRollerApiClient.cs`: shared browser API client used by `Home`, `Workspace`, and leaf controls
|
||||
- `RpgRoller/wwwroot/js/rpgroller-api.js`: browser-side API + SSE + session storage interop for Blazor
|
||||
- `RpgRoller/wwwroot/styles.css`: responsive UX styling and theme tokens
|
||||
- `RpgRoller/Components/`: Blazor app shell, routes, layout, page components, and query/client helpers
|
||||
- `RpgRoller/Components/Pages/Home.razor`: gateway that switches between loading, auth, and authenticated workspace views, and force-reloads after login so the authenticated play workspace is built from the fresh session cookie
|
||||
- `RpgRoller/Components/Pages/Home.razor.cs`: gateway/session orchestration for `Home`
|
||||
- `RpgRoller/Components/Pages/Workspace.razor`: authenticated workspace UI
|
||||
- `RpgRoller/Components/Pages/Workspace.razor.cs`: workspace composition root, coordinator wiring, lifecycle, and JS-invokable entry points
|
||||
- `RpgRoller/Components/Pages/WorkspaceState.cs`: workspace UI state plus pure computed and formatting projections used directly by the Razor view
|
||||
- `RpgRoller/Components/Pages/WorkspaceSessionCoordinator.cs`, `WorkspaceCampaignCoordinator.cs`, `WorkspaceCampaignScopeCoordinator.cs`, `WorkspacePlayCoordinator.cs`, `WorkspaceAdminCoordinator.cs`, `WorkspaceLiveStateController.cs`, `WorkspaceFeedbackService.cs`, and `WorkspaceToast.cs`: session/bootstrap, campaign scope, play/log, admin, live update, and toast concerns used by `Workspace`
|
||||
- `RpgRoller/Components/Pages/HomeControls/`: workspace and auth child components, forms, header, panels, and modal controls
|
||||
- `RpgRoller/Components/RpgRollerApiClient.cs`: browser API client for write actions
|
||||
- `RpgRoller/Components/WorkspaceQueryService.cs`: server-side read model access for workspace data
|
||||
- `RpgRoller/wwwroot/js/rpgroller-api.js`: browser interop for session storage, SSE wiring, and DOM helpers
|
||||
- `RpgRoller/wwwroot/styles.css`: app styling and responsive layout
|
||||
|
||||
Backend state persistence:
|
||||
Current repo note:
|
||||
|
||||
- EF Core with SQLite (`Microsoft.EntityFrameworkCore.Sqlite`)
|
||||
- Development DB: `RpgRoller/App_Data/rpgroller.development.db`
|
||||
- Default DB: `RpgRoller/App_Data/rpgroller.db`
|
||||
- Database schema is created/upgraded automatically on startup via EF Core migrations (`Database.Migrate`)
|
||||
- Runtime state is loaded once at startup into memory and written back to SQLite on successful state changes
|
||||
- `TASKS.md` records the completed decomposition work and the final execution notes for this refactor.
|
||||
- This README describes the code as it exists today. It does not treat blueprint items in `TASKS.md` as finished unless they are already present in the repo.
|
||||
|
||||
Gameplay capabilities now include:
|
||||
## Runtime and Persistence
|
||||
|
||||
- Instant skill filtering in the character panel (filters live while typing and hides non-matching skills/groups)
|
||||
- Skill groups per character with skill prototypes (create/edit/delete groups, assign/reassign skills, and prefill new skill forms from group defaults)
|
||||
- Skill and skill-group deletion flows
|
||||
- GM-driven character owner transfer within campaign management flows
|
||||
- Character owner selection in edit modal backed by existing-username dropdown data
|
||||
- Role-aware authorization with admin role support (including admin user/role management)
|
||||
- Admin workspace tools include direct download of the live SQLite database file
|
||||
- Campaign deletion by campaign owner or admin (unlinks characters from the campaign and clears campaign log entries)
|
||||
- User deletion by admin also deletes campaigns owned by that user and unlinks all characters from those deleted campaigns
|
||||
- Play screen visibility is owner-scoped: only owned characters are listed, and private log entries are visible only to the roller
|
||||
- Campaign management owner labels use account display names (no GUID fallback rendering)
|
||||
- Character edit flow supports unlinking from campaigns (owner/GM/admin) and assigning to any existing campaign via expanded campaign options
|
||||
- Campaign management supports character deletion by character owner or admin
|
||||
- Shared top header control across all authenticated workspace screens (play, campaign management, admin)
|
||||
- Admin user management is integrated into workspace screen toggles (`Play`, `Campaign Management`, `Admin`)
|
||||
- Persistence uses EF Core with SQLite (`Microsoft.EntityFrameworkCore.Sqlite`).
|
||||
- The default database file is `RpgRoller/App_Data/rpgroller.db`.
|
||||
- `ConnectionStrings__RpgRoller` overrides the SQLite path for local runs, tests, or temporary environments.
|
||||
- Startup applies pending EF Core migrations through `Database.Migrate()`.
|
||||
- The app loads runtime state into memory during startup and persists successful state changes back to SQLite.
|
||||
- `RpgRoller/App_Data/rpgroller.development.db` is a checked-in migration coverage fixture used by hosting tests that copy it to a temporary file before validation.
|
||||
|
||||
## Prerequisites
|
||||
## Product Capabilities
|
||||
|
||||
- .NET SDK 10.0+
|
||||
- PowerShell 7+
|
||||
- Run `dotnet tool restore` once to enable the repo-local `dotnet-ef` command.
|
||||
- Supported campaign rulesets: D6 System, D&D 5e, and Rolemaster
|
||||
- Account registration, login, session-based auth, and role-aware authorization
|
||||
- Admin tools for user listing, role updates, account deletion, and direct SQLite database download
|
||||
- Campaign creation, roster reads, participant-scoped visibility, and owner/admin deletion
|
||||
- Character creation, activation, owner transfer, campaign reassignment or unlinking, and owner/admin deletion
|
||||
- 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
|
||||
- Campaign log paging, lazy-loaded roll detail, compact summaries, and live state refresh through SSE
|
||||
- Custom roll submission from the play screen without creating a persisted skill
|
||||
- Instant skill filtering in the character panel
|
||||
- Campaign management owner labels based on display names
|
||||
|
||||
Rolemaster support:
|
||||
|
||||
- Standard expressions such as `d10`, `15d10`, `2d10+48`, and `d100-15`
|
||||
- Open-ended percentile expressions such as `d100!+85`
|
||||
- Conditional `FumbleRange` handling for open-ended percentile skills and skill-group defaults
|
||||
- Persisted and validated automatic retry toggle for open-ended percentile skills; only eligible Rolemaster skills can enable it
|
||||
- Rolemaster skill rolls open a modal prompt before rolling so the player can apply a one-shot situational modifier; the prompt autofocuses, supports Enter and Escape, and closes when clicking outside it
|
||||
- One-shot situational modifiers are transient Rolemaster-only roll inputs; the temporary modifier is applied to both the first attempt and any automatic retry attempt
|
||||
- Automatic retry windows for eligible open-ended skills: results `76-90` retry once with `+5`, and results `91-110` retry once with `+10`
|
||||
- Open-ended high chaining and low-end subtraction with ordered die metadata in roll detail
|
||||
- Compact log badges and summaries for open-ended, retry, and fumble-related events, including `Retry +5` and `Retry +10`
|
||||
|
||||
## Local Development
|
||||
|
||||
1. Run the local CI parity script:
|
||||
Prerequisites:
|
||||
|
||||
- .NET SDK 10.0+
|
||||
- PowerShell 7+
|
||||
- Node.js 22+
|
||||
|
||||
Initial setup:
|
||||
|
||||
```powershell
|
||||
pwsh ./scripts/ci-local.ps1
|
||||
dotnet tool restore
|
||||
npm ci
|
||||
npm exec playwright install chromium
|
||||
```
|
||||
2. Start the backend:
|
||||
|
||||
Run locally:
|
||||
|
||||
1. Start the app:
|
||||
```powershell
|
||||
dotnet run --project RpgRoller/RpgRoller.csproj
|
||||
```
|
||||
3. Open `http://localhost:5000` (or the port shown in the console).
|
||||
2. Open `http://localhost:5000` or the URL printed in the console.
|
||||
|
||||
VS Code F5 debug profiles are available in `.vscode/launch.json`:
|
||||
Playwright helpers:
|
||||
|
||||
- Run the checked-in smoke suite against an isolated temporary SQLite database:
|
||||
```powershell
|
||||
pwsh ./scripts/run-playwright.ps1
|
||||
```
|
||||
- Run Playwright directly when the app is already running:
|
||||
```powershell
|
||||
npm run e2e
|
||||
```
|
||||
|
||||
VS Code launch profiles in `.vscode/launch.json`:
|
||||
|
||||
- `RpgRoller: Server`
|
||||
- `RpgRoller: Server + Edge (F5)`
|
||||
- `RpgRoller: Server + Firefox (F5)`
|
||||
|
||||
To use a custom SQLite database path, set `ConnectionStrings__RpgRoller`.
|
||||
To run under a subfolder (for example `/rpgroller`), set `PathBase` (for example `PathBase=/rpgroller`).
|
||||
Environment overrides:
|
||||
|
||||
For migration authoring, use the local tool command form:
|
||||
- Set `ConnectionStrings__RpgRoller` to point at a custom SQLite database.
|
||||
- Set `PathBase` to host the app under a sub-path such as `/rpgroller`.
|
||||
|
||||
Migration authoring:
|
||||
|
||||
```powershell
|
||||
dotnet dotnet-ef migrations add <MigrationName> --project RpgRoller/RpgRoller.csproj --startup-project RpgRoller/RpgRoller.csproj
|
||||
```
|
||||
|
||||
SQLite migration rule:
|
||||
|
||||
- Keep table-rebuild operations separate from unrelated schema or data changes so EF Core does not emit non-transactional migration warnings.
|
||||
|
||||
## Frontend Runtime
|
||||
|
||||
- Runtime frontend is Blazor Server with interactive components.
|
||||
- Browser interop is in `RpgRoller/wwwroot/js/rpgroller-api.js`.
|
||||
- Workspace reads are resolved server-side through scoped query services; browser interop remains for browser-only concerns such as session storage, SSE wiring, and DOM helpers.
|
||||
- Live workspace refreshes now compare separate roster, per-character sheet, and log versions so unrelated state changes do not force a full roster + sheet + log reload.
|
||||
- Workspace campaign data is loaded in bounded slices: visible campaign summaries, a selected campaign roster, a selected character sheet, and a 25-row incremental log window backed by `/api/campaigns/{campaignId}/log/page`.
|
||||
- Campaign log rows now ship compact summary data first and lazy-load dice + breakdown detail through `/api/rolls/{rollId}` only when a row is expanded.
|
||||
- Hot API contracts share a source-generated `System.Text.Json` context, and HTTP JSON responses are gzip-compressed when the client advertises support.
|
||||
- OpenAPI contract source remains at `openapi/RpgRoller.json`.
|
||||
- The UI runs as Blazor Server with interactive components.
|
||||
- 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.
|
||||
- Live workspace refresh compares separate roster, per-character sheet, and log versions so unrelated changes do not trigger full reloads.
|
||||
- Campaign log data is loaded in bounded slices: campaign summaries, one selected roster, one selected character sheet, and a 25-row incremental log window from `/api/campaigns/{campaignId}/log/page`.
|
||||
- Log rows return compact summary data first and lazy-load full detail from `/api/rolls/{rollId}` when expanded.
|
||||
- Newly appended local rolls auto-expand in the play workspace and reuse the roll response as the initial detail payload.
|
||||
- Custom roll submission uses the selected character context; D6 uses baseline wild-die/fumble behavior, while D&D 5e and Rolemaster use the submitted expression directly.
|
||||
- API JSON contracts use the source-generated `RpgRollerJsonSerializerContext`.
|
||||
- HTTP JSON responses are gzip-compressed when the client advertises support.
|
||||
- The OpenAPI contract source lives at `openapi/RpgRoller.json`.
|
||||
|
||||
## Test and Coverage
|
||||
|
||||
- Tests:
|
||||
- Test command:
|
||||
```powershell
|
||||
dotnet test RpgRoller.Tests/RpgRoller.Tests.csproj --collect:"XPlat Code Coverage" --settings RpgRoller.Tests/coverlet.runsettings
|
||||
```
|
||||
- Regression tests enforce payload budgets for the hottest contracts: character sheet reads, initial log page loads, incremental log updates, and roll mutation responses.
|
||||
- Coverage gate:
|
||||
```powershell
|
||||
pwsh ./scripts/check-coverage.ps1 -MinLineRate 0.90 -MinBranchRate 0.70
|
||||
```
|
||||
- Coverage collector scope:
|
||||
- `RpgRoller.Tests/coverlet.runsettings` now measures the full backend assembly (`RpgRoller`), not only service namespace files.
|
||||
- Local parity script:
|
||||
```powershell
|
||||
pwsh ./scripts/ci-local.ps1
|
||||
```
|
||||
- `scripts/ci-local.ps1` writes coverage collector output to a unique temporary results directory outside the repo, reads coverage from there, removes that directory at the end of the run, and sweeps stray `coverage.cobertura.xml` files from `RpgRoller.Tests/TestResults`.
|
||||
- Regression tests enforce payload budgets for character sheet reads, initial and incremental campaign log loads, roll mutation responses, and lazy-loaded Rolemaster roll detail payloads.
|
||||
- `RpgRoller.Tests/coverlet.runsettings` measures the full `RpgRoller` backend assembly.
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
namespace RpgRoller.Tests;
|
||||
|
||||
public sealed class AuthApiTests : ApiTestBase
|
||||
public sealed class AuthApiTests(WebApplicationFactory<Program> factory) : ApiTestBase(factory)
|
||||
{
|
||||
public AuthApiTests(WebApplicationFactory<Program> factory) : base(factory)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RegisterLoginAndMeFlow_WorksWithDuplicateUsernameGuard()
|
||||
{
|
||||
|
||||
@@ -2,12 +2,8 @@ using System.Text;
|
||||
|
||||
namespace RpgRoller.Tests;
|
||||
|
||||
public sealed class CampaignApiTests : ApiTestBase
|
||||
public sealed class CampaignApiTests(WebApplicationFactory<Program> factory) : ApiTestBase(factory)
|
||||
{
|
||||
public CampaignApiTests(WebApplicationFactory<Program> factory) : base(factory)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CampaignCharacterAndSkillFlow_EnforcesRulesetValidation()
|
||||
{
|
||||
@@ -65,6 +61,56 @@ public sealed class CampaignApiTests : ApiTestBase
|
||||
Assert.Equal(otherCampaign.Id, updatedCharacter.CampaignId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CampaignCreation_AcceptsRolemasterRuleset()
|
||||
{
|
||||
using var factory = CreateFactory(2, 2, 2);
|
||||
using var gmClient = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
|
||||
await RegisterAsync(gmClient, "gm-rm-api", "Password123", "Game Master");
|
||||
await LoginAsync(gmClient, "gm-rm-api", "Password123");
|
||||
|
||||
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns", new("Shadow World", "rolemaster"));
|
||||
|
||||
Assert.Equal("rolemaster", campaign.RulesetId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RolemasterSkillDefinitions_RoundTripRetryAndFumbleOptionsThroughApi()
|
||||
{
|
||||
using var factory = CreateFactory(88, 42, 17);
|
||||
using var gmClient = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
|
||||
await RegisterAsync(gmClient, "gm-rm-skill", "Password123", "Game Master");
|
||||
await LoginAsync(gmClient, "gm-rm-skill", "Password123");
|
||||
|
||||
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns", new("Shadow World", "rolemaster"));
|
||||
var character = await PostAsync<CreateCharacterRequest, CharacterSummary>(gmClient, "/api/characters", new("Kalen", campaign.Id));
|
||||
|
||||
var missingFumbleRange = await gmClient.PostAsJsonAsync($"/api/characters/{character.Id}/skills", new CreateSkillRequest("Bad Open Ended", "d100!+35", 0, false));
|
||||
Assert.Equal(HttpStatusCode.BadRequest, missingFumbleRange.StatusCode);
|
||||
|
||||
var group = await PostAsync<CreateSkillGroupRequest, SkillGroupSummary>(gmClient, $"/api/characters/{character.Id}/skill-groups", new("Perception", "d100!+15", 0, false, 5));
|
||||
Assert.Equal(5, group.FumbleRange);
|
||||
|
||||
var invalidRetry = await gmClient.PostAsJsonAsync($"/api/characters/{character.Id}/skills", new CreateSkillRequest("Bad Retry", "d100+35", 0, false, group.Id, null, true));
|
||||
Assert.Equal(HttpStatusCode.BadRequest, invalidRetry.StatusCode);
|
||||
|
||||
var skill = await PostAsync<CreateSkillRequest, SkillSummary>(gmClient, $"/api/characters/{character.Id}/skills", new("Awareness", "d100!+35", 0, false, group.Id, 3, true));
|
||||
Assert.Equal(3, skill.FumbleRange);
|
||||
Assert.True(skill.RolemasterAutoRetry);
|
||||
|
||||
var updatedSkill = await PutAsync<UpdateSkillRequest, SkillSummary>(gmClient, $"/api/skills/{skill.Id}", new("Awareness", "d100!+45", 0, false, group.Id, 4, true));
|
||||
Assert.Equal(4, updatedSkill.FumbleRange);
|
||||
Assert.True(updatedSkill.RolemasterAutoRetry);
|
||||
|
||||
var sheet = await GetAsync<CharacterSheet>(gmClient, $"/api/characters/{character.Id}/sheet");
|
||||
Assert.Equal(5, Assert.Single(sheet.SkillGroups).FumbleRange);
|
||||
var sheetSkill = Assert.Single(sheet.Skills);
|
||||
Assert.Equal(4, sheetSkill.FumbleRange);
|
||||
Assert.True(sheetSkill.RolemasterAutoRetry);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SkillGroupsAndOwnerTransfer_WorkThroughApi()
|
||||
{
|
||||
@@ -95,7 +141,7 @@ public sealed class CampaignApiTests : ApiTestBase
|
||||
var groupedSkill = await PostAsync<CreateSkillRequest, SkillSummary>(ownerClient, $"/api/characters/{character.Id}/skills", new("Strike", "2D+1", 1, true, renamedGroup.Id));
|
||||
Assert.Equal(renamedGroup.Id, groupedSkill.SkillGroupId);
|
||||
|
||||
var ungroupedSkill = await PutAsync<UpdateSkillRequest, SkillSummary>(ownerClient, $"/api/skills/{groupedSkill.Id}", new("Strike", "2D+1", 1, true, null));
|
||||
var ungroupedSkill = await PutAsync<UpdateSkillRequest, SkillSummary>(ownerClient, $"/api/skills/{groupedSkill.Id}", new("Strike", "2D+1", 1, true));
|
||||
Assert.Null(ungroupedSkill.SkillGroupId);
|
||||
|
||||
var groupedAgainSkill = await PutAsync<UpdateSkillRequest, SkillSummary>(ownerClient, $"/api/skills/{groupedSkill.Id}", new("Strike", "2D+1", 1, true, renamedGroup.Id));
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
namespace RpgRoller.Tests;
|
||||
|
||||
public sealed class FrontendHostTests : ApiTestBase
|
||||
public sealed class FrontendHostTests(WebApplicationFactory<Program> factory) : ApiTestBase(factory)
|
||||
{
|
||||
public FrontendHostTests(WebApplicationFactory<Program> factory) : base(factory)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RootPath_ServesBlazorFrontendShell()
|
||||
{
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
namespace RpgRoller.Tests;
|
||||
|
||||
public sealed class ResponseCompressionApiTests : ApiTestBase
|
||||
public sealed class ResponseCompressionApiTests(WebApplicationFactory<Program> factory) : ApiTestBase(factory)
|
||||
{
|
||||
public ResponseCompressionApiTests(WebApplicationFactory<Program> factory) : base(factory)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AuthenticatedJsonResponses_EnableGzipCompression()
|
||||
{
|
||||
|
||||
185
RpgRoller.Tests/Api/RolemasterApiTests.cs
Normal file
185
RpgRoller.Tests/Api/RolemasterApiTests.cs
Normal file
@@ -0,0 +1,185 @@
|
||||
namespace RpgRoller.Tests;
|
||||
|
||||
public sealed class RolemasterApiTests(WebApplicationFactory<Program> factory) : ApiTestBase(factory)
|
||||
{
|
||||
[Fact]
|
||||
public async Task RolemasterRollEndpoints_ExecuteGenericRolemasterExpressions()
|
||||
{
|
||||
using var factory = CreateFactory(8, 6, 74);
|
||||
using var client = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
|
||||
await RegisterAsync(client, "rolemaster-api", "Password123", "Rolemaster Api");
|
||||
await LoginAsync(client, "rolemaster-api", "Password123");
|
||||
|
||||
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(client, "/api/campaigns", new("Rolemaster", "rolemaster"));
|
||||
var character = await PostAsync<CreateCharacterRequest, CharacterSummary>(client, "/api/characters", new("Hero", campaign.Id));
|
||||
var initiative = await PostAsync<CreateSkillRequest, SkillSummary>(client, $"/api/characters/{character.Id}/skills", new("Healing", "2d10+48", 0, false));
|
||||
var perception = await PostAsync<CreateSkillRequest, SkillSummary>(client, $"/api/characters/{character.Id}/skills", new("Perception", "d100-15", 0, false));
|
||||
|
||||
var initiativeRoll = await PostAsync<RollSkillRequest, RollResult>(client, $"/api/skills/{initiative.Id}/roll", new("public"));
|
||||
var percentileRoll = await PostAsync<RollSkillRequest, RollResult>(client, $"/api/skills/{perception.Id}/roll", new("public"));
|
||||
var logPage = await GetAsync<CampaignLogPage>(client, $"/api/campaigns/{campaign.Id}/log/page?limit=10");
|
||||
|
||||
Assert.Equal(62, initiativeRoll.Result);
|
||||
Assert.Equal("8+6+48=62", initiativeRoll.Breakdown);
|
||||
Assert.All(initiativeRoll.Dice, die => Assert.Equal(RollDieKinds.RolemasterStandard, die.Kind));
|
||||
|
||||
Assert.Equal(59, percentileRoll.Result);
|
||||
Assert.Equal("74-15=59", percentileRoll.Breakdown);
|
||||
Assert.Equal(RollDieKinds.RolemasterStandard, Assert.Single(percentileRoll.Dice).Kind);
|
||||
|
||||
Assert.Equal(2, logPage.Entries.Length);
|
||||
Assert.Equal("8 + 6 | rolemaster", logPage.Entries[0].SummaryText);
|
||||
Assert.Null(logPage.Entries[0].EventBadges);
|
||||
Assert.Equal("74 | rolemaster", logPage.Entries[1].SummaryText);
|
||||
Assert.Null(logPage.Entries[1].EventBadges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RolemasterOpenEndedRolls_AppearInLogPageAndDetail()
|
||||
{
|
||||
using var factory = CreateFactory(5, 97, 100, 12);
|
||||
using var client = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
|
||||
await RegisterAsync(client, "rolemaster-open-api", "Password123", "Rolemaster Open Api");
|
||||
await LoginAsync(client, "rolemaster-open-api", "Password123");
|
||||
|
||||
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(client, "/api/campaigns", new("Rolemaster Open", "rolemaster"));
|
||||
var character = await PostAsync<CreateCharacterRequest, CharacterSummary>(client, "/api/characters", new("Hero", campaign.Id));
|
||||
var skill = await PostAsync<CreateSkillRequest, SkillSummary>(client, $"/api/characters/{character.Id}/skills", new("Awareness", "d100!+85", 0, false, null, 5));
|
||||
|
||||
var roll = await PostAsync<RollSkillRequest, RollResult>(client, $"/api/skills/{skill.Id}/roll", new("public"));
|
||||
var logPage = await GetAsync<CampaignLogPage>(client, $"/api/campaigns/{campaign.Id}/log/page?limit=5");
|
||||
var detail = await GetAsync<CampaignRollDetail>(client, $"/api/rolls/{roll.RollId}");
|
||||
|
||||
Assert.Equal(-124, roll.Result);
|
||||
Assert.Equal("(05) -97 -100 -12 +85 = -124", roll.Breakdown);
|
||||
var logEntry = Assert.Single(logPage.Entries);
|
||||
Assert.Equal("(05) -97 -100 -12 | open-ended low", logEntry.SummaryText);
|
||||
var eventBadges = Assert.IsType<string[]>(logEntry.EventBadges);
|
||||
Assert.Collection(eventBadges, badge => Assert.Equal("rf", badge), badge => Assert.Equal("r100", badge));
|
||||
Assert.Equal(roll.Breakdown, detail.Breakdown);
|
||||
Assert.Collection(detail.Dice, die =>
|
||||
{
|
||||
Assert.Equal(RollDieKinds.RolemasterOpenEndedInitial, die.Kind);
|
||||
Assert.Equal(1, die.Sequence);
|
||||
Assert.Null(die.SignedContribution);
|
||||
}, die =>
|
||||
{
|
||||
Assert.Equal(RollDieKinds.RolemasterOpenEndedLowSubtract, die.Kind);
|
||||
Assert.Equal(2, die.Sequence);
|
||||
Assert.Equal(-97, die.SignedContribution);
|
||||
}, die =>
|
||||
{
|
||||
Assert.Equal(RollDieKinds.RolemasterOpenEndedLowSubtract, die.Kind);
|
||||
Assert.Equal(3, die.Sequence);
|
||||
Assert.Equal(-100, die.SignedContribution);
|
||||
}, die =>
|
||||
{
|
||||
Assert.Equal(RollDieKinds.RolemasterOpenEndedLowSubtract, die.Kind);
|
||||
Assert.Equal(4, die.Sequence);
|
||||
Assert.Equal(-12, die.SignedContribution);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RolemasterAutoRetryRolls_AppearInLogPageAndDetail()
|
||||
{
|
||||
using var factory = CreateFactory(66, 42, 90, 32, 65);
|
||||
using var client = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
|
||||
await RegisterAsync(client, "rolemaster-retry-api", "Password123", "Rolemaster Retry Api");
|
||||
await LoginAsync(client, "rolemaster-retry-api", "Password123");
|
||||
|
||||
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(client, "/api/campaigns", new("Rolemaster Retry", "rolemaster"));
|
||||
var character = await PostAsync<CreateCharacterRequest, CharacterSummary>(client, "/api/characters", new("Hero", campaign.Id));
|
||||
var retryFiveSkill = await PostAsync<CreateSkillRequest, SkillSummary>(client, $"/api/characters/{character.Id}/skills", new("Awareness +5", "d100!+10", 0, false, null, 5, true));
|
||||
var retryTenSkill = await PostAsync<CreateSkillRequest, SkillSummary>(client, $"/api/characters/{character.Id}/skills", new("Awareness +10", "d100!+1", 0, false, null, 5, true));
|
||||
var disabledSkill = await PostAsync<CreateSkillRequest, SkillSummary>(client, $"/api/characters/{character.Id}/skills", new("Awareness Off", "d100!+10", 0, false, null, 5));
|
||||
|
||||
var retryFiveRoll = await PostAsync<RollSkillRequest, RollResult>(client, $"/api/skills/{retryFiveSkill.Id}/roll", new("public"));
|
||||
var retryTenRoll = await PostAsync<RollSkillRequest, RollResult>(client, $"/api/skills/{retryTenSkill.Id}/roll", new("public"));
|
||||
var disabledRoll = await PostAsync<RollSkillRequest, RollResult>(client, $"/api/skills/{disabledSkill.Id}/roll", new("public"));
|
||||
var logPage = await GetAsync<CampaignLogPage>(client, $"/api/campaigns/{campaign.Id}/log/page?limit=10");
|
||||
var detail = await GetAsync<CampaignRollDetail>(client, $"/api/rolls/{retryFiveRoll.RollId}");
|
||||
|
||||
Assert.Equal(57, retryFiveRoll.Result);
|
||||
Assert.Equal("66+10=76; retry(+5): 42+10=52; final=57", retryFiveRoll.Breakdown);
|
||||
Assert.Collection(retryFiveRoll.Dice, die =>
|
||||
{
|
||||
Assert.Equal(1, die.Attempt);
|
||||
Assert.Equal(1, die.Sequence);
|
||||
}, die =>
|
||||
{
|
||||
Assert.Equal(2, die.Attempt);
|
||||
Assert.Equal(1, die.Sequence);
|
||||
});
|
||||
|
||||
Assert.Equal(43, retryTenRoll.Result);
|
||||
Assert.Equal("90+1=91; retry(+10): 32+1=33; final=43", retryTenRoll.Breakdown);
|
||||
|
||||
Assert.Equal(75, disabledRoll.Result);
|
||||
Assert.Equal("65+10=75", disabledRoll.Breakdown);
|
||||
Assert.All(disabledRoll.Dice, die => Assert.Null(die.Attempt));
|
||||
|
||||
Assert.Equal(3, logPage.Entries.Length);
|
||||
Assert.Equal("66 | open-ended | retry +5", logPage.Entries[0].SummaryText);
|
||||
Assert.Equal(["r66", "rs5"], Assert.IsType<string[]>(logPage.Entries[0].EventBadges));
|
||||
Assert.Equal("90 | open-ended | retry +10", logPage.Entries[1].SummaryText);
|
||||
Assert.Equal(["rs10"], Assert.IsType<string[]>(logPage.Entries[1].EventBadges));
|
||||
Assert.Equal("65 | open-ended", logPage.Entries[2].SummaryText);
|
||||
Assert.Null(logPage.Entries[2].EventBadges);
|
||||
|
||||
Assert.Equal(retryFiveRoll.Breakdown, detail.Breakdown);
|
||||
Assert.Collection(detail.Dice, die =>
|
||||
{
|
||||
Assert.Equal(1, die.Attempt);
|
||||
Assert.Equal(RollDieKinds.RolemasterOpenEndedInitial, die.Kind);
|
||||
}, die =>
|
||||
{
|
||||
Assert.Equal(2, die.Attempt);
|
||||
Assert.Equal(RollDieKinds.RolemasterOpenEndedInitial, die.Kind);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RolemasterSkillRoll_AcceptsSituationalModifier_AndAppliesItToRetryMath()
|
||||
{
|
||||
using var factory = CreateFactory(8, 42);
|
||||
using var client = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
|
||||
await RegisterAsync(client, "rolemaster-situational-api", "Password123", "Rolemaster Situational Api");
|
||||
await LoginAsync(client, "rolemaster-situational-api", "Password123");
|
||||
|
||||
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(client, "/api/campaigns", new("Rolemaster Situational", "rolemaster"));
|
||||
var character = await PostAsync<CreateCharacterRequest, CharacterSummary>(client, "/api/characters", new("Hero", campaign.Id));
|
||||
var skill = await PostAsync<CreateSkillRequest, SkillSummary>(client, $"/api/characters/{character.Id}/skills", new("Observation", "d100!+50", 0, false, null, 5, true));
|
||||
|
||||
var roll = await PostAsync<RollSkillRequest, RollResult>(client, $"/api/skills/{skill.Id}/roll", new("public", 20));
|
||||
|
||||
Assert.Equal(117, roll.Result);
|
||||
Assert.Equal("8+50+20=78; retry(+5): 42+50+20=112; final=117", roll.Breakdown);
|
||||
Assert.Collection(roll.Dice, die => Assert.Equal(1, die.Attempt), die => Assert.Equal(2, die.Attempt));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SkillRoll_RejectsSituationalModifier_ForNonRolemasterCampaigns()
|
||||
{
|
||||
using var factory = CreateFactory(12);
|
||||
using var client = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
|
||||
await RegisterAsync(client, "non-rolemaster-situational-api", "Password123", "Non Rolemaster Situational Api");
|
||||
await LoginAsync(client, "non-rolemaster-situational-api", "Password123");
|
||||
|
||||
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(client, "/api/campaigns", new("Dnd Situational", "dnd5e"));
|
||||
var character = await PostAsync<CreateCharacterRequest, CharacterSummary>(client, "/api/characters", new("Hero", campaign.Id));
|
||||
var skill = await PostAsync<CreateSkillRequest, SkillSummary>(client, $"/api/characters/{character.Id}/skills", new("Attack", "1d20+5", 0, false));
|
||||
|
||||
var response = await client.PostAsJsonAsync($"/api/skills/{skill.Id}/roll", new RollSkillRequest("public", 20));
|
||||
var error = await response.Content.ReadFromJsonAsync<ApiError>();
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
Assert.NotNull(error);
|
||||
Assert.Equal("invalid_situational_modifier", error.Code);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,7 @@
|
||||
namespace RpgRoller.Tests;
|
||||
|
||||
public sealed class RollVisibilityApiTests : ApiTestBase
|
||||
public sealed class RollVisibilityApiTests(WebApplicationFactory<Program> factory) : ApiTestBase(factory)
|
||||
{
|
||||
public RollVisibilityApiTests(WebApplicationFactory<Program> factory) : base(factory)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RollVisibilityAndAuthorization_AreEnforced()
|
||||
{
|
||||
@@ -75,6 +71,19 @@ public sealed class RollVisibilityApiTests : ApiTestBase
|
||||
var invalidVisibility = await playerClient.PostAsJsonAsync($"/api/skills/{skill.Id}/roll", new RollSkillRequest("hidden"));
|
||||
Assert.Equal(HttpStatusCode.BadRequest, invalidVisibility.StatusCode);
|
||||
|
||||
var customRoll = await PostAsync<CustomRollRequest, RollResult>(playerClient, $"/api/characters/{playerCharacter.Id}/custom-rolls", new("1D+2", "public"));
|
||||
Assert.Equal(Guid.Empty, customRoll.SkillId);
|
||||
|
||||
var customRollLogPage = await GetAsync<CampaignLogPage>(observerClient, $"/api/campaigns/{campaign.Id}/log/page");
|
||||
Assert.Equal(2, customRollLogPage.Entries.Length);
|
||||
Assert.Equal("Custom roll", customRollLogPage.Entries[1].SkillName);
|
||||
|
||||
var invalidCustomRollResponse = await playerClient.PostAsJsonAsync($"/api/characters/{playerCharacter.Id}/custom-rolls", new CustomRollRequest("bad", "public"));
|
||||
Assert.Equal(HttpStatusCode.BadRequest, invalidCustomRollResponse.StatusCode);
|
||||
var invalidCustomRoll = await invalidCustomRollResponse.Content.ReadFromJsonAsync<ApiError>();
|
||||
Assert.NotNull(invalidCustomRoll);
|
||||
Assert.Equal("invalid_expression", invalidCustomRoll.Code);
|
||||
|
||||
using var anonymousClient = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
var unauthorizedCampaignCreate = await anonymousClient.PostAsJsonAsync("/api/campaigns", new CreateCampaignRequest("Nope", "d6"));
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, unauthorizedCampaignCreate.StatusCode);
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
namespace RpgRoller.Tests;
|
||||
|
||||
public sealed class SystemApiTests : ApiTestBase
|
||||
public sealed class SystemApiTests(WebApplicationFactory<Program> factory) : ApiTestBase(factory)
|
||||
{
|
||||
public SystemApiTests(WebApplicationFactory<Program> factory) : base(factory)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RulesetAndSseEndpoints_ReturnExpectedResponses()
|
||||
{
|
||||
@@ -13,7 +9,9 @@ public sealed class SystemApiTests : ApiTestBase
|
||||
using var client = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
|
||||
var rulesets = await GetAsync<IReadOnlyList<RulesetDefinition>>(client, "/api/rulesets");
|
||||
Assert.Equal(2, rulesets.Count);
|
||||
Assert.Equal(3, rulesets.Count);
|
||||
var rolemaster = Assert.Single(rulesets, ruleset => ruleset.Id == "rolemaster");
|
||||
Assert.Equal("Rolemaster", rolemaster.Name);
|
||||
|
||||
await RegisterAsync(client, "sse", "Password123", "Sse User");
|
||||
await LoginAsync(client, "sse", "Password123");
|
||||
|
||||
@@ -11,11 +11,11 @@ public sealed class BackendCoverageTests
|
||||
var extensionsType = typeof(Program).Assembly.GetType("RpgRoller.Api.SessionTokenHttpContextExtensions");
|
||||
Assert.NotNull(extensionsType);
|
||||
|
||||
var method = extensionsType!.GetMethod("GetRequiredSessionToken", BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic);
|
||||
var method = extensionsType.GetMethod("GetRequiredSessionToken", BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic);
|
||||
Assert.NotNull(method);
|
||||
|
||||
var context = new DefaultHttpContext();
|
||||
var exception = Assert.Throws<TargetInvocationException>(() => method!.Invoke(null, [context]));
|
||||
var exception = Assert.Throws<TargetInvocationException>(() => method.Invoke(null, [context]));
|
||||
Assert.IsType<InvalidOperationException>(exception.InnerException);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,12 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using RpgRoller.Data;
|
||||
using RpgRoller.Hosting;
|
||||
|
||||
@@ -125,6 +130,17 @@ public sealed class HostingCoverageTests
|
||||
|
||||
Assert.Contains("WildDice", columns);
|
||||
Assert.Contains("AllowFumble", columns);
|
||||
Assert.Contains("FumbleRange", columns);
|
||||
Assert.Contains("RolemasterAutoRetry", columns);
|
||||
|
||||
using var skillGroupsTableInfoCommand = verifyConnection.CreateCommand();
|
||||
skillGroupsTableInfoCommand.CommandText = "PRAGMA table_info('SkillGroups');";
|
||||
using var skillGroupsTableInfoReader = skillGroupsTableInfoCommand.ExecuteReader();
|
||||
var skillGroupColumns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
while (skillGroupsTableInfoReader.Read())
|
||||
skillGroupColumns.Add(skillGroupsTableInfoReader.GetString(1));
|
||||
|
||||
Assert.Contains("FumbleRange", skillGroupColumns);
|
||||
|
||||
using var rollTableInfoCommand = verifyConnection.CreateCommand();
|
||||
rollTableInfoCommand.CommandText = "PRAGMA table_info('RollLogEntries');";
|
||||
@@ -183,5 +199,296 @@ public sealed class HostingCoverageTests
|
||||
rolesHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260226160859_AddAuthorizationRolesAndCampaignDeletion';";
|
||||
var rolesHistoryCount = Convert.ToInt32(rolesHistoryCommand.ExecuteScalar());
|
||||
Assert.Equal(1, rolesHistoryCount);
|
||||
|
||||
using var authorizationRolesHistoryCommand = verifyConnection.CreateCommand();
|
||||
authorizationRolesHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260226170000_AddAuthorizationRoles';";
|
||||
var authorizationRolesHistoryCount = Convert.ToInt32(authorizationRolesHistoryCommand.ExecuteScalar());
|
||||
Assert.Equal(1, authorizationRolesHistoryCount);
|
||||
|
||||
using var rolemasterHistoryCommand = verifyConnection.CreateCommand();
|
||||
rolemasterHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260402222501_AddRolemasterFumbleRange';";
|
||||
var rolemasterHistoryCount = Convert.ToInt32(rolemasterHistoryCommand.ExecuteScalar());
|
||||
Assert.Equal(1, rolemasterHistoryCount);
|
||||
|
||||
using var retryHistoryCommand = verifyConnection.CreateCommand();
|
||||
retryHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260414204309_AddRolemasterAutoRetry';";
|
||||
var retryHistoryCount = Convert.ToInt32(retryHistoryCommand.ExecuteScalar());
|
||||
Assert.Equal(1, retryHistoryCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuthorizationMigrations_SplitCharactersRebuildFromRolesColumnAddition()
|
||||
{
|
||||
var dbPath = Path.Combine(Path.GetTempPath(), $"rpgroller-migration-script-{Guid.NewGuid():N}.db");
|
||||
var options = new DbContextOptionsBuilder<RpgRollerDbContext>().UseSqlite($"Data Source={dbPath}").Options;
|
||||
|
||||
using var db = new RpgRollerDbContext(options);
|
||||
var migrator = db.GetService<IMigrator>();
|
||||
var charactersScript = migrator.GenerateScript("20260226131003_AddSkillGroupPrototypes", "20260226160859_AddAuthorizationRolesAndCampaignDeletion");
|
||||
var rolesScript = migrator.GenerateScript("20260226160859_AddAuthorizationRolesAndCampaignDeletion", "20260226170000_AddAuthorizationRoles");
|
||||
|
||||
Assert.Contains("""CREATE TABLE "ef_temp_Characters" (""", charactersScript);
|
||||
Assert.DoesNotContain("""ALTER TABLE "Users" ADD "Roles" TEXT NOT NULL DEFAULT 'admin';""", charactersScript);
|
||||
Assert.Contains("""ALTER TABLE "Users" ADD "Roles" TEXT NOT NULL DEFAULT 'admin';""", rolesScript);
|
||||
Assert.DoesNotContain("""CREATE TABLE "ef_temp_Characters" (""", rolesScript);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SqliteSchemaUpgrader_BackfillsSplitAuthorizationRolesMigrationHistory()
|
||||
{
|
||||
var dbPath = Path.Combine(Path.GetTempPath(), $"rpgroller-split-history-{Guid.NewGuid():N}.db");
|
||||
|
||||
using (var connection = new SqliteConnection($"Data Source={dbPath}"))
|
||||
{
|
||||
connection.Open();
|
||||
using var command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
CREATE TABLE "__EFMigrationsHistory" (
|
||||
"MigrationId" TEXT NOT NULL CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY,
|
||||
"ProductVersion" TEXT NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
|
||||
VALUES
|
||||
('20260226084000_InitialSchema', '10.0.2'),
|
||||
('20260226090000_ModelSync', '10.0.2'),
|
||||
('20260226100000_AddRollLogDice', '10.0.2'),
|
||||
('20260226124941_AddSkillGroupsAndCharacterOwnerTransfer', '10.0.2'),
|
||||
('20260226131003_AddSkillGroupPrototypes', '10.0.2'),
|
||||
('20260226160859_AddAuthorizationRolesAndCampaignDeletion', '10.0.2');
|
||||
|
||||
CREATE TABLE "Users" (
|
||||
"Id" TEXT NOT NULL CONSTRAINT "PK_Users" PRIMARY KEY,
|
||||
"Username" TEXT NOT NULL,
|
||||
"UsernameNormalized" TEXT NOT NULL,
|
||||
"PasswordHash" TEXT NOT NULL,
|
||||
"DisplayName" TEXT NOT NULL,
|
||||
"ActiveCharacterId" TEXT NULL,
|
||||
"Roles" TEXT NOT NULL DEFAULT 'admin'
|
||||
);
|
||||
|
||||
CREATE TABLE "Sessions" (
|
||||
"Token" TEXT NOT NULL CONSTRAINT "PK_Sessions" PRIMARY KEY,
|
||||
"UserId" TEXT NOT NULL,
|
||||
"CreatedAtUtc" TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "Campaigns" (
|
||||
"Id" TEXT NOT NULL CONSTRAINT "PK_Campaigns" PRIMARY KEY,
|
||||
"GmUserId" TEXT NOT NULL,
|
||||
"Name" TEXT NOT NULL,
|
||||
"Ruleset" TEXT NOT NULL,
|
||||
"Version" INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "Characters" (
|
||||
"Id" TEXT NOT NULL CONSTRAINT "PK_Characters" PRIMARY KEY,
|
||||
"OwnerUserId" TEXT NOT NULL,
|
||||
"CampaignId" TEXT NULL,
|
||||
"Name" TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX "IX_Characters_OwnerUserId" ON "Characters" ("OwnerUserId");
|
||||
CREATE INDEX "IX_Characters_CampaignId" ON "Characters" ("CampaignId");
|
||||
|
||||
CREATE TABLE "SkillGroups" (
|
||||
"Id" TEXT NOT NULL CONSTRAINT "PK_SkillGroups" PRIMARY KEY,
|
||||
"CharacterId" TEXT NOT NULL,
|
||||
"Name" TEXT NOT NULL,
|
||||
"AllowFumble" INTEGER NOT NULL,
|
||||
"DiceRollDefinition" TEXT NOT NULL,
|
||||
"WildDice" INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX "IX_SkillGroups_CharacterId" ON "SkillGroups" ("CharacterId");
|
||||
|
||||
CREATE TABLE "Skills" (
|
||||
"Id" TEXT NOT NULL CONSTRAINT "PK_Skills" PRIMARY KEY,
|
||||
"CharacterId" TEXT NOT NULL,
|
||||
"Name" TEXT NOT NULL,
|
||||
"DiceRollDefinition" TEXT NOT NULL,
|
||||
"WildDice" INTEGER NOT NULL,
|
||||
"AllowFumble" INTEGER NOT NULL,
|
||||
"SkillGroupId" TEXT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX "IX_Skills_CharacterId" ON "Skills" ("CharacterId");
|
||||
CREATE INDEX "IX_Skills_SkillGroupId" ON "Skills" ("SkillGroupId");
|
||||
|
||||
CREATE TABLE "RollLogEntries" (
|
||||
"Id" TEXT NOT NULL CONSTRAINT "PK_RollLogEntries" PRIMARY KEY,
|
||||
"CampaignId" TEXT NOT NULL,
|
||||
"CharacterId" TEXT NOT NULL,
|
||||
"SkillId" TEXT NOT NULL,
|
||||
"RollerUserId" TEXT NOT NULL,
|
||||
"Visibility" TEXT NOT NULL,
|
||||
"Result" INTEGER NOT NULL,
|
||||
"Breakdown" TEXT NOT NULL,
|
||||
"TimestampUtc" TEXT NOT NULL,
|
||||
"Dice" TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX "IX_RollLogEntries_CampaignId" ON "RollLogEntries" ("CampaignId");
|
||||
CREATE INDEX "IX_RollLogEntries_CharacterId" ON "RollLogEntries" ("CharacterId");
|
||||
CREATE INDEX "IX_RollLogEntries_RollerUserId" ON "RollLogEntries" ("RollerUserId");
|
||||
CREATE INDEX "IX_RollLogEntries_SkillId" ON "RollLogEntries" ("SkillId");
|
||||
""";
|
||||
_ = command.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
var options = new DbContextOptionsBuilder<RpgRollerDbContext>().UseSqlite($"Data Source={dbPath}").Options;
|
||||
using (var db = new RpgRollerDbContext(options))
|
||||
{
|
||||
SqliteSchemaUpgrader.ApplyPendingChanges(db);
|
||||
}
|
||||
|
||||
using var verifyConnection = new SqliteConnection($"Data Source={dbPath}");
|
||||
verifyConnection.Open();
|
||||
|
||||
using var authorizationRolesHistoryCommand = verifyConnection.CreateCommand();
|
||||
authorizationRolesHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260226170000_AddAuthorizationRoles';";
|
||||
var authorizationRolesHistoryCount = Convert.ToInt32(authorizationRolesHistoryCommand.ExecuteScalar());
|
||||
Assert.Equal(1, authorizationRolesHistoryCount);
|
||||
|
||||
using var rolemasterHistoryCommand = verifyConnection.CreateCommand();
|
||||
rolemasterHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260402222501_AddRolemasterFumbleRange';";
|
||||
var rolemasterHistoryCount = Convert.ToInt32(rolemasterHistoryCommand.ExecuteScalar());
|
||||
Assert.Equal(1, rolemasterHistoryCount);
|
||||
|
||||
using var retryHistoryCommand = verifyConnection.CreateCommand();
|
||||
retryHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260414204309_AddRolemasterAutoRetry';";
|
||||
var retryHistoryCount = Convert.ToInt32(retryHistoryCommand.ExecuteScalar());
|
||||
Assert.Equal(1, retryHistoryCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InitializeRpgRollerState_MigratesCopiedDevelopmentDatabaseAndPreservesD6Rolling()
|
||||
{
|
||||
var sourceDbPath = Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "RpgRoller", "App_Data", "rpgroller.development.db");
|
||||
var copiedDbPath = Path.Combine(Path.GetTempPath(), $"rpgroller-dev-copy-{Guid.NewGuid():N}.db");
|
||||
File.Copy(Path.GetFullPath(sourceDbPath), copiedDbPath, true);
|
||||
|
||||
Guid skillId;
|
||||
Guid ownerUserId;
|
||||
Guid characterId;
|
||||
var campaignCountBefore = 0;
|
||||
var skillCountBefore = 0;
|
||||
using (var connection = new SqliteConnection($"Data Source={copiedDbPath}"))
|
||||
{
|
||||
connection.Open();
|
||||
|
||||
using var countsCommand = connection.CreateCommand();
|
||||
countsCommand.CommandText = """
|
||||
SELECT (SELECT COUNT(*) FROM Campaigns),
|
||||
(SELECT COUNT(*) FROM Skills);
|
||||
""";
|
||||
using var countsReader = countsCommand.ExecuteReader();
|
||||
Assert.True(countsReader.Read());
|
||||
campaignCountBefore = countsReader.GetInt32(0);
|
||||
skillCountBefore = countsReader.GetInt32(1);
|
||||
|
||||
using var existingSkillCommand = connection.CreateCommand();
|
||||
existingSkillCommand.CommandText = """
|
||||
SELECT s.Id, c.OwnerUserId, c.Id
|
||||
FROM Skills s
|
||||
INNER JOIN Characters c ON c.Id = s.CharacterId
|
||||
INNER JOIN Campaigns cp ON cp.Id = c.CampaignId
|
||||
WHERE cp.Ruleset = 'D6'
|
||||
ORDER BY s.Name
|
||||
LIMIT 1;
|
||||
""";
|
||||
using var existingSkillReader = existingSkillCommand.ExecuteReader();
|
||||
Assert.True(existingSkillReader.Read());
|
||||
skillId = Guid.Parse(existingSkillReader.GetString(0));
|
||||
ownerUserId = Guid.Parse(existingSkillReader.GetString(1));
|
||||
characterId = Guid.Parse(existingSkillReader.GetString(2));
|
||||
|
||||
using var sessionCommand = connection.CreateCommand();
|
||||
sessionCommand.CommandText = """
|
||||
INSERT INTO Sessions ("Token", "UserId", "CreatedAtUtc")
|
||||
VALUES ($token, $userId, $createdAtUtc);
|
||||
""";
|
||||
var tokenParameter = sessionCommand.CreateParameter();
|
||||
tokenParameter.ParameterName = "$token";
|
||||
tokenParameter.Value = "migration-test-session";
|
||||
sessionCommand.Parameters.Add(tokenParameter);
|
||||
|
||||
var userParameter = sessionCommand.CreateParameter();
|
||||
userParameter.ParameterName = "$userId";
|
||||
userParameter.Value = ownerUserId.ToString();
|
||||
sessionCommand.Parameters.Add(userParameter);
|
||||
|
||||
var createdAtParameter = sessionCommand.CreateParameter();
|
||||
createdAtParameter.ParameterName = "$createdAtUtc";
|
||||
createdAtParameter.Value = DateTimeOffset.UtcNow.ToString("O");
|
||||
sessionCommand.Parameters.Add(createdAtParameter);
|
||||
|
||||
_ = sessionCommand.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
var builder = WebApplication.CreateBuilder(new WebApplicationOptions
|
||||
{
|
||||
ContentRootPath = Path.GetTempPath(),
|
||||
EnvironmentName = Environments.Development
|
||||
});
|
||||
builder.Logging.AddFilter("Microsoft.AspNetCore", LogLevel.Warning);
|
||||
builder.Logging.AddFilter("Microsoft.EntityFrameworkCore", LogLevel.Warning);
|
||||
builder.Logging.AddFilter("Microsoft.Hosting", LogLevel.Warning);
|
||||
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?> { ["ConnectionStrings:RpgRoller"] = $"Data Source={copiedDbPath}" });
|
||||
builder.Services.AddRpgRollerCore(builder.Configuration, builder.Environment);
|
||||
|
||||
using var app = builder.Build();
|
||||
app.InitializeRpgRollerState();
|
||||
|
||||
using var scope = app.Services.CreateScope();
|
||||
var game = scope.ServiceProvider.GetRequiredService<IGameService>();
|
||||
var rollResult = game.RollSkill("migration-test-session", skillId, "public");
|
||||
Assert.True(rollResult.Succeeded);
|
||||
Assert.NotEmpty(ServiceTestSupport.GetValue(rollResult).Dice);
|
||||
|
||||
var migratedSheet = ServiceTestSupport.GetValue(game.GetCharacterSheet("migration-test-session", characterId));
|
||||
Assert.Contains(migratedSheet.Skills, skill => skill.Id == skillId);
|
||||
|
||||
using var verifyConnection = new SqliteConnection($"Data Source={copiedDbPath}");
|
||||
verifyConnection.Open();
|
||||
|
||||
using var countsAfterCommand = verifyConnection.CreateCommand();
|
||||
countsAfterCommand.CommandText = """
|
||||
SELECT (SELECT COUNT(*) FROM Campaigns),
|
||||
(SELECT COUNT(*) FROM Skills);
|
||||
""";
|
||||
using var countsAfterReader = countsAfterCommand.ExecuteReader();
|
||||
Assert.True(countsAfterReader.Read());
|
||||
Assert.Equal(campaignCountBefore, countsAfterReader.GetInt32(0));
|
||||
Assert.Equal(skillCountBefore, countsAfterReader.GetInt32(1));
|
||||
|
||||
using var skillsTableInfoCommand = verifyConnection.CreateCommand();
|
||||
skillsTableInfoCommand.CommandText = "PRAGMA table_info('Skills');";
|
||||
using var skillsTableInfoReader = skillsTableInfoCommand.ExecuteReader();
|
||||
var skillColumns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
while (skillsTableInfoReader.Read())
|
||||
skillColumns.Add(skillsTableInfoReader.GetString(1));
|
||||
|
||||
Assert.Contains("FumbleRange", skillColumns);
|
||||
Assert.Contains("RolemasterAutoRetry", skillColumns);
|
||||
|
||||
using var skillGroupsTableInfoCommand = verifyConnection.CreateCommand();
|
||||
skillGroupsTableInfoCommand.CommandText = "PRAGMA table_info('SkillGroups');";
|
||||
using var skillGroupsTableInfoReader = skillGroupsTableInfoCommand.ExecuteReader();
|
||||
var skillGroupColumns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
while (skillGroupsTableInfoReader.Read())
|
||||
skillGroupColumns.Add(skillGroupsTableInfoReader.GetString(1));
|
||||
|
||||
Assert.Contains("FumbleRange", skillGroupColumns);
|
||||
|
||||
using var authorizationRolesHistoryCommand = verifyConnection.CreateCommand();
|
||||
authorizationRolesHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260226170000_AddAuthorizationRoles';";
|
||||
var authorizationRolesHistoryCount = Convert.ToInt32(authorizationRolesHistoryCommand.ExecuteScalar());
|
||||
Assert.Equal(1, authorizationRolesHistoryCount);
|
||||
|
||||
using var retryHistoryCommand = verifyConnection.CreateCommand();
|
||||
retryHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260414204309_AddRolemasterAutoRetry';";
|
||||
var retryHistoryCount = Convert.ToInt32(retryHistoryCommand.ExecuteScalar());
|
||||
Assert.Equal(1, retryHistoryCount);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
using System.Text.Json;
|
||||
using RpgRoller.Contracts;
|
||||
|
||||
namespace RpgRoller.Tests;
|
||||
|
||||
@@ -87,6 +86,29 @@ public sealed class PayloadBudgetTests
|
||||
AssertPayloadWithinBudget(incrementalPage, 2 * 1024, "incremental log update");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RolemasterCampaignLogInitialPagePayload_StaysWithinBudget()
|
||||
{
|
||||
using var harness = ServiceTestSupport.CreateHarness(CreateRolemasterOpenEndedRolls(90));
|
||||
var service = harness.Service;
|
||||
|
||||
service.Register("gm-rm-log-budget", "Password123", "GM");
|
||||
service.Register("owner-rm-log-budget", "Password123", "Owner");
|
||||
|
||||
var gmSession = ServiceTestSupport.GetValue(service.Login("gm-rm-log-budget", "Password123")).SessionToken;
|
||||
var ownerSession = ServiceTestSupport.GetValue(service.Login("owner-rm-log-budget", "Password123")).SessionToken;
|
||||
|
||||
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Rolemaster Payload Log", "rolemaster"));
|
||||
var character = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Open Hero", campaign.Id));
|
||||
var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, character.Id, "Awareness", "d100!+85", 0, false, null, 5));
|
||||
|
||||
for (var i = 0; i < 25; i++)
|
||||
_ = ServiceTestSupport.GetValue(service.RollSkill(ownerSession, skill.Id, "public"));
|
||||
|
||||
var page = ServiceTestSupport.GetValue(service.GetCampaignLogPage(gmSession, campaign.Id, limit: 25));
|
||||
AssertPayloadWithinBudget(page, 8 * 1024, "initial rolemaster log page");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RollResultPayload_StaysWithinJsInteropBudget()
|
||||
{
|
||||
@@ -107,6 +129,43 @@ public sealed class PayloadBudgetTests
|
||||
AssertPayloadWithinBudget(roll, 16 * 1024, "roll mutation response");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RolemasterRollDetailPayload_StaysWithinBudget_AndRetryMetadataRemainsLazy()
|
||||
{
|
||||
using var harness = ServiceTestSupport.CreateHarness(66, 42);
|
||||
var service = harness.Service;
|
||||
|
||||
service.Register("gm-rm-detail-budget", "Password123", "GM");
|
||||
service.Register("owner-rm-detail-budget", "Password123", "Owner");
|
||||
|
||||
var gmSession = ServiceTestSupport.GetValue(service.Login("gm-rm-detail-budget", "Password123")).SessionToken;
|
||||
var ownerSession = ServiceTestSupport.GetValue(service.Login("owner-rm-detail-budget", "Password123")).SessionToken;
|
||||
|
||||
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Rolemaster Payload Detail", "rolemaster"));
|
||||
var character = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Detail Hero", campaign.Id));
|
||||
var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, character.Id, "Sense", "d100!+10", 0, false, null, 5, true));
|
||||
|
||||
var roll = ServiceTestSupport.GetValue(service.RollSkill(ownerSession, skill.Id, "public"));
|
||||
var logPage = ServiceTestSupport.GetValue(service.GetCampaignLogPage(gmSession, campaign.Id, limit: 5));
|
||||
var detail = ServiceTestSupport.GetValue(service.GetRollDetail(gmSession, roll.RollId));
|
||||
Assert.Equal("66 | open-ended | retry +5", Assert.Single(logPage.Entries).SummaryText);
|
||||
|
||||
AssertPayloadWithinBudget(detail, 4 * 1024, "rolemaster roll detail");
|
||||
|
||||
var rollJson = JsonSerializer.Serialize(roll, SerializerOptions);
|
||||
var logPageJson = JsonSerializer.Serialize(logPage, SerializerOptions);
|
||||
var detailJson = JsonSerializer.Serialize(detail, SerializerOptions);
|
||||
|
||||
Assert.DoesNotContain("\"signedContribution\":null", rollJson, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("\"signedContribution\"", logPageJson, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("\"sequence\"", logPageJson, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("\"breakdown\"", logPageJson, StringComparison.Ordinal);
|
||||
Assert.Contains("\"kind\":\"rolemaster-open-ended-initial\"", detailJson, StringComparison.Ordinal);
|
||||
Assert.Contains("\"signedContribution\":66", detailJson, StringComparison.Ordinal);
|
||||
Assert.Contains("\"attempt\":1", detailJson, StringComparison.Ordinal);
|
||||
Assert.Contains("\"attempt\":2", detailJson, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static void AssertPayloadWithinBudget<T>(T payload, int maxBytes, string label)
|
||||
{
|
||||
var byteCount = JsonSerializer.SerializeToUtf8Bytes(payload, SerializerOptions).Length;
|
||||
@@ -123,5 +182,15 @@ public sealed class PayloadBudgetTests
|
||||
return scriptedRolls;
|
||||
}
|
||||
|
||||
private static int[] CreateRolemasterOpenEndedRolls(int count)
|
||||
{
|
||||
var values = new[] { 96, 100, 12 };
|
||||
var scriptedRolls = new int[count];
|
||||
for (var i = 0; i < count; i++)
|
||||
scriptedRolls[i] = values[i % values.Length];
|
||||
|
||||
return scriptedRolls;
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions SerializerOptions = RpgRollerJson.CreateSerializerOptions();
|
||||
}
|
||||
@@ -7,28 +7,53 @@ public sealed class DiceRulesTests
|
||||
{
|
||||
Assert.Equal(RulesetKind.D6, DiceRules.TryParseRulesetId("d6"));
|
||||
Assert.Equal(RulesetKind.Dnd5e, DiceRules.TryParseRulesetId("dnd5e"));
|
||||
Assert.Equal(RulesetKind.Rolemaster, DiceRules.TryParseRulesetId("rolemaster"));
|
||||
Assert.Null(DiceRules.TryParseRulesetId("unknown"));
|
||||
|
||||
var d6 = DiceRules.ParseExpression(RulesetKind.D6, "5D+4");
|
||||
var dnd = DiceRules.ParseExpression(RulesetKind.Dnd5e, "2d12+2");
|
||||
var rolemasterImplicitSingle = DiceRules.ParseExpression(RulesetKind.Rolemaster, "d10");
|
||||
var rolemasterManyDice = DiceRules.ParseExpression(RulesetKind.Rolemaster, "15d10-15");
|
||||
var rolemasterPercentile = DiceRules.ParseExpression(RulesetKind.Rolemaster, "d100+4");
|
||||
var rolemasterOpenEnded = DiceRules.ParseExpression(RulesetKind.Rolemaster, "1d100!+85");
|
||||
var emptyExpression = DiceRules.ParseExpression(RulesetKind.Dnd5e, "");
|
||||
var badFormat = DiceRules.ParseExpression(RulesetKind.Dnd5e, "abc");
|
||||
var tooManyDice = DiceRules.ParseExpression(RulesetKind.D6, "51D+1");
|
||||
var tooManySides = DiceRules.ParseExpression(RulesetKind.Dnd5e, "1d1001");
|
||||
var tooLargeModifier = DiceRules.ParseExpression(RulesetKind.Dnd5e, "1d20+1001");
|
||||
var negativeDndModifier = DiceRules.ParseExpression(RulesetKind.Dnd5e, "1d20-1");
|
||||
var invalidRolemasterOpenEndedFormat = DiceRules.ParseExpression(RulesetKind.Rolemaster, "2d10!+1");
|
||||
var tooNegativeRolemasterModifier = DiceRules.ParseExpression(RulesetKind.Rolemaster, "d100-1001");
|
||||
var unknownRulesetExpression = DiceRules.ParseExpression((RulesetKind)99, "1d20+1");
|
||||
|
||||
Assert.True(d6.Succeeded);
|
||||
Assert.True(dnd.Succeeded);
|
||||
Assert.True(rolemasterImplicitSingle.Succeeded);
|
||||
Assert.True(rolemasterManyDice.Succeeded);
|
||||
Assert.True(rolemasterPercentile.Succeeded);
|
||||
Assert.True(rolemasterOpenEnded.Succeeded);
|
||||
Assert.False(emptyExpression.Succeeded);
|
||||
Assert.False(badFormat.Succeeded);
|
||||
Assert.False(tooManyDice.Succeeded);
|
||||
Assert.False(tooManySides.Succeeded);
|
||||
Assert.False(tooLargeModifier.Succeeded);
|
||||
Assert.False(negativeDndModifier.Succeeded);
|
||||
Assert.False(invalidRolemasterOpenEndedFormat.Succeeded);
|
||||
Assert.False(tooNegativeRolemasterModifier.Succeeded);
|
||||
Assert.False(unknownRulesetExpression.Succeeded);
|
||||
|
||||
Assert.Equal("d10", rolemasterImplicitSingle.Value!.Canonical);
|
||||
Assert.Equal(DiceExpressionKind.Standard, rolemasterImplicitSingle.Value.Kind);
|
||||
Assert.Equal("15d10-15", rolemasterManyDice.Value!.Canonical);
|
||||
Assert.Equal(DiceExpressionKind.Standard, rolemasterManyDice.Value.Kind);
|
||||
Assert.Equal("d100+4", rolemasterPercentile.Value!.Canonical);
|
||||
Assert.Equal(DiceExpressionKind.Standard, rolemasterPercentile.Value.Kind);
|
||||
Assert.Equal("d100!+85", rolemasterOpenEnded.Value!.Canonical);
|
||||
Assert.Equal(DiceExpressionKind.RolemasterOpenEndedPercentile, rolemasterOpenEnded.Value.Kind);
|
||||
|
||||
Assert.Equal("d6", DiceRules.ToRulesetId(RulesetKind.D6));
|
||||
Assert.Equal("dnd5e", DiceRules.ToRulesetId(RulesetKind.Dnd5e));
|
||||
Assert.Equal("rolemaster", DiceRules.ToRulesetId(RulesetKind.Rolemaster));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => DiceRules.ToRulesetId((RulesetKind)99));
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
using RpgRoller.Domain;
|
||||
|
||||
namespace RpgRoller.Tests;
|
||||
|
||||
public sealed class ServiceAdminAndCampaignDeletionTests
|
||||
@@ -18,6 +16,9 @@ public sealed class ServiceAdminAndCampaignDeletionTests
|
||||
var adminSession = ServiceTestSupport.GetValue(service.Login("admin", "Password123")).SessionToken;
|
||||
var memberSession = ServiceTestSupport.GetValue(service.Login("member", "Password123")).SessionToken;
|
||||
|
||||
var usernames = ServiceTestSupport.GetValue(service.GetUsernames(memberSession));
|
||||
Assert.Equal(["admin", "member"], usernames);
|
||||
|
||||
var forbiddenList = service.GetUsers(memberSession);
|
||||
Assert.False(forbiddenList.Succeeded);
|
||||
|
||||
@@ -28,6 +29,10 @@ public sealed class ServiceAdminAndCampaignDeletionTests
|
||||
var promoted = ServiceTestSupport.GetValue(service.UpdateUserRoles(adminSession, memberUser.Id, [UserRoles.Admin]));
|
||||
Assert.Contains(promoted.Roles, role => string.Equals(role, UserRoles.Admin, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
var invalidRole = service.UpdateUserRoles(adminSession, memberUser.Id, [UserRoles.Admin, "gm"]);
|
||||
Assert.False(invalidRole.Succeeded);
|
||||
Assert.Equal("invalid_role", invalidRole.Error?.Code);
|
||||
|
||||
var selfDemote = service.UpdateUserRoles(adminSession, bootstrapAdmin.Id, Array.Empty<string>());
|
||||
Assert.False(selfDemote.Succeeded);
|
||||
|
||||
@@ -111,6 +116,9 @@ public sealed class ServiceAdminAndCampaignDeletionTests
|
||||
var deleteResult = ServiceTestSupport.GetValue(service.DeleteUser(adminSession, gmUser.Id));
|
||||
Assert.True(deleteResult);
|
||||
|
||||
Assert.Null(service.GetUserBySession(gmSession));
|
||||
Assert.False(service.GetMe(gmSession).Succeeded);
|
||||
Assert.False(service.GetUsernames(gmSession).Succeeded);
|
||||
Assert.False(service.GetCampaign(adminSession, gmOwnedCampaign.Id).Succeeded);
|
||||
|
||||
var playerCharacters = ServiceTestSupport.GetValue(service.GetOwnCharacters(playerSession));
|
||||
|
||||
@@ -14,9 +14,11 @@ public sealed class ServiceCampaignTests
|
||||
service.Register("gm", "Password123", "GM");
|
||||
var gmSession = ServiceTestSupport.GetValue(service.Login("gm", "Password123")).SessionToken;
|
||||
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Name", "d6"));
|
||||
var rolemasterCampaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Rolemaster Name", "rolemaster"));
|
||||
|
||||
var invalidRuleset = service.CreateCampaign(gmSession, "Name 2", "unknown");
|
||||
Assert.False(invalidRuleset.Succeeded);
|
||||
Assert.Equal("rolemaster", rolemasterCampaign.RulesetId);
|
||||
|
||||
var noCampaignCharacter = service.CreateCharacter(gmSession, "Hero", Guid.NewGuid());
|
||||
Assert.False(noCampaignCharacter.Succeeded);
|
||||
|
||||
85
RpgRoller.Tests/Services/ServiceHelperExtractionTests.cs
Normal file
85
RpgRoller.Tests/Services/ServiceHelperExtractionTests.cs
Normal file
@@ -0,0 +1,85 @@
|
||||
namespace RpgRoller.Tests;
|
||||
|
||||
public sealed class ServiceHelperExtractionTests
|
||||
{
|
||||
[Fact]
|
||||
public void RoleSerializer_NormalizesParsesAndSerializesRoles()
|
||||
{
|
||||
var normalized = RoleSerializer.Normalize([" Admin ", "gm", "admin", "", "GM"]);
|
||||
var serialized = RoleSerializer.Serialize(normalized);
|
||||
var parsed = RoleSerializer.Parse(" admin,GM,admin ");
|
||||
|
||||
Assert.Equal(["admin", "gm"], normalized);
|
||||
Assert.Equal("admin,gm", serialized);
|
||||
Assert.Equal(["admin", "gm"], parsed);
|
||||
Assert.True(RoleSerializer.HasRole(serialized, "ADMIN"));
|
||||
Assert.False(RoleSerializer.HasRole(serialized, "owner"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("public", RollVisibility.Public)]
|
||||
[InlineData("PRIVATE", RollVisibility.Private)]
|
||||
public void RollVisibilityParser_ParsesKnownValues(string input, RollVisibility expected)
|
||||
{
|
||||
var result = RollVisibilityParser.Parse(input);
|
||||
|
||||
Assert.True(result.Succeeded);
|
||||
Assert.Equal(expected, result.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RollVisibilityParser_RejectsUnknownValue()
|
||||
{
|
||||
var result = RollVisibilityParser.Parse("hidden");
|
||||
|
||||
Assert.False(result.Succeeded);
|
||||
Assert.Equal("invalid_visibility", result.Error!.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CustomRollOptionsResolver_ReturnsD6DefaultsOnlyForD6()
|
||||
{
|
||||
Assert.Equal((1, true, (int?)null), CustomRollOptionsResolver.Resolve(RulesetKind.D6));
|
||||
Assert.Equal((0, false, (int?)null), CustomRollOptionsResolver.Resolve(RulesetKind.Dnd5e));
|
||||
Assert.Equal((0, false, (int?)null), CustomRollOptionsResolver.Resolve(RulesetKind.Rolemaster));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SkillDefinitionValidator_ValidatesRulesetSpecificOptions()
|
||||
{
|
||||
var d6 = SkillDefinitionValidator.Validate(RulesetKind.D6, "2D+1", 1, true, null);
|
||||
var rolemaster = SkillDefinitionValidator.Validate(RulesetKind.Rolemaster, "d100!+15", 0, false, 5, true);
|
||||
var invalidD6 = SkillDefinitionValidator.Validate(RulesetKind.D6, "2D+1", 0, true, null);
|
||||
var invalidRolemaster = SkillDefinitionValidator.Validate(RulesetKind.Rolemaster, "d100!+15", 0, false, null);
|
||||
var invalidRetry = SkillDefinitionValidator.Validate(RulesetKind.Rolemaster, "d100+15", 0, false, null, true);
|
||||
|
||||
Assert.True(d6.Succeeded);
|
||||
Assert.Equal(("2D+1", 1, true, (int?)null, false), d6.Value);
|
||||
|
||||
Assert.True(rolemaster.Succeeded);
|
||||
Assert.Equal(("d100!+15", 0, false, (int?)5, true), rolemaster.Value);
|
||||
|
||||
Assert.False(invalidD6.Succeeded);
|
||||
Assert.Equal("invalid_wild_dice", invalidD6.Error!.Code);
|
||||
|
||||
Assert.False(invalidRolemaster.Succeeded);
|
||||
Assert.Equal("invalid_fumble_range", invalidRolemaster.Error!.Code);
|
||||
|
||||
Assert.False(invalidRetry.Succeeded);
|
||||
Assert.Equal("invalid_rolemaster_retry", invalidRetry.Error!.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RolemasterRetryPolicy_ResolvesRetryBandsAndMarkers()
|
||||
{
|
||||
Assert.Equal(5, RolemasterRetryPolicy.ResolveAutoRetryBonus(76));
|
||||
Assert.Equal(5, RolemasterRetryPolicy.ResolveAutoRetryBonus(90));
|
||||
Assert.Equal(10, RolemasterRetryPolicy.ResolveAutoRetryBonus(91));
|
||||
Assert.Equal(10, RolemasterRetryPolicy.ResolveAutoRetryBonus(110));
|
||||
Assert.Null(RolemasterRetryPolicy.ResolveAutoRetryBonus(75));
|
||||
Assert.Null(RolemasterRetryPolicy.ResolveAutoRetryBonus(111));
|
||||
Assert.Equal(5, RolemasterRetryPolicy.TryExtractRetryBonus("68+10=78; retry(+5): 42+10=52; final=57"));
|
||||
Assert.Equal(10, RolemasterRetryPolicy.TryExtractRetryBonus("90+1=91; retry(+10): 32+1=33; final=43"));
|
||||
Assert.Null(RolemasterRetryPolicy.TryExtractRetryBonus("68+10=78"));
|
||||
}
|
||||
}
|
||||
@@ -92,4 +92,32 @@ public sealed class ServicePersistenceTests
|
||||
Assert.False(invalidExpressionHarness.Service.RollSkill(ownerSession, skill.Id, "public").Succeeded);
|
||||
Assert.False(service.GetCampaignLog(string.Empty, campaign.Id).Succeeded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RolemasterSkillOptions_PersistAcrossDatabaseReload()
|
||||
{
|
||||
using var harness = ServiceTestSupport.CreateHarness();
|
||||
var service = harness.Service;
|
||||
|
||||
service.Register("gm-rm-persist", "Password123", "GM");
|
||||
service.Register("owner-rm-persist", "Password123", "Owner");
|
||||
|
||||
var gmSession = ServiceTestSupport.GetValue(service.Login("gm-rm-persist", "Password123")).SessionToken;
|
||||
var ownerSession = ServiceTestSupport.GetValue(service.Login("owner-rm-persist", "Password123")).SessionToken;
|
||||
|
||||
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Rolemaster Persistence", "rolemaster"));
|
||||
var character = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Loremaster", campaign.Id));
|
||||
var group = ServiceTestSupport.GetValue(service.CreateSkillGroup(ownerSession, character.Id, "Perception", "d100!+25", 0, false, 5));
|
||||
var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, character.Id, "Read Runes", "d100!+35", 0, false, group.Id, 3, true));
|
||||
|
||||
using var reloadedHarness = ServiceTestSupport.CreateHarnessFromPath(harness.DbPath);
|
||||
var reloadedSheet = ServiceTestSupport.GetValue(reloadedHarness.Service.GetCharacterSheet(ownerSession, character.Id));
|
||||
|
||||
var reloadedGroup = Assert.Single(reloadedSheet.SkillGroups, current => current.Id == group.Id);
|
||||
Assert.Equal(5, reloadedGroup.FumbleRange);
|
||||
|
||||
var reloadedSkill = Assert.Single(reloadedSheet.Skills, current => current.Id == skill.Id);
|
||||
Assert.Equal(3, reloadedSkill.FumbleRange);
|
||||
Assert.True(reloadedSkill.RolemasterAutoRetry);
|
||||
}
|
||||
}
|
||||
317
RpgRoller.Tests/Services/ServiceRolemasterRollTests.cs
Normal file
317
RpgRoller.Tests/Services/ServiceRolemasterRollTests.cs
Normal file
@@ -0,0 +1,317 @@
|
||||
namespace RpgRoller.Tests;
|
||||
|
||||
public sealed class ServiceRolemasterRollTests
|
||||
{
|
||||
[Fact]
|
||||
public void RollSkill_RolemasterStandardMultiDie_ComputesTotalAndTagsDice()
|
||||
{
|
||||
using var harness = ServiceTestSupport.CreateHarness(7, 10);
|
||||
var service = harness.Service;
|
||||
|
||||
service.Register("gm-init", "Password123", "GM");
|
||||
var session = ServiceTestSupport.GetValue(service.Login("gm-init", "Password123")).SessionToken;
|
||||
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(session, "Rolemaster", "rolemaster"));
|
||||
var character = ServiceTestSupport.GetValue(service.CreateCharacter(session, "Hero", campaign.Id));
|
||||
var skill = ServiceTestSupport.GetValue(service.CreateSkill(session, character.Id, "Healing", "2d10+48", 0, false));
|
||||
|
||||
var roll = ServiceTestSupport.GetValue(service.RollSkill(session, skill.Id, "public"));
|
||||
var logPage = ServiceTestSupport.GetValue(service.GetCampaignLogPage(session, campaign.Id, limit: 5));
|
||||
|
||||
Assert.Equal(65, roll.Result);
|
||||
Assert.Equal("7+10+48=65", roll.Breakdown);
|
||||
Assert.Equal("7 + 10 | rolemaster", Assert.Single(logPage.Entries).SummaryText);
|
||||
Assert.Collection(roll.Dice, die =>
|
||||
{
|
||||
Assert.Equal(7, die.Roll);
|
||||
Assert.Equal(1, die.Sequence);
|
||||
Assert.Equal(RollDieKinds.RolemasterStandard, die.Kind);
|
||||
Assert.Equal(7, die.SignedContribution);
|
||||
}, die =>
|
||||
{
|
||||
Assert.Equal(10, die.Roll);
|
||||
Assert.Equal(2, die.Sequence);
|
||||
Assert.Equal(RollDieKinds.RolemasterStandard, die.Kind);
|
||||
Assert.Equal(10, die.SignedContribution);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RollSkill_RolemasterStandardSingleDie_ComputesTotalAndTagsDice()
|
||||
{
|
||||
using var harness = ServiceTestSupport.CreateHarness(73);
|
||||
var service = harness.Service;
|
||||
|
||||
service.Register("gm-percentile", "Password123", "GM");
|
||||
var session = ServiceTestSupport.GetValue(service.Login("gm-percentile", "Password123")).SessionToken;
|
||||
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(session, "Rolemaster", "rolemaster"));
|
||||
var character = ServiceTestSupport.GetValue(service.CreateCharacter(session, "Hero", campaign.Id));
|
||||
var skill = ServiceTestSupport.GetValue(service.CreateSkill(session, character.Id, "Perception", "d100-15", 0, false));
|
||||
|
||||
var roll = ServiceTestSupport.GetValue(service.RollSkill(session, skill.Id, "public"));
|
||||
var logPage = ServiceTestSupport.GetValue(service.GetCampaignLogPage(session, campaign.Id, limit: 5));
|
||||
|
||||
Assert.Equal(58, roll.Result);
|
||||
Assert.Equal("73-15=58", roll.Breakdown);
|
||||
Assert.Equal("73 | rolemaster", Assert.Single(logPage.Entries).SummaryText);
|
||||
Assert.Null(Assert.Single(logPage.Entries).EventBadges);
|
||||
|
||||
var die = Assert.Single(roll.Dice);
|
||||
Assert.Equal(73, die.Roll);
|
||||
Assert.Equal(1, die.Sequence);
|
||||
Assert.Equal(RollDieKinds.RolemasterStandard, die.Kind);
|
||||
Assert.Equal(73, die.SignedContribution);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RollSkill_RolemasterStandardSingleDie_AppliesSituationalModifier()
|
||||
{
|
||||
using var harness = ServiceTestSupport.CreateHarness(73);
|
||||
var service = harness.Service;
|
||||
|
||||
service.Register("gm-percentile-bonus", "Password123", "GM");
|
||||
var session = ServiceTestSupport.GetValue(service.Login("gm-percentile-bonus", "Password123")).SessionToken;
|
||||
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(session, "Rolemaster", "rolemaster"));
|
||||
var character = ServiceTestSupport.GetValue(service.CreateCharacter(session, "Hero", campaign.Id));
|
||||
var skill = ServiceTestSupport.GetValue(service.CreateSkill(session, character.Id, "Perception", "d100-15", 0, false));
|
||||
|
||||
var roll = ServiceTestSupport.GetValue(service.RollSkill(session, skill.Id, "public", 20));
|
||||
|
||||
Assert.Equal(78, roll.Result);
|
||||
Assert.Equal("73-15+20=78", roll.Breakdown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RollSkill_RolemasterOpenEndedHigh_RecursesAndBuildsReadableLogSummary()
|
||||
{
|
||||
using var harness = ServiceTestSupport.CreateHarness(97, 96, 45);
|
||||
var service = harness.Service;
|
||||
|
||||
service.Register("gm-open-high", "Password123", "GM");
|
||||
var session = ServiceTestSupport.GetValue(service.Login("gm-open-high", "Password123")).SessionToken;
|
||||
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(session, "Rolemaster", "rolemaster"));
|
||||
var character = ServiceTestSupport.GetValue(service.CreateCharacter(session, "Hero", campaign.Id));
|
||||
var skill = ServiceTestSupport.GetValue(service.CreateSkill(session, character.Id, "Awareness", "d100!+85", 0, false, null, 5));
|
||||
|
||||
var roll = ServiceTestSupport.GetValue(service.RollSkill(session, skill.Id, "public"));
|
||||
var detail = ServiceTestSupport.GetValue(service.GetRollDetail(session, roll.RollId));
|
||||
var logPage = ServiceTestSupport.GetValue(service.GetCampaignLogPage(session, campaign.Id, limit: 5));
|
||||
|
||||
Assert.Equal(323, roll.Result);
|
||||
Assert.Equal("97+96+45+85=323", roll.Breakdown);
|
||||
Assert.Equal("97 + 96 + 45 | open-ended high", Assert.Single(logPage.Entries).SummaryText);
|
||||
Assert.Null(Assert.Single(logPage.Entries).EventBadges);
|
||||
Assert.Equal(roll.Breakdown, detail.Breakdown);
|
||||
Assert.Collection(detail.Dice, die =>
|
||||
{
|
||||
Assert.Equal(97, die.Roll);
|
||||
Assert.Equal(1, die.Sequence);
|
||||
Assert.Equal(RollDieKinds.RolemasterOpenEndedInitial, die.Kind);
|
||||
Assert.Equal(97, die.SignedContribution);
|
||||
Assert.False(die.Added);
|
||||
}, die =>
|
||||
{
|
||||
Assert.Equal(96, die.Roll);
|
||||
Assert.Equal(2, die.Sequence);
|
||||
Assert.Equal(RollDieKinds.RolemasterOpenEndedHigh, die.Kind);
|
||||
Assert.Equal(96, die.SignedContribution);
|
||||
Assert.True(die.Added);
|
||||
}, die =>
|
||||
{
|
||||
Assert.Equal(45, die.Roll);
|
||||
Assert.Equal(3, die.Sequence);
|
||||
Assert.Equal(RollDieKinds.RolemasterOpenEndedHigh, die.Kind);
|
||||
Assert.Equal(45, die.SignedContribution);
|
||||
Assert.True(die.Added);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RollSkill_RolemasterOpenEndedLow_SubtractsRecursiveHighChain()
|
||||
{
|
||||
using var harness = ServiceTestSupport.CreateHarness(5, 97, 100, 12);
|
||||
var service = harness.Service;
|
||||
|
||||
service.Register("gm-open-low", "Password123", "GM");
|
||||
var session = ServiceTestSupport.GetValue(service.Login("gm-open-low", "Password123")).SessionToken;
|
||||
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(session, "Rolemaster", "rolemaster"));
|
||||
var character = ServiceTestSupport.GetValue(service.CreateCharacter(session, "Hero", campaign.Id));
|
||||
var skill = ServiceTestSupport.GetValue(service.CreateSkill(session, character.Id, "Awareness", "d100!+85", 0, false, null, 5));
|
||||
|
||||
var roll = ServiceTestSupport.GetValue(service.RollSkill(session, skill.Id, "public"));
|
||||
var logPage = ServiceTestSupport.GetValue(service.GetCampaignLogPage(session, campaign.Id, limit: 5));
|
||||
|
||||
Assert.Equal(-124, roll.Result);
|
||||
Assert.Equal("(05) -97 -100 -12 +85 = -124", roll.Breakdown);
|
||||
var logEntry = Assert.Single(logPage.Entries);
|
||||
Assert.Equal("(05) -97 -100 -12 | open-ended low", logEntry.SummaryText);
|
||||
var lowEventBadges = Assert.IsType<string[]>(logEntry.EventBadges);
|
||||
Assert.Collection(lowEventBadges, badge => Assert.Equal("rf", badge), badge => Assert.Equal("r100", badge));
|
||||
Assert.Collection(roll.Dice, die =>
|
||||
{
|
||||
Assert.Equal(5, die.Roll);
|
||||
Assert.Equal(1, die.Sequence);
|
||||
Assert.Equal(RollDieKinds.RolemasterOpenEndedInitial, die.Kind);
|
||||
Assert.Null(die.SignedContribution);
|
||||
}, die =>
|
||||
{
|
||||
Assert.Equal(97, die.Roll);
|
||||
Assert.Equal(2, die.Sequence);
|
||||
Assert.Equal(RollDieKinds.RolemasterOpenEndedLowSubtract, die.Kind);
|
||||
Assert.Equal(-97, die.SignedContribution);
|
||||
}, die =>
|
||||
{
|
||||
Assert.Equal(100, die.Roll);
|
||||
Assert.Equal(3, die.Sequence);
|
||||
Assert.Equal(RollDieKinds.RolemasterOpenEndedLowSubtract, die.Kind);
|
||||
Assert.Equal(-100, die.SignedContribution);
|
||||
}, die =>
|
||||
{
|
||||
Assert.Equal(12, die.Roll);
|
||||
Assert.Equal(4, die.Sequence);
|
||||
Assert.Equal(RollDieKinds.RolemasterOpenEndedLowSubtract, die.Kind);
|
||||
Assert.Equal(-12, die.SignedContribution);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RollSkill_RolemasterSixtySix_AddsRareBadgeToLogSummary()
|
||||
{
|
||||
using var harness = ServiceTestSupport.CreateHarness(66);
|
||||
var service = harness.Service;
|
||||
|
||||
service.Register("gm-sixty-six", "Password123", "GM");
|
||||
var session = ServiceTestSupport.GetValue(service.Login("gm-sixty-six", "Password123")).SessionToken;
|
||||
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(session, "Rolemaster", "rolemaster"));
|
||||
var character = ServiceTestSupport.GetValue(service.CreateCharacter(session, "Hero", campaign.Id));
|
||||
var skill = ServiceTestSupport.GetValue(service.CreateSkill(session, character.Id, "Perception", "d100-15", 0, false));
|
||||
|
||||
_ = ServiceTestSupport.GetValue(service.RollSkill(session, skill.Id, "public"));
|
||||
var logEntry = Assert.Single(ServiceTestSupport.GetValue(service.GetCampaignLogPage(session, campaign.Id, limit: 5)).Entries);
|
||||
|
||||
var badge = Assert.Single(Assert.IsType<string[]>(logEntry.EventBadges));
|
||||
Assert.Equal("r66", badge);
|
||||
Assert.Equal("66 | rolemaster", logEntry.SummaryText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RollSkill_RolemasterAutoRetryPlusFive_UsesRetryResultAndMarksAttempts()
|
||||
{
|
||||
using var harness = ServiceTestSupport.CreateHarness(66, 42);
|
||||
var service = harness.Service;
|
||||
|
||||
service.Register("gm-retry-five", "Password123", "GM");
|
||||
var session = ServiceTestSupport.GetValue(service.Login("gm-retry-five", "Password123")).SessionToken;
|
||||
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(session, "Rolemaster Retry", "rolemaster"));
|
||||
var character = ServiceTestSupport.GetValue(service.CreateCharacter(session, "Hero", campaign.Id));
|
||||
var skill = ServiceTestSupport.GetValue(service.CreateSkill(session, character.Id, "Awareness", "d100!+10", 0, false, null, 5, true));
|
||||
|
||||
var roll = ServiceTestSupport.GetValue(service.RollSkill(session, skill.Id, "public"));
|
||||
var detail = ServiceTestSupport.GetValue(service.GetRollDetail(session, roll.RollId));
|
||||
var logEntry = Assert.Single(ServiceTestSupport.GetValue(service.GetCampaignLogPage(session, campaign.Id, limit: 5)).Entries);
|
||||
|
||||
Assert.Equal(57, roll.Result);
|
||||
Assert.Equal("66+10=76; retry(+5): 42+10=52; final=57", roll.Breakdown);
|
||||
Assert.Equal("66 | open-ended | retry +5", logEntry.SummaryText);
|
||||
Assert.Equal(["r66", "rs5"], Assert.IsType<string[]>(logEntry.EventBadges));
|
||||
Assert.Equal(roll.Breakdown, detail.Breakdown);
|
||||
Assert.Collection(detail.Dice, die =>
|
||||
{
|
||||
Assert.Equal(66, die.Roll);
|
||||
Assert.Equal(1, die.Sequence);
|
||||
Assert.Equal(1, die.Attempt);
|
||||
Assert.Equal(RollDieKinds.RolemasterOpenEndedInitial, die.Kind);
|
||||
Assert.Equal(66, die.SignedContribution);
|
||||
}, die =>
|
||||
{
|
||||
Assert.Equal(42, die.Roll);
|
||||
Assert.Equal(1, die.Sequence);
|
||||
Assert.Equal(2, die.Attempt);
|
||||
Assert.Equal(RollDieKinds.RolemasterOpenEndedInitial, die.Kind);
|
||||
Assert.Equal(42, die.SignedContribution);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RollSkill_RolemasterAutoRetry_UsesSituationalModifierInBothAttempts()
|
||||
{
|
||||
using var harness = ServiceTestSupport.CreateHarness(8, 42);
|
||||
var service = harness.Service;
|
||||
|
||||
service.Register("gm-retry-situational", "Password123", "GM");
|
||||
var session = ServiceTestSupport.GetValue(service.Login("gm-retry-situational", "Password123")).SessionToken;
|
||||
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(session, "Rolemaster Retry", "rolemaster"));
|
||||
var character = ServiceTestSupport.GetValue(service.CreateCharacter(session, "Hero", campaign.Id));
|
||||
var skill = ServiceTestSupport.GetValue(service.CreateSkill(session, character.Id, "Observation", "d100!+50", 0, false, null, 5, true));
|
||||
|
||||
var roll = ServiceTestSupport.GetValue(service.RollSkill(session, skill.Id, "public", 20));
|
||||
var logEntry = Assert.Single(ServiceTestSupport.GetValue(service.GetCampaignLogPage(session, campaign.Id, limit: 5)).Entries);
|
||||
|
||||
Assert.Equal(117, roll.Result);
|
||||
Assert.Equal("8+50+20=78; retry(+5): 42+50+20=112; final=117", roll.Breakdown);
|
||||
Assert.Equal("8 | open-ended | retry +5", logEntry.SummaryText);
|
||||
Assert.Equal(["rs5"], Assert.IsType<string[]>(logEntry.EventBadges));
|
||||
Assert.All(roll.Dice, die => Assert.True(die.Attempt is 1 or 2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RollSkill_RolemasterAutoRetryPlusTen_UsesRetryResultAndMarksAttempts()
|
||||
{
|
||||
using var harness = ServiceTestSupport.CreateHarness(90, 32);
|
||||
var service = harness.Service;
|
||||
|
||||
service.Register("gm-retry-ten", "Password123", "GM");
|
||||
var session = ServiceTestSupport.GetValue(service.Login("gm-retry-ten", "Password123")).SessionToken;
|
||||
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(session, "Rolemaster Retry", "rolemaster"));
|
||||
var character = ServiceTestSupport.GetValue(service.CreateCharacter(session, "Hero", campaign.Id));
|
||||
var skill = ServiceTestSupport.GetValue(service.CreateSkill(session, character.Id, "Awareness", "d100!+1", 0, false, null, 5, true));
|
||||
|
||||
var roll = ServiceTestSupport.GetValue(service.RollSkill(session, skill.Id, "public"));
|
||||
var logEntry = Assert.Single(ServiceTestSupport.GetValue(service.GetCampaignLogPage(session, campaign.Id, limit: 5)).Entries);
|
||||
|
||||
Assert.Equal(43, roll.Result);
|
||||
Assert.Equal("90+1=91; retry(+10): 32+1=33; final=43", roll.Breakdown);
|
||||
Assert.Equal("90 | open-ended | retry +10", logEntry.SummaryText);
|
||||
Assert.Equal(["rs10"], Assert.IsType<string[]>(logEntry.EventBadges));
|
||||
Assert.All(roll.Dice, die => Assert.True(die.Attempt is 1 or 2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RollSkill_RolemasterAutoRetryDisabled_KeepsOriginalResult()
|
||||
{
|
||||
using var harness = ServiceTestSupport.CreateHarness(65);
|
||||
var service = harness.Service;
|
||||
|
||||
service.Register("gm-retry-off", "Password123", "GM");
|
||||
var session = ServiceTestSupport.GetValue(service.Login("gm-retry-off", "Password123")).SessionToken;
|
||||
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(session, "Rolemaster Retry", "rolemaster"));
|
||||
var character = ServiceTestSupport.GetValue(service.CreateCharacter(session, "Hero", campaign.Id));
|
||||
var skill = ServiceTestSupport.GetValue(service.CreateSkill(session, character.Id, "Awareness", "d100!+10", 0, false, null, 5));
|
||||
|
||||
var roll = ServiceTestSupport.GetValue(service.RollSkill(session, skill.Id, "public"));
|
||||
var logEntry = Assert.Single(ServiceTestSupport.GetValue(service.GetCampaignLogPage(session, campaign.Id, limit: 5)).Entries);
|
||||
|
||||
Assert.Equal(75, roll.Result);
|
||||
Assert.Equal("65+10=75", roll.Breakdown);
|
||||
Assert.Equal("65 | open-ended", logEntry.SummaryText);
|
||||
Assert.Null(logEntry.EventBadges);
|
||||
Assert.All(roll.Dice, die => Assert.Null(die.Attempt));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RollSkill_SituationalModifier_IsRejectedForNonRolemasterCampaigns()
|
||||
{
|
||||
using var harness = ServiceTestSupport.CreateHarness(12);
|
||||
var service = harness.Service;
|
||||
|
||||
service.Register("gm-non-rolemaster-modifier", "Password123", "GM");
|
||||
var session = ServiceTestSupport.GetValue(service.Login("gm-non-rolemaster-modifier", "Password123")).SessionToken;
|
||||
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(session, "DnD", "dnd5e"));
|
||||
var character = ServiceTestSupport.GetValue(service.CreateCharacter(session, "Hero", campaign.Id));
|
||||
var skill = ServiceTestSupport.GetValue(service.CreateSkill(session, character.Id, "Attack", "1d20+5", 0, false));
|
||||
|
||||
var roll = service.RollSkill(session, skill.Id, "public", 20);
|
||||
|
||||
Assert.False(roll.Succeeded);
|
||||
Assert.Equal("invalid_situational_modifier", roll.Error!.Code);
|
||||
}
|
||||
}
|
||||
67
RpgRoller.Tests/Services/ServiceRollHelperTests.cs
Normal file
67
RpgRoller.Tests/Services/ServiceRollHelperTests.cs
Normal file
@@ -0,0 +1,67 @@
|
||||
namespace RpgRoller.Tests;
|
||||
|
||||
public sealed class ServiceRollHelperTests
|
||||
{
|
||||
private sealed class FixedDiceRoller(IEnumerable<int> values) : IDiceRoller
|
||||
{
|
||||
public int Roll(int sides)
|
||||
{
|
||||
var next = m_Values.Count > 0 ? m_Values.Dequeue() : 1;
|
||||
return Math.Clamp(next, 1, sides);
|
||||
}
|
||||
|
||||
private readonly Queue<int> m_Values = new(values);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RollBreakdownFormatter_FormatsStandardAndRolemasterOpenEndedBreakdowns()
|
||||
{
|
||||
Assert.Equal("4+5+2=11", RollBreakdownFormatter.BuildBreakdown([4, 5], 2, 11));
|
||||
Assert.Equal("0=0", RollBreakdownFormatter.BuildBreakdown([], 0, 0));
|
||||
Assert.Equal("4+5+2+7=18", RollBreakdownFormatter.BuildRolemasterModifierBreakdown([4, 5], 2, 7, 18));
|
||||
Assert.Equal("97+96+45+85=323", RollBreakdownFormatter.BuildRolemasterOpenEndedBreakdown(97, [96, 45], false, 85, 323));
|
||||
Assert.Equal("8+50+20=78", RollBreakdownFormatter.BuildRolemasterOpenEndedBreakdown(8, [], false, 50, 78, 20));
|
||||
Assert.Equal("(05) -97 -100 -12 +85 = -124", RollBreakdownFormatter.BuildRolemasterOpenEndedBreakdown(5, [97, 100, 12], true, 85, -124));
|
||||
Assert.Equal("(05) -97 -100 -12 +85 +20 = -104", RollBreakdownFormatter.BuildRolemasterOpenEndedBreakdown(5, [97, 100, 12], true, 85, -104, 20));
|
||||
Assert.Equal("66+10=76; retry(+5): 42+10=52; final=57", RollBreakdownFormatter.BuildRolemasterRetryBreakdown("66+10=76", 5, "42+10=52", 57));
|
||||
Assert.Equal("05", RollBreakdownFormatter.FormatRolemasterTriggerRoll(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CampaignLogSummaryBuilder_ExtractsExpressionsAndBuildsBadgesAndSummaries()
|
||||
{
|
||||
var d6Dice = new[] { new RollDieResult(6, true, false, true, false, false), new RollDieResult(1, false, true, true, false, false) };
|
||||
var rolemasterDice = new[] { new RollDieResult(5, false, false, false, false, false, 1, RollDieKinds.RolemasterOpenEndedInitial), new RollDieResult(97, false, false, false, false, false, 2, RollDieKinds.RolemasterOpenEndedLowSubtract, -97), new RollDieResult(100, false, false, false, false, false, 3, RollDieKinds.RolemasterOpenEndedLowSubtract, -100) };
|
||||
var retryDice = new[] { new RollDieResult(66, false, false, false, false, false, 1, RollDieKinds.RolemasterOpenEndedInitial, 66, 1), new RollDieResult(42, false, false, false, false, false, 1, RollDieKinds.RolemasterOpenEndedInitial, 42, 2) };
|
||||
const string retryBreakdown = "66+10=76; retry(+5): 42+10=52; final=57";
|
||||
|
||||
Assert.Equal("1d20+5", CampaignLogSummaryBuilder.ExtractCustomRollExpression("1d20+5 => 20+5=25", " => "));
|
||||
Assert.Null(CampaignLogSummaryBuilder.ExtractCustomRollExpression("20+5=25", " => "));
|
||||
Assert.Equal("6, 1", CampaignLogSummaryBuilder.BuildCompactLogSummary(d6Dice));
|
||||
Assert.Equal("(05) -97 -100 | open-ended low", CampaignLogSummaryBuilder.BuildCompactLogSummary(rolemasterDice));
|
||||
Assert.Equal("66 | open-ended | retry +5", CampaignLogSummaryBuilder.BuildCompactLogSummary(retryDice, retryBreakdown));
|
||||
Assert.Equal("No detail available.", CampaignLogSummaryBuilder.BuildCompactLogSummary([]));
|
||||
Assert.Equal(["w6", "w1"], Assert.IsType<string[]>(CampaignLogSummaryBuilder.BuildCompactLogEventBadges(RulesetKind.D6, null, d6Dice)));
|
||||
Assert.Equal(["n20"], Assert.IsType<string[]>(CampaignLogSummaryBuilder.BuildCompactLogEventBadges(RulesetKind.Dnd5e, "1d20+5", [new(20, false, false, false, false, false)])));
|
||||
Assert.Equal(["rf", "r100"], Assert.IsType<string[]>(CampaignLogSummaryBuilder.BuildCompactLogEventBadges(RulesetKind.Rolemaster, "d100!+85", rolemasterDice)));
|
||||
Assert.Equal(["r66", "rs5"], Assert.IsType<string[]>(CampaignLogSummaryBuilder.BuildCompactLogEventBadges(RulesetKind.Rolemaster, "d100!+10", retryDice, retryBreakdown)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RollEngine_DelegatesToRulesetSpecificEngines()
|
||||
{
|
||||
var engine = new RollEngine(new(new FixedDiceRoller([7, 10])), new(new FixedDiceRoller([6, 4, 2])), new(new FixedDiceRoller([97, 96, 45])));
|
||||
|
||||
var d6Roll = engine.Roll(RulesetKind.D6, new(2, 6, 1, "2D+1"), 1, true, null);
|
||||
Assert.Equal(13, d6Roll.Total);
|
||||
Assert.Equal("6+4+2+1=13", d6Roll.Breakdown);
|
||||
|
||||
var standardRoll = engine.Roll(RulesetKind.Dnd5e, new(2, 10, 3, "2d10+3"), 0, false, null);
|
||||
Assert.Equal(20, standardRoll.Total);
|
||||
Assert.Equal("7+10+3=20", standardRoll.Breakdown);
|
||||
|
||||
var rolemasterRoll = engine.Roll(RulesetKind.Rolemaster, new(1, 100, 85, "d100!+85", DiceExpressionKind.RolemasterOpenEndedPercentile), 0, false, 5);
|
||||
Assert.Equal(323, rolemasterRoll.Total);
|
||||
Assert.Equal("97+96+45+85=323", rolemasterRoll.Breakdown);
|
||||
}
|
||||
}
|
||||
385
RpgRoller.Tests/Services/ServiceSharedHelperTests.cs
Normal file
385
RpgRoller.Tests/Services/ServiceSharedHelperTests.cs
Normal file
@@ -0,0 +1,385 @@
|
||||
namespace RpgRoller.Tests;
|
||||
|
||||
public sealed class ServiceSharedHelperTests
|
||||
{
|
||||
[Fact]
|
||||
public void GameStateStore_TracksCampaignSlicesAndCharacterVersions()
|
||||
{
|
||||
var campaignId = Guid.NewGuid();
|
||||
var characterId = Guid.NewGuid();
|
||||
var store = new GameStateStore();
|
||||
|
||||
store.CampaignsById[campaignId] = new()
|
||||
{
|
||||
Id = campaignId,
|
||||
GmUserId = Guid.NewGuid(),
|
||||
Name = "Alpha",
|
||||
Ruleset = RulesetKind.D6,
|
||||
Version = 1
|
||||
};
|
||||
store.CharactersById[characterId] = new()
|
||||
{
|
||||
Id = characterId,
|
||||
OwnerUserId = Guid.NewGuid(),
|
||||
CampaignId = campaignId,
|
||||
Name = "Scout"
|
||||
};
|
||||
|
||||
store.RebuildCampaignStateLocked();
|
||||
|
||||
var initialState = store.GetOrCreateCampaignStateLocked(campaignId);
|
||||
Assert.Equal(1, initialState.CharacterVersions[characterId]);
|
||||
|
||||
store.TouchRosterLocked(campaignId);
|
||||
store.TouchCharacterLocked(campaignId, characterId);
|
||||
store.TouchLogLocked(campaignId);
|
||||
|
||||
Assert.Equal(4, initialState.TotalVersion);
|
||||
Assert.Equal(2, initialState.RosterVersion);
|
||||
Assert.Equal(2, initialState.LogVersion);
|
||||
Assert.Equal(2, initialState.CharacterVersions[characterId]);
|
||||
|
||||
store.RemoveCharacterStateLocked(campaignId, characterId);
|
||||
Assert.Empty(initialState.CharacterVersions);
|
||||
|
||||
store.AddCharacterStateLocked(campaignId, characterId);
|
||||
Assert.Equal(1, initialState.CharacterVersions[characterId]);
|
||||
|
||||
store.TouchRosterLocked(null);
|
||||
store.TouchCharacterLocked(Guid.NewGuid(), Guid.NewGuid());
|
||||
store.TouchLogLocked(Guid.NewGuid());
|
||||
store.RemoveCharacterStateLocked(Guid.NewGuid(), Guid.NewGuid());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GameAuthorization_CoversCampaignAndRollVisibility()
|
||||
{
|
||||
var adminId = Guid.NewGuid();
|
||||
var gmId = Guid.NewGuid();
|
||||
var playerId = Guid.NewGuid();
|
||||
var outsiderId = Guid.NewGuid();
|
||||
var campaignId = Guid.NewGuid();
|
||||
var store = new GameStateStore();
|
||||
|
||||
store.UsersById[adminId] = new()
|
||||
{
|
||||
Id = adminId,
|
||||
Username = "admin",
|
||||
UsernameNormalized = "ADMIN",
|
||||
PasswordHash = "hash",
|
||||
DisplayName = "Admin",
|
||||
Roles = UserRoles.Admin
|
||||
};
|
||||
store.UsersById[gmId] = new()
|
||||
{
|
||||
Id = gmId,
|
||||
Username = "gm",
|
||||
UsernameNormalized = "GM",
|
||||
PasswordHash = "hash",
|
||||
DisplayName = "GM",
|
||||
Roles = string.Empty
|
||||
};
|
||||
store.UsersById[playerId] = new()
|
||||
{
|
||||
Id = playerId,
|
||||
Username = "player",
|
||||
UsernameNormalized = "PLAYER",
|
||||
PasswordHash = "hash",
|
||||
DisplayName = "Player",
|
||||
Roles = string.Empty
|
||||
};
|
||||
store.UsersById[outsiderId] = new()
|
||||
{
|
||||
Id = outsiderId,
|
||||
Username = "outsider",
|
||||
UsernameNormalized = "OUTSIDER",
|
||||
PasswordHash = "hash",
|
||||
DisplayName = "Outsider",
|
||||
Roles = string.Empty
|
||||
};
|
||||
|
||||
var campaign = new Campaign
|
||||
{
|
||||
Id = campaignId,
|
||||
GmUserId = gmId,
|
||||
Name = "Alpha",
|
||||
Ruleset = RulesetKind.D6,
|
||||
Version = 1
|
||||
};
|
||||
store.CampaignsById[campaignId] = campaign;
|
||||
var playerCharacterId = Guid.NewGuid();
|
||||
store.CharactersById[playerCharacterId] = new()
|
||||
{
|
||||
Id = playerCharacterId,
|
||||
OwnerUserId = playerId,
|
||||
CampaignId = campaignId,
|
||||
Name = "Scout"
|
||||
};
|
||||
|
||||
var publicEntry = new RollLogEntry
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CampaignId = campaignId,
|
||||
CharacterId = Guid.NewGuid(),
|
||||
SkillId = Guid.NewGuid(),
|
||||
RollerUserId = playerId,
|
||||
Visibility = RollVisibility.Public,
|
||||
Result = 12,
|
||||
Breakdown = "6+6=12",
|
||||
Dice = "[]",
|
||||
TimestampUtc = DateTimeOffset.UtcNow
|
||||
};
|
||||
var privateEntry = new RollLogEntry
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CampaignId = publicEntry.CampaignId,
|
||||
CharacterId = publicEntry.CharacterId,
|
||||
SkillId = publicEntry.SkillId,
|
||||
RollerUserId = publicEntry.RollerUserId,
|
||||
Visibility = RollVisibility.Private,
|
||||
Result = publicEntry.Result,
|
||||
Breakdown = publicEntry.Breakdown,
|
||||
Dice = publicEntry.Dice,
|
||||
TimestampUtc = publicEntry.TimestampUtc
|
||||
};
|
||||
|
||||
Assert.True(GameAuthorization.HasRole(store.UsersById[adminId], UserRoles.Admin));
|
||||
Assert.True(GameAuthorization.CanViewCampaign(store, adminId, campaignId));
|
||||
Assert.True(GameAuthorization.CanViewCampaign(store, gmId, campaignId));
|
||||
Assert.True(GameAuthorization.CanViewCampaign(store, playerId, campaignId));
|
||||
Assert.False(GameAuthorization.CanViewCampaign(store, outsiderId, campaignId));
|
||||
|
||||
Assert.True(GameAuthorization.CanEditCharacter(playerId, store.CharactersById.Values.Single(), campaign));
|
||||
Assert.True(GameAuthorization.CanEditCharacter(gmId, store.CharactersById.Values.Single(), campaign));
|
||||
Assert.False(GameAuthorization.CanEditCharacter(outsiderId, store.CharactersById.Values.Single(), campaign));
|
||||
|
||||
Assert.True(GameAuthorization.CanViewRoll(store, gmId, campaign, privateEntry));
|
||||
Assert.True(GameAuthorization.CanViewRoll(store, playerId, campaign, privateEntry));
|
||||
Assert.False(GameAuthorization.CanViewRoll(store, outsiderId, campaign, publicEntry));
|
||||
Assert.False(GameAuthorization.CanViewRoll(store, outsiderId, campaign, privateEntry));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GameContextResolver_HandlesUnauthorizedForbiddenAndSuccessPaths()
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
var otherUserId = Guid.NewGuid();
|
||||
var campaignId = Guid.NewGuid();
|
||||
var store = new GameStateStore();
|
||||
|
||||
store.UsersById[userId] = new()
|
||||
{
|
||||
Id = userId,
|
||||
Username = "user",
|
||||
UsernameNormalized = "USER",
|
||||
PasswordHash = "hash",
|
||||
DisplayName = "User",
|
||||
Roles = string.Empty
|
||||
};
|
||||
store.UsersById[otherUserId] = new()
|
||||
{
|
||||
Id = otherUserId,
|
||||
Username = "other",
|
||||
UsernameNormalized = "OTHER",
|
||||
PasswordHash = "hash",
|
||||
DisplayName = "Other",
|
||||
Roles = string.Empty
|
||||
};
|
||||
store.SessionsByToken["valid"] = new()
|
||||
{
|
||||
Token = "valid",
|
||||
UserId = userId,
|
||||
CreatedAtUtc = DateTimeOffset.UtcNow
|
||||
};
|
||||
store.CampaignsById[campaignId] = new()
|
||||
{
|
||||
Id = campaignId,
|
||||
GmUserId = otherUserId,
|
||||
Name = "Alpha",
|
||||
Ruleset = RulesetKind.D6,
|
||||
Version = 1
|
||||
};
|
||||
var participant = new Character
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OwnerUserId = userId,
|
||||
CampaignId = campaignId,
|
||||
Name = "Scout"
|
||||
};
|
||||
store.CharactersById[participant.Id] = participant;
|
||||
|
||||
Assert.Null(GameContextResolver.ResolveUserLocked(store, string.Empty));
|
||||
Assert.Null(GameContextResolver.ResolveUserLocked(store, "missing"));
|
||||
Assert.Equal(userId, GameContextResolver.ResolveUserLocked(store, "valid")!.Id);
|
||||
|
||||
Assert.Equal("unauthorized", GameContextResolver.ResolveCampaignContextLocked(store, string.Empty, campaignId).Error!.Code);
|
||||
Assert.Equal("campaign_not_found", GameContextResolver.ResolveCampaignContextLocked(store, "valid", Guid.NewGuid()).Error!.Code);
|
||||
|
||||
var forbiddenStore = new GameStateStore();
|
||||
forbiddenStore.UsersById[userId] = store.UsersById[userId];
|
||||
forbiddenStore.SessionsByToken["valid"] = store.SessionsByToken["valid"];
|
||||
forbiddenStore.CampaignsById[campaignId] = store.CampaignsById[campaignId];
|
||||
Assert.Equal("forbidden", GameContextResolver.ResolveCampaignContextLocked(forbiddenStore, "valid", campaignId).Error!.Code);
|
||||
|
||||
var context = ServiceTestSupport.GetValue(GameContextResolver.ResolveCampaignContextLocked(store, "valid", campaignId));
|
||||
Assert.Equal(userId, context.User.Id);
|
||||
Assert.Equal(campaignId, context.Campaign.Id);
|
||||
|
||||
var orphan = new Character
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OwnerUserId = userId,
|
||||
CampaignId = null,
|
||||
Name = "Orphan"
|
||||
};
|
||||
Assert.False(GameContextResolver.TryResolveCharacterCampaignLocked(store, orphan, out _, out var orphanError));
|
||||
Assert.Equal("character_not_in_campaign", orphanError!.Code);
|
||||
|
||||
var missingCampaignCharacter = new Character
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OwnerUserId = orphan.OwnerUserId,
|
||||
CampaignId = Guid.NewGuid(),
|
||||
Name = orphan.Name
|
||||
};
|
||||
Assert.False(GameContextResolver.TryResolveCharacterCampaignLocked(store, missingCampaignCharacter, out _, out var missingCampaignError));
|
||||
Assert.Equal("character_not_in_campaign", missingCampaignError!.Code);
|
||||
|
||||
Assert.True(GameContextResolver.TryResolveCharacterCampaignLocked(store, participant, out var resolvedCampaign, out var noError));
|
||||
Assert.Equal(campaignId, resolvedCampaign.Id);
|
||||
Assert.Null(noError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GameDtoMapper_MapsServiceContractsAndFallbacks()
|
||||
{
|
||||
var gmId = Guid.NewGuid();
|
||||
var ownerId = Guid.NewGuid();
|
||||
var blankOwnerId = Guid.NewGuid();
|
||||
var campaignId = Guid.NewGuid();
|
||||
var characterId = Guid.NewGuid();
|
||||
var skillGroupId = Guid.NewGuid();
|
||||
var skillId = Guid.NewGuid();
|
||||
var rollId = Guid.NewGuid();
|
||||
var store = new GameStateStore();
|
||||
|
||||
store.UsersById[gmId] = new()
|
||||
{
|
||||
Id = gmId,
|
||||
Username = "gm",
|
||||
UsernameNormalized = "GM",
|
||||
PasswordHash = "hash",
|
||||
DisplayName = "GM",
|
||||
Roles = UserRoles.Admin
|
||||
};
|
||||
store.UsersById[ownerId] = new()
|
||||
{
|
||||
Id = ownerId,
|
||||
Username = "owner",
|
||||
UsernameNormalized = "OWNER",
|
||||
PasswordHash = "hash",
|
||||
DisplayName = "Owner",
|
||||
Roles = string.Empty
|
||||
};
|
||||
store.UsersById[blankOwnerId] = new()
|
||||
{
|
||||
Id = blankOwnerId,
|
||||
Username = "blank",
|
||||
UsernameNormalized = "BLANK",
|
||||
PasswordHash = "hash",
|
||||
DisplayName = "",
|
||||
Roles = string.Empty
|
||||
};
|
||||
store.CampaignsById[campaignId] = new()
|
||||
{
|
||||
Id = campaignId,
|
||||
GmUserId = gmId,
|
||||
Name = "Alpha",
|
||||
Ruleset = RulesetKind.Rolemaster,
|
||||
Version = 1
|
||||
};
|
||||
store.CharactersById[characterId] = new()
|
||||
{
|
||||
Id = characterId,
|
||||
OwnerUserId = ownerId,
|
||||
CampaignId = campaignId,
|
||||
Name = "Scout"
|
||||
};
|
||||
store.SkillGroupsById[skillGroupId] = new()
|
||||
{
|
||||
Id = skillGroupId,
|
||||
CharacterId = characterId,
|
||||
Name = "Awareness",
|
||||
DiceRollDefinition = "d100!+15",
|
||||
WildDice = 0,
|
||||
AllowFumble = false,
|
||||
FumbleRange = 5
|
||||
};
|
||||
store.SkillsById[skillId] = new()
|
||||
{
|
||||
Id = skillId,
|
||||
CharacterId = characterId,
|
||||
SkillGroupId = skillGroupId,
|
||||
Name = "Perception",
|
||||
DiceRollDefinition = "d100!+25",
|
||||
WildDice = 0,
|
||||
AllowFumble = false,
|
||||
FumbleRange = 3,
|
||||
RolemasterAutoRetry = true
|
||||
};
|
||||
store.RebuildCampaignStateLocked();
|
||||
store.TouchRosterLocked(campaignId);
|
||||
store.TouchCharacterLocked(campaignId, characterId);
|
||||
store.TouchLogLocked(campaignId);
|
||||
|
||||
var dice = new[] { new RollDieResult(66, false, false, false, false, false, 1, RollDieKinds.RolemasterStandard, 66) };
|
||||
var logEntry = new RollLogEntry
|
||||
{
|
||||
Id = rollId,
|
||||
CampaignId = campaignId,
|
||||
CharacterId = characterId,
|
||||
SkillId = skillId,
|
||||
RollerUserId = ownerId,
|
||||
Visibility = RollVisibility.Private,
|
||||
Result = 91,
|
||||
Breakdown = "66+25=91",
|
||||
Dice = "[]",
|
||||
TimestampUtc = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
var userSummary = GameDtoMapper.ToUserSummary(store.UsersById[gmId]);
|
||||
var adminSummary = GameDtoMapper.ToAdminUserSummary(store.UsersById[gmId]);
|
||||
var campaignOption = GameDtoMapper.ToCampaignOption(store.CampaignsById[campaignId]);
|
||||
var campaignSummary = GameDtoMapper.ToCampaignSummary(store, store.CampaignsById[campaignId]);
|
||||
var campaignRoster = GameDtoMapper.ToCampaignRoster(store, store.CampaignsById[campaignId]);
|
||||
var characterSummary = GameDtoMapper.ToCharacterSummary(store, store.CharactersById[characterId]);
|
||||
var sheet = GameDtoMapper.ToCharacterSheet(store, characterId);
|
||||
var groupSummary = GameDtoMapper.ToSkillGroupSummary(store.SkillGroupsById[skillGroupId]);
|
||||
var skillSummary = GameDtoMapper.ToSkillSummary(store.SkillsById[skillId]);
|
||||
var rollResult = GameDtoMapper.ToRollResult(logEntry, dice);
|
||||
var logDto = GameDtoMapper.ToCampaignLogEntry(logEntry, "Scout", "Perception", "Owner", dice);
|
||||
var logListDto = GameDtoMapper.ToCampaignLogListEntry(logEntry, "Scout", "Perception", "You", "Private (you)", "private-self", "66 | rolemaster", ["r66"]);
|
||||
var detail = GameDtoMapper.ToCampaignRollDetail(logEntry, dice);
|
||||
var snapshot = GameDtoMapper.ToCampaignStateSnapshot(store, campaignId);
|
||||
|
||||
Assert.Contains(UserRoles.Admin, userSummary.Roles);
|
||||
Assert.Contains(UserRoles.Admin, adminSummary.Roles);
|
||||
Assert.Equal("Alpha", campaignOption.Name);
|
||||
Assert.Equal("rolemaster", campaignSummary.RulesetId);
|
||||
Assert.Single(campaignRoster.Characters);
|
||||
Assert.Equal("Owner", characterSummary.OwnerDisplayName);
|
||||
Assert.Single(sheet.SkillGroups);
|
||||
Assert.Single(sheet.Skills);
|
||||
Assert.Equal(5, groupSummary.FumbleRange);
|
||||
Assert.Equal(3, skillSummary.FumbleRange);
|
||||
Assert.True(skillSummary.RolemasterAutoRetry);
|
||||
Assert.Equal("private", rollResult.Visibility);
|
||||
Assert.Equal("Owner", logDto.RollerDisplayName);
|
||||
Assert.Equal("private-self", logListDto.VisibilityStyle);
|
||||
Assert.Equal(logEntry.Breakdown, detail.Breakdown);
|
||||
Assert.Equal(campaignId, snapshot.CampaignId);
|
||||
Assert.Single(snapshot.CharacterVersions);
|
||||
Assert.Equal("fallback", GameDtoMapper.ResolveOwnerDisplayName(store, blankOwnerId, "fallback"));
|
||||
Assert.Equal("fallback", GameDtoMapper.ResolveOwnerDisplayName(store, Guid.NewGuid(), "fallback"));
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,7 @@ public sealed class ServiceSkillGroupAndOwnershipTests
|
||||
var otherGroup = ServiceTestSupport.GetValue(service.CreateSkillGroup(otherSession, otherCharacter.Id, "Other Group", "2D+1", 1, true));
|
||||
Assert.False(service.UpdateSkill(ownerSession, skill.Id, "Strike", "2D+1", 1, true, otherGroup.Id).Succeeded);
|
||||
|
||||
var ungroupedSkill = ServiceTestSupport.GetValue(service.UpdateSkill(ownerSession, skill.Id, "Strike", "2D+1", 1, true, null));
|
||||
var ungroupedSkill = ServiceTestSupport.GetValue(service.UpdateSkill(ownerSession, skill.Id, "Strike", "2D+1", 1, true));
|
||||
Assert.Null(ungroupedSkill.SkillGroupId);
|
||||
|
||||
var regroupedSkill = ServiceTestSupport.GetValue(service.UpdateSkill(ownerSession, skill.Id, "Strike", "2D+1", 1, true, renamedGroup.Id));
|
||||
@@ -88,7 +88,7 @@ public sealed class ServiceSkillGroupAndOwnershipTests
|
||||
var gmTransfer = ServiceTestSupport.GetValue(service.UpdateCharacter(gmSession, character.Id, "Transferred", campaign.Id, "receiver"));
|
||||
var receiver = service.GetUserBySession(receiverSession);
|
||||
Assert.NotNull(receiver);
|
||||
Assert.Equal(receiver!.Id, gmTransfer.OwnerUserId);
|
||||
Assert.Equal(receiver.Id, gmTransfer.OwnerUserId);
|
||||
|
||||
var previousOwnerMe = ServiceTestSupport.GetValue(service.GetMe(ownerSession));
|
||||
Assert.Null(previousOwnerMe.ActiveCharacterId);
|
||||
@@ -133,7 +133,7 @@ public sealed class ServiceSkillGroupAndOwnershipTests
|
||||
|
||||
var adminTwo = service.GetUserBySession(adminTwoSession);
|
||||
Assert.NotNull(adminTwo);
|
||||
_ = ServiceTestSupport.GetValue(service.UpdateUserRoles(gmSession, adminTwo!.Id, [ "admin" ]));
|
||||
_ = ServiceTestSupport.GetValue(service.UpdateUserRoles(gmSession, adminTwo.Id, ["admin"]));
|
||||
|
||||
var adminUnlink = ServiceTestSupport.GetValue(service.UpdateCharacter(adminTwoSession, character.Id, "Admin Unlink", null));
|
||||
Assert.Null(adminUnlink.CampaignId);
|
||||
@@ -174,4 +174,62 @@ public sealed class ServiceSkillGroupAndOwnershipTests
|
||||
var campaignAfterDeletes = ServiceTestSupport.GetValue(service.GetCampaign(gmSession, campaign.Id));
|
||||
Assert.Empty(campaignAfterDeletes.Characters);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RolemasterSkillDefinitions_CanonicalizeAndKeepLegacyNegativeModifierRules()
|
||||
{
|
||||
using var harness = ServiceTestSupport.CreateHarness();
|
||||
var service = harness.Service;
|
||||
|
||||
service.Register("gm-rm", "Password123", "GM");
|
||||
service.Register("owner-rm", "Password123", "Owner");
|
||||
|
||||
var gmSession = ServiceTestSupport.GetValue(service.Login("gm-rm", "Password123")).SessionToken;
|
||||
var ownerSession = ServiceTestSupport.GetValue(service.Login("owner-rm", "Password123")).SessionToken;
|
||||
|
||||
var rolemasterCampaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Shadow World", "rolemaster"));
|
||||
var dndCampaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Forgotten Realms", "dnd5e"));
|
||||
var rolemasterCharacter = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Harn", rolemasterCampaign.Id));
|
||||
var dndCharacter = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Mage", dndCampaign.Id));
|
||||
|
||||
var negativeDndSkill = service.CreateSkill(ownerSession, dndCharacter.Id, "Invalid", "1d20-1", 0, false);
|
||||
Assert.False(negativeDndSkill.Succeeded);
|
||||
|
||||
var invalidRolemasterOptions = service.CreateSkillGroup(ownerSession, rolemasterCharacter.Id, "Invalid", "2d10-15", 3, true);
|
||||
Assert.False(invalidRolemasterOptions.Succeeded);
|
||||
|
||||
var rolemasterGroup = ServiceTestSupport.GetValue(service.CreateSkillGroup(ownerSession, rolemasterCharacter.Id, "Awareness", "d100!+15", 0, false, 5));
|
||||
Assert.Equal("d100!+15", rolemasterGroup.DiceRollDefinition);
|
||||
Assert.Equal(0, rolemasterGroup.WildDice);
|
||||
Assert.False(rolemasterGroup.AllowFumble);
|
||||
Assert.Equal(5, rolemasterGroup.FumbleRange);
|
||||
|
||||
var percentileWithFumbleRange = service.CreateSkill(ownerSession, rolemasterCharacter.Id, "Bad Percentile", "1d100-20", 0, false, null, 5);
|
||||
Assert.False(percentileWithFumbleRange.Succeeded);
|
||||
|
||||
var percentileSkill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, rolemasterCharacter.Id, "Perception", "1d100-20", 0, false, rolemasterGroup.Id));
|
||||
Assert.Equal("d100-20", percentileSkill.DiceRollDefinition);
|
||||
Assert.Equal(0, percentileSkill.WildDice);
|
||||
Assert.False(percentileSkill.AllowFumble);
|
||||
Assert.Null(percentileSkill.FumbleRange);
|
||||
|
||||
var missingOpenEndedFumbleRange = service.UpdateSkill(ownerSession, percentileSkill.Id, "Perception", "1d100!+85", 0, false, rolemasterGroup.Id);
|
||||
Assert.False(missingOpenEndedFumbleRange.Succeeded);
|
||||
|
||||
var invalidOpenEndedFumbleRange = service.UpdateSkill(ownerSession, percentileSkill.Id, "Perception", "1d100!+85", 0, false, rolemasterGroup.Id, 96);
|
||||
Assert.False(invalidOpenEndedFumbleRange.Succeeded);
|
||||
|
||||
var openEndedSkill = ServiceTestSupport.GetValue(service.UpdateSkill(ownerSession, percentileSkill.Id, "Perception", "1d100!+85", 0, false, rolemasterGroup.Id, 5));
|
||||
Assert.Equal("d100!+85", openEndedSkill.DiceRollDefinition);
|
||||
Assert.Equal(0, openEndedSkill.WildDice);
|
||||
Assert.False(openEndedSkill.AllowFumble);
|
||||
Assert.Equal(5, openEndedSkill.FumbleRange);
|
||||
|
||||
var invalidRetrySkill = service.UpdateSkill(ownerSession, percentileSkill.Id, "Perception", "d100+15", 0, false, rolemasterGroup.Id, null, true);
|
||||
Assert.False(invalidRetrySkill.Succeeded);
|
||||
Assert.Equal("invalid_rolemaster_retry", invalidRetrySkill.Error!.Code);
|
||||
|
||||
var retrySkill = ServiceTestSupport.GetValue(service.UpdateSkill(ownerSession, percentileSkill.Id, "Perception", "d100!+85", 0, false, rolemasterGroup.Id, 5, true));
|
||||
Assert.True(retrySkill.RolemasterAutoRetry);
|
||||
}
|
||||
}
|
||||
@@ -148,6 +148,44 @@ public sealed class ServiceSkillRollTests
|
||||
Assert.Equal(rollIds[^1], gapPage.Cursor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CampaignLogPage_BuildsD6AndDndSpecialEventBadges()
|
||||
{
|
||||
using var harness = ServiceTestSupport.CreateHarness(6, 4, 6, 6, 2, 20, 1);
|
||||
var service = harness.Service;
|
||||
|
||||
service.Register("gm-special", "Password123", "GM");
|
||||
service.Register("owner-special", "Password123", "Owner");
|
||||
|
||||
var gmSession = ServiceTestSupport.GetValue(service.Login("gm-special", "Password123")).SessionToken;
|
||||
var ownerSession = ServiceTestSupport.GetValue(service.Login("owner-special", "Password123")).SessionToken;
|
||||
|
||||
var d6Campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "D6 Special", "d6"));
|
||||
var d6Character = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Wild Hero", d6Campaign.Id));
|
||||
var d6Skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, d6Character.Id, "Stealth", "2D+1", 1, true));
|
||||
|
||||
_ = ServiceTestSupport.GetValue(service.RollSkill(ownerSession, d6Skill.Id, "public"));
|
||||
var d6Entry = Assert.Single(ServiceTestSupport.GetValue(service.GetCampaignLogPage(gmSession, d6Campaign.Id, limit: 5)).Entries);
|
||||
var d6Badges = Assert.IsType<string[]>(d6Entry.EventBadges);
|
||||
Assert.Equal("w6", Assert.Single(d6Badges));
|
||||
Assert.Equal("6, 4, 6, 6, 2", d6Entry.SummaryText);
|
||||
|
||||
var dndCampaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Dnd Special", "dnd5e"));
|
||||
var dndCharacter = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Natural Hero", dndCampaign.Id));
|
||||
var dndSkill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, dndCharacter.Id, "Attack", "1d20+5", 0, false));
|
||||
|
||||
_ = ServiceTestSupport.GetValue(service.RollSkill(ownerSession, dndSkill.Id, "public"));
|
||||
_ = ServiceTestSupport.GetValue(service.RollSkill(ownerSession, dndSkill.Id, "public"));
|
||||
var dndEntries = ServiceTestSupport.GetValue(service.GetCampaignLogPage(gmSession, dndCampaign.Id, limit: 5)).Entries;
|
||||
|
||||
var firstDndBadges = Assert.IsType<string[]>(dndEntries[0].EventBadges);
|
||||
Assert.Equal("n20", Assert.Single(firstDndBadges));
|
||||
Assert.Equal("20", dndEntries[0].SummaryText);
|
||||
var secondDndBadges = Assert.IsType<string[]>(dndEntries[1].EventBadges);
|
||||
Assert.Equal("n1", Assert.Single(secondDndBadges));
|
||||
Assert.Equal("1", dndEntries[1].SummaryText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RollDetail_ReturnsVisibleDetailAndHidesPrivateRoll()
|
||||
{
|
||||
@@ -187,4 +225,43 @@ public sealed class ServiceSkillRollTests
|
||||
Assert.False(outsiderPublicDetail.Succeeded);
|
||||
Assert.Equal("roll_not_found", outsiderPublicDetail.Error!.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CustomRoll_UsesCampaignRuleset_AndAppearsAsCustomRollInLog()
|
||||
{
|
||||
using var harness = ServiceTestSupport.CreateHarness(20);
|
||||
var service = harness.Service;
|
||||
|
||||
service.Register("gm-custom", "Password123", "GM");
|
||||
service.Register("owner-custom", "Password123", "Owner");
|
||||
service.Register("other-custom", "Password123", "Other");
|
||||
|
||||
var gmSession = ServiceTestSupport.GetValue(service.Login("gm-custom", "Password123")).SessionToken;
|
||||
var ownerSession = ServiceTestSupport.GetValue(service.Login("owner-custom", "Password123")).SessionToken;
|
||||
var otherSession = ServiceTestSupport.GetValue(service.Login("other-custom", "Password123")).SessionToken;
|
||||
|
||||
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Custom", "dnd5e"));
|
||||
var character = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Hero", campaign.Id));
|
||||
|
||||
var invalidExpression = service.RollCustom(ownerSession, character.Id, "bad", "public");
|
||||
Assert.False(invalidExpression.Succeeded);
|
||||
Assert.Equal("invalid_expression", invalidExpression.Error!.Code);
|
||||
|
||||
var forbiddenRoll = service.RollCustom(otherSession, character.Id, "1d20+5", "public");
|
||||
Assert.False(forbiddenRoll.Succeeded);
|
||||
Assert.Equal("forbidden", forbiddenRoll.Error!.Code);
|
||||
|
||||
var customRoll = ServiceTestSupport.GetValue(service.RollCustom(ownerSession, character.Id, "1d20+5", "private"));
|
||||
Assert.Equal(Guid.Empty, customRoll.SkillId);
|
||||
Assert.StartsWith("1d20+5 => ", customRoll.Breakdown, StringComparison.Ordinal);
|
||||
|
||||
var logPage = ServiceTestSupport.GetValue(service.GetCampaignLogPage(gmSession, campaign.Id, limit: 5));
|
||||
var entry = Assert.Single(logPage.Entries);
|
||||
Assert.Equal("Custom roll", entry.SkillName);
|
||||
Assert.Equal("Private (GM view)", entry.VisibilityLabel);
|
||||
Assert.Contains("n20", Assert.IsType<string[]>(entry.EventBadges));
|
||||
|
||||
var log = ServiceTestSupport.GetValue(service.GetCampaignLog(ownerSession, campaign.Id));
|
||||
Assert.Equal("Custom roll", Assert.Single(log).SkillName);
|
||||
}
|
||||
}
|
||||
99
RpgRoller.Tests/Services/ServiceStateInfrastructureTests.cs
Normal file
99
RpgRoller.Tests/Services/ServiceStateInfrastructureTests.cs
Normal file
@@ -0,0 +1,99 @@
|
||||
namespace RpgRoller.Tests;
|
||||
|
||||
public sealed class ServiceStateInfrastructureTests
|
||||
{
|
||||
[Fact]
|
||||
public void GameStateStore_StartsWithEmptyMutableCollections()
|
||||
{
|
||||
var store = new GameStateStore();
|
||||
|
||||
Assert.NotNull(store.Gate);
|
||||
Assert.Empty(store.UsersById);
|
||||
Assert.Empty(store.UserIdsByUsername);
|
||||
Assert.Empty(store.SessionsByToken);
|
||||
Assert.Empty(store.CampaignsById);
|
||||
Assert.Empty(store.CampaignStateById);
|
||||
Assert.Empty(store.CharactersById);
|
||||
Assert.Empty(store.SkillGroupsById);
|
||||
Assert.Empty(store.SkillsById);
|
||||
Assert.Empty(store.RollLog);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GameStateCloneFactory_ProducesDetachedCopies()
|
||||
{
|
||||
var user = new UserAccount
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Username = "user",
|
||||
UsernameNormalized = "USER",
|
||||
PasswordHash = "hash",
|
||||
DisplayName = "User",
|
||||
Roles = "admin",
|
||||
ActiveCharacterId = Guid.NewGuid()
|
||||
};
|
||||
var session = new UserSession
|
||||
{
|
||||
Token = "token",
|
||||
UserId = user.Id,
|
||||
CreatedAtUtc = DateTimeOffset.UtcNow
|
||||
};
|
||||
var campaign = new Campaign
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
GmUserId = user.Id,
|
||||
Name = "Main",
|
||||
Ruleset = RulesetKind.D6,
|
||||
Version = 3
|
||||
};
|
||||
var character = new Character
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OwnerUserId = user.Id,
|
||||
CampaignId = campaign.Id,
|
||||
Name = "Hero"
|
||||
};
|
||||
var skillGroup = new SkillGroup
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CharacterId = character.Id,
|
||||
Name = "Group",
|
||||
DiceRollDefinition = "2D+1",
|
||||
WildDice = 1,
|
||||
AllowFumble = true,
|
||||
FumbleRange = null
|
||||
};
|
||||
var skill = new Skill
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CharacterId = character.Id,
|
||||
SkillGroupId = skillGroup.Id,
|
||||
Name = "Skill",
|
||||
DiceRollDefinition = "2D+2",
|
||||
WildDice = 1,
|
||||
AllowFumble = true,
|
||||
FumbleRange = null
|
||||
};
|
||||
var logEntry = new RollLogEntry
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CampaignId = campaign.Id,
|
||||
CharacterId = character.Id,
|
||||
SkillId = skill.Id,
|
||||
RollerUserId = user.Id,
|
||||
Visibility = RollVisibility.Public,
|
||||
Result = 12,
|
||||
Breakdown = "6 + 6 = 12",
|
||||
Dice = "[]",
|
||||
TimestampUtc = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
Assert.NotSame(user, GameStateCloneFactory.CloneUser(user));
|
||||
Assert.NotSame(session, GameStateCloneFactory.CloneSession(session));
|
||||
Assert.NotSame(campaign, GameStateCloneFactory.CloneCampaign(campaign));
|
||||
Assert.NotSame(character, GameStateCloneFactory.CloneCharacter(character));
|
||||
Assert.NotSame(skillGroup, GameStateCloneFactory.CloneSkillGroup(skillGroup));
|
||||
Assert.NotSame(skill, GameStateCloneFactory.CloneSkill(skill));
|
||||
Assert.NotSame(logEntry, GameStateCloneFactory.CloneRollLogEntry(logEntry));
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,182 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using RpgRoller.Components;
|
||||
using RpgRoller.Contracts;
|
||||
using RpgRoller.Services;
|
||||
|
||||
namespace RpgRoller.Tests;
|
||||
|
||||
public sealed class WorkspaceQueryServiceTests
|
||||
{
|
||||
private sealed class StubGameService : IGameService
|
||||
{
|
||||
public IReadOnlyList<RulesetDefinition> GetRulesets()
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public ServiceResult<UserSummary> Register(string username, string password, string displayName)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public ServiceResult<(UserSummary User, string SessionToken)> Login(string username, string password)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public void Logout(string sessionToken)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public UserSummary? GetUserBySession(string sessionToken)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public ServiceResult<MeResponse> GetMe(string sessionToken)
|
||||
{
|
||||
return GetMeHandler(sessionToken);
|
||||
}
|
||||
|
||||
public ServiceResult<CampaignSummary> CreateCampaign(string sessionToken, string name, string rulesetId)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public ServiceResult<IReadOnlyList<CampaignSummary>> GetCampaigns(string sessionToken)
|
||||
{
|
||||
return GetCampaignsHandler(sessionToken);
|
||||
}
|
||||
|
||||
public ServiceResult<IReadOnlyList<CampaignOption>> GetCharacterCampaignOptions(string sessionToken)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public ServiceResult<CampaignRoster> GetCampaign(string sessionToken, Guid campaignId)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public ServiceResult<bool> DeleteCampaign(string sessionToken, Guid campaignId)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public ServiceResult<IReadOnlyList<string>> GetUsernames(string sessionToken)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public ServiceResult<IReadOnlyList<AdminUserSummary>> GetUsers(string sessionToken)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public ServiceResult<AdminUserSummary> UpdateUserRoles(string sessionToken, Guid userId, IReadOnlyList<string> roles)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public ServiceResult<bool> DeleteUser(string sessionToken, Guid userId)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public ServiceResult<CharacterSummary> CreateCharacter(string sessionToken, string name, Guid campaignId)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public ServiceResult<CharacterSummary> UpdateCharacter(string sessionToken, Guid characterId, string name, Guid? campaignId, string? ownerUsername = null)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public ServiceResult<bool> DeleteCharacter(string sessionToken, Guid characterId)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public ServiceResult<bool> ActivateCharacter(string sessionToken, Guid characterId)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public ServiceResult<IReadOnlyList<CharacterSummary>> GetOwnCharacters(string sessionToken)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public ServiceResult<SkillGroupSummary> CreateSkillGroup(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, int? fumbleRange = null)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public ServiceResult<SkillGroupSummary> UpdateSkillGroup(string sessionToken, Guid skillGroupId, string name, string diceRollDefinition, int wildDice, bool allowFumble, int? fumbleRange = null)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public ServiceResult<bool> DeleteSkillGroup(string sessionToken, Guid skillGroupId)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null, bool rolemasterAutoRetry = false)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null, bool rolemasterAutoRetry = false)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public ServiceResult<bool> DeleteSkill(string sessionToken, Guid skillId)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public ServiceResult<CharacterSheet> GetCharacterSheet(string sessionToken, Guid characterId)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility, int situationalModifier = 0)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public ServiceResult<RollResult> RollCustom(string sessionToken, Guid characterId, string expression, string visibility)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public ServiceResult<IReadOnlyList<CampaignLogEntry>> GetCampaignLog(string sessionToken, Guid campaignId)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public ServiceResult<CampaignLogPage> GetCampaignLogPage(string sessionToken, Guid campaignId, Guid? afterRollId = null, int? limit = null)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public ServiceResult<CampaignRollDetail> GetRollDetail(string sessionToken, Guid rollId)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public ServiceResult<CampaignStateSnapshot> GetCampaignStateSnapshot(string sessionToken, Guid campaignId)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public Func<string, ServiceResult<MeResponse>> GetMeHandler { get; init; } = _ => ServiceResult<MeResponse>.Failure("unexpected_call", "Unexpected GetMe call.");
|
||||
|
||||
public Func<string, ServiceResult<IReadOnlyList<CampaignSummary>>> GetCampaignsHandler { get; init; } = _ => ServiceResult<IReadOnlyList<CampaignSummary>>.Failure("unexpected_call", "Unexpected GetCampaigns call.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SessionTokenAccessor_ReadsSessionCookieFromHttpContext()
|
||||
{
|
||||
@@ -27,7 +197,7 @@ public sealed class WorkspaceQueryServiceTests
|
||||
GetCampaignsHandler = sessionToken =>
|
||||
{
|
||||
Assert.Equal("server-session", sessionToken);
|
||||
return ServiceResult<IReadOnlyList<CampaignSummary>>.Success([new CampaignSummary(Guid.NewGuid(), "Alpha", "d6", new CampaignGmSummary(Guid.NewGuid(), "GM"), 1)]);
|
||||
return ServiceResult<IReadOnlyList<CampaignSummary>>.Success([new(Guid.NewGuid(), "Alpha", "d6", new(Guid.NewGuid(), "GM"), 1)]);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -40,64 +210,20 @@ public sealed class WorkspaceQueryServiceTests
|
||||
[Fact]
|
||||
public async Task GetMeAsync_MapsUnauthorizedServiceResultToApiRequestException()
|
||||
{
|
||||
var service = new StubGameService
|
||||
{
|
||||
GetMeHandler = _ => ServiceResult<MeResponse>.Failure("unauthorized", "You must be logged in.")
|
||||
};
|
||||
var service = new StubGameService { GetMeHandler = _ => ServiceResult<MeResponse>.Failure("unauthorized", "You must be logged in.") };
|
||||
|
||||
var queryService = new WorkspaceQueryService(service, CreateSessionTokenAccessor("expired-session"));
|
||||
var exception = await Assert.ThrowsAsync<ApiRequestException>(queryService.GetMeAsync);
|
||||
|
||||
Assert.Equal(401, exception.StatusCode);
|
||||
Assert.Equal("You must be logged in.", exception.Message);
|
||||
Assert.Equal("unauthorized", exception.ErrorCode);
|
||||
}
|
||||
|
||||
private static WorkspaceSessionTokenAccessor CreateSessionTokenAccessor(string sessionToken)
|
||||
{
|
||||
var httpContext = new DefaultHttpContext();
|
||||
httpContext.Request.Headers.Cookie = $"rpgroller_session={sessionToken}";
|
||||
return new WorkspaceSessionTokenAccessor(new HttpContextAccessor { HttpContext = httpContext });
|
||||
}
|
||||
|
||||
private sealed class StubGameService : IGameService
|
||||
{
|
||||
public Func<string, ServiceResult<MeResponse>> GetMeHandler { get; init; } =
|
||||
_ => ServiceResult<MeResponse>.Failure("unexpected_call", "Unexpected GetMe call.");
|
||||
|
||||
public Func<string, ServiceResult<IReadOnlyList<CampaignSummary>>> GetCampaignsHandler { get; init; } =
|
||||
_ => ServiceResult<IReadOnlyList<CampaignSummary>>.Failure("unexpected_call", "Unexpected GetCampaigns call.");
|
||||
|
||||
public IReadOnlyList<RulesetDefinition> GetRulesets() => throw new NotSupportedException();
|
||||
public ServiceResult<UserSummary> Register(string username, string password, string displayName) => throw new NotSupportedException();
|
||||
public ServiceResult<(UserSummary User, string SessionToken)> Login(string username, string password) => throw new NotSupportedException();
|
||||
public void Logout(string sessionToken) => throw new NotSupportedException();
|
||||
public UserSummary? GetUserBySession(string sessionToken) => throw new NotSupportedException();
|
||||
public ServiceResult<MeResponse> GetMe(string sessionToken) => GetMeHandler(sessionToken);
|
||||
public ServiceResult<CampaignSummary> CreateCampaign(string sessionToken, string name, string rulesetId) => throw new NotSupportedException();
|
||||
public ServiceResult<IReadOnlyList<CampaignSummary>> GetCampaigns(string sessionToken) => GetCampaignsHandler(sessionToken);
|
||||
public ServiceResult<IReadOnlyList<CampaignOption>> GetCharacterCampaignOptions(string sessionToken) => throw new NotSupportedException();
|
||||
public ServiceResult<CampaignRoster> GetCampaign(string sessionToken, Guid campaignId) => throw new NotSupportedException();
|
||||
public ServiceResult<bool> DeleteCampaign(string sessionToken, Guid campaignId) => throw new NotSupportedException();
|
||||
public ServiceResult<IReadOnlyList<string>> GetUsernames(string sessionToken) => throw new NotSupportedException();
|
||||
public ServiceResult<IReadOnlyList<AdminUserSummary>> GetUsers(string sessionToken) => throw new NotSupportedException();
|
||||
public ServiceResult<AdminUserSummary> UpdateUserRoles(string sessionToken, Guid userId, IReadOnlyList<string> roles) => throw new NotSupportedException();
|
||||
public ServiceResult<bool> DeleteUser(string sessionToken, Guid userId) => throw new NotSupportedException();
|
||||
public ServiceResult<CharacterSummary> CreateCharacter(string sessionToken, string name, Guid campaignId) => throw new NotSupportedException();
|
||||
public ServiceResult<CharacterSummary> UpdateCharacter(string sessionToken, Guid characterId, string name, Guid? campaignId, string? ownerUsername = null) => throw new NotSupportedException();
|
||||
public ServiceResult<bool> DeleteCharacter(string sessionToken, Guid characterId) => throw new NotSupportedException();
|
||||
public ServiceResult<bool> ActivateCharacter(string sessionToken, Guid characterId) => throw new NotSupportedException();
|
||||
public ServiceResult<IReadOnlyList<CharacterSummary>> GetOwnCharacters(string sessionToken) => throw new NotSupportedException();
|
||||
public ServiceResult<SkillGroupSummary> CreateSkillGroup(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble) => throw new NotSupportedException();
|
||||
public ServiceResult<SkillGroupSummary> UpdateSkillGroup(string sessionToken, Guid skillGroupId, string name, string diceRollDefinition, int wildDice, bool allowFumble) => throw new NotSupportedException();
|
||||
public ServiceResult<bool> DeleteSkillGroup(string sessionToken, Guid skillGroupId) => throw new NotSupportedException();
|
||||
public ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null) => throw new NotSupportedException();
|
||||
public ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null) => throw new NotSupportedException();
|
||||
public ServiceResult<bool> DeleteSkill(string sessionToken, Guid skillId) => throw new NotSupportedException();
|
||||
public ServiceResult<CharacterSheet> GetCharacterSheet(string sessionToken, Guid characterId) => throw new NotSupportedException();
|
||||
public ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility) => throw new NotSupportedException();
|
||||
public ServiceResult<IReadOnlyList<CampaignLogEntry>> GetCampaignLog(string sessionToken, Guid campaignId) => throw new NotSupportedException();
|
||||
public ServiceResult<CampaignLogPage> GetCampaignLogPage(string sessionToken, Guid campaignId, Guid? afterRollId = null, int? limit = null) => throw new NotSupportedException();
|
||||
public ServiceResult<CampaignRollDetail> GetRollDetail(string sessionToken, Guid rollId) => throw new NotSupportedException();
|
||||
public ServiceResult<CampaignStateSnapshot> GetCampaignStateSnapshot(string sessionToken, Guid campaignId) => throw new NotSupportedException();
|
||||
return new(new HttpContextAccessor { HttpContext = httpContext });
|
||||
}
|
||||
}
|
||||
101
RpgRoller.Tests/Services/WorkspaceStateTests.cs
Normal file
101
RpgRoller.Tests/Services/WorkspaceStateTests.cs
Normal file
@@ -0,0 +1,101 @@
|
||||
using RpgRoller.Components.Pages;
|
||||
|
||||
namespace RpgRoller.Tests;
|
||||
|
||||
public sealed class WorkspaceStateTests
|
||||
{
|
||||
[Fact]
|
||||
public void OwnerLabel_ResolvesCurrentUserGmAndFallbacks()
|
||||
{
|
||||
var gmId = Guid.NewGuid();
|
||||
var userId = Guid.NewGuid();
|
||||
var otherOwnerId = Guid.NewGuid();
|
||||
var state = new WorkspaceState
|
||||
{
|
||||
User = new(userId, "user", "User", []),
|
||||
SelectedCampaign = new(Guid.NewGuid(), "Alpha", "d6", new(gmId, "GM"), [
|
||||
new(Guid.NewGuid(), "Scout", otherOwnerId, Guid.NewGuid(), "Other Owner")
|
||||
])
|
||||
};
|
||||
|
||||
Assert.Equal("You", state.OwnerLabel(userId));
|
||||
Assert.Equal("GM (GM)", state.OwnerLabel(gmId));
|
||||
Assert.Equal("Other Owner", state.OwnerLabel(otherOwnerId));
|
||||
Assert.Equal("Unknown owner", state.OwnerLabel(Guid.NewGuid()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SkillDefinitionLabel_FormatsD6RolemasterAndDefaultRulesets()
|
||||
{
|
||||
var skill = new CharacterSheetSkill(Guid.NewGuid(), null, "Awareness", "d100!+15", 1, true, 5, true);
|
||||
var state = new WorkspaceState { SelectedCampaign = new(Guid.NewGuid(), "Alpha", "d6", new(Guid.NewGuid(), "GM"), []) };
|
||||
|
||||
Assert.Equal("d100!+15, wild 1, fumble on", state.SkillDefinitionLabel(skill));
|
||||
|
||||
state.SelectedCampaign = new(Guid.NewGuid(), "Alpha", "rolemaster", new(Guid.NewGuid(), "GM"), []);
|
||||
Assert.Equal("Open-ended percentile: d100!+15, fumble <= 5, auto retry", state.SkillDefinitionLabel(skill));
|
||||
|
||||
state.SelectedCampaign = new(Guid.NewGuid(), "Alpha", "dnd5e", new(Guid.NewGuid(), "GM"), []);
|
||||
Assert.Equal("d100!+15", state.SkillDefinitionLabel(skill));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlaySelections_FilterToOwnedCharactersAndPreferSelectedThenActive()
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
var ownedCharacter = new CharacterSummary(Guid.NewGuid(), "Owned", userId, Guid.NewGuid(), "User");
|
||||
var secondOwnedCharacter = new CharacterSummary(Guid.NewGuid(), "Owned Two", userId, Guid.NewGuid(), "User");
|
||||
var otherCharacter = new CharacterSummary(Guid.NewGuid(), "Other", Guid.NewGuid(), Guid.NewGuid(), "Other");
|
||||
var state = new WorkspaceState
|
||||
{
|
||||
User = new(userId, "user", "User", []),
|
||||
SelectedCampaign = new(Guid.NewGuid(), "Alpha", "d6", new(Guid.NewGuid(), "GM"), [ownedCharacter, secondOwnedCharacter, otherCharacter]),
|
||||
SelectedCharacterId = secondOwnedCharacter.Id,
|
||||
ActiveCharacterId = ownedCharacter.Id,
|
||||
SelectedCharacterSkills = [new(Guid.NewGuid(), null, "Stealth", "2D+1", 1, true, null, false)],
|
||||
SelectedCharacterSkillGroups = [new(Guid.NewGuid(), "Combat", "2D+1", 1, true, null)]
|
||||
};
|
||||
|
||||
Assert.Equal(2, state.PlaySelectedCampaign!.Characters.Length);
|
||||
Assert.Equal(secondOwnedCharacter.Id, state.PlaySelectedCharacterId);
|
||||
Assert.Single(state.PlaySelectedCharacterSkills);
|
||||
Assert.Single(state.PlaySelectedCharacterSkillGroups);
|
||||
|
||||
state.SelectedCharacterId = Guid.NewGuid();
|
||||
Assert.Equal(ownedCharacter.Id, state.PlaySelectedCharacterId);
|
||||
|
||||
state.ActiveCharacterId = Guid.NewGuid();
|
||||
Assert.Equal(ownedCharacter.Id, state.PlaySelectedCharacterId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScreenAndConnectionFlags_ReflectCurrentState()
|
||||
{
|
||||
var adminId = Guid.NewGuid();
|
||||
var state = new WorkspaceState
|
||||
{
|
||||
User = new(adminId, "admin", "Admin", [UserRoles.Admin]),
|
||||
SelectedCampaign = new(Guid.NewGuid(), "Alpha", "d6", new(adminId, "Admin"), []),
|
||||
CurrentScreen = "admin",
|
||||
ConnectionState = "reconnecting"
|
||||
};
|
||||
|
||||
Assert.True(state.IsAdminScreen);
|
||||
Assert.False(state.IsPlayScreen);
|
||||
Assert.True(state.IsCurrentUserAdmin);
|
||||
Assert.True(state.IsCurrentUserGm);
|
||||
Assert.True(state.CanDeleteSelectedCampaign);
|
||||
Assert.True(state.IsSelectedCampaignD6);
|
||||
Assert.Equal("Reconnecting", state.ConnectionStateLabel);
|
||||
Assert.Equal("warn", state.ConnectionStateCssClass);
|
||||
Assert.Equal("rr-app", state.AppCssClass);
|
||||
|
||||
state.CurrentScreen = "play";
|
||||
state.ConnectionState = "connected";
|
||||
|
||||
Assert.True(state.IsPlayScreen);
|
||||
Assert.Equal("Connected", state.ConnectionStateLabel);
|
||||
Assert.Equal("ok", state.ConnectionStateCssClass);
|
||||
Assert.Equal("rr-app app-play", state.AppCssClass);
|
||||
}
|
||||
}
|
||||
@@ -1,37 +1,37 @@
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using RpgRoller.Data;
|
||||
using RpgRoller.Hosting;
|
||||
|
||||
namespace RpgRoller.Tests;
|
||||
|
||||
public abstract class ApiTestBase : IClassFixture<WebApplicationFactory<Program>>
|
||||
public abstract class ApiTestBase(WebApplicationFactory<Program> factory) : IClassFixture<WebApplicationFactory<Program>>
|
||||
{
|
||||
private sealed class FixedDiceRoller : IDiceRoller
|
||||
private sealed class FixedDiceRoller(IEnumerable<int> values) : IDiceRoller
|
||||
{
|
||||
public FixedDiceRoller(IEnumerable<int> values)
|
||||
{
|
||||
m_Values = new(values);
|
||||
}
|
||||
|
||||
public int Roll(int sides)
|
||||
{
|
||||
var next = m_Values.Count > 0 ? m_Values.Dequeue() : 1;
|
||||
return Math.Clamp(next, 1, sides);
|
||||
}
|
||||
|
||||
private readonly Queue<int> m_Values;
|
||||
}
|
||||
|
||||
protected ApiTestBase(WebApplicationFactory<Program> factory)
|
||||
{
|
||||
m_BaseFactory = factory;
|
||||
private readonly Queue<int> m_Values = new(values);
|
||||
}
|
||||
|
||||
protected WebApplicationFactory<Program> CreateFactory(params int[] rollValues)
|
||||
{
|
||||
return m_BaseFactory.WithWebHostBuilder(builder => builder.ConfigureServices(services =>
|
||||
return factory.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureLogging(logging =>
|
||||
{
|
||||
logging.AddFilter("Microsoft.AspNetCore", LogLevel.Warning);
|
||||
logging.AddFilter("Microsoft.EntityFrameworkCore", LogLevel.Warning);
|
||||
logging.AddFilter("Microsoft.Hosting", LogLevel.Warning);
|
||||
});
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.RemoveAll<IDiceRoller>();
|
||||
services.AddSingleton<IDiceRoller>(new FixedDiceRoller(rollValues));
|
||||
@@ -44,7 +44,8 @@ public abstract class ApiTestBase : IClassFixture<WebApplicationFactory<Program>
|
||||
var dbPath = Path.Combine(Path.GetTempPath(), $"rpgroller-tests-{Guid.NewGuid():N}.db");
|
||||
services.AddSingleton(new SqliteDatabaseFile(dbPath));
|
||||
services.AddDbContextFactory<RpgRollerDbContext>(options => options.UseSqlite($"Data Source={dbPath}"));
|
||||
}));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
protected static async Task<UserSummary> RegisterAsync(HttpClient client, string username, string password, string displayName)
|
||||
@@ -84,6 +85,4 @@ public abstract class ApiTestBase : IClassFixture<WebApplicationFactory<Program>
|
||||
Assert.NotNull(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
private readonly WebApplicationFactory<Program> m_BaseFactory;
|
||||
}
|
||||
@@ -46,29 +46,19 @@ internal static class ServiceTestSupport
|
||||
public int HashCalls { get; private set; }
|
||||
}
|
||||
|
||||
private sealed class FixedDiceRoller : IDiceRoller
|
||||
private sealed class FixedDiceRoller(IEnumerable<int> values) : IDiceRoller
|
||||
{
|
||||
public FixedDiceRoller(IEnumerable<int> values)
|
||||
{
|
||||
m_Values = new(values);
|
||||
}
|
||||
|
||||
public int Roll(int sides)
|
||||
{
|
||||
var next = m_Values.Count > 0 ? m_Values.Dequeue() : 1;
|
||||
return Math.Clamp(next, 1, sides);
|
||||
}
|
||||
|
||||
private readonly Queue<int> m_Values;
|
||||
private readonly Queue<int> m_Values = new(values);
|
||||
}
|
||||
|
||||
internal sealed class SqliteDbContextFactory : IDbContextFactory<RpgRollerDbContext>, IDisposable
|
||||
internal sealed class SqliteDbContextFactory(string dbPath) : IDbContextFactory<RpgRollerDbContext>, IDisposable
|
||||
{
|
||||
public SqliteDbContextFactory(string dbPath)
|
||||
{
|
||||
m_Options = new DbContextOptionsBuilder<RpgRollerDbContext>().UseSqlite($"Data Source={dbPath}").Options;
|
||||
}
|
||||
|
||||
public RpgRollerDbContext CreateDbContext()
|
||||
{
|
||||
return new(m_Options);
|
||||
@@ -78,7 +68,7 @@ internal static class ServiceTestSupport
|
||||
{
|
||||
}
|
||||
|
||||
private readonly DbContextOptions<RpgRollerDbContext> m_Options;
|
||||
private readonly DbContextOptions<RpgRollerDbContext> m_Options = new DbContextOptionsBuilder<RpgRollerDbContext>().UseSqlite($"Data Source={dbPath}").Options;
|
||||
}
|
||||
|
||||
internal static ServiceHarness CreateHarness(params int[] rollValues)
|
||||
|
||||
@@ -36,10 +36,10 @@ internal static class AdminEndpoints
|
||||
return TypedResults.Unauthorized();
|
||||
|
||||
if (!user.Roles.Contains(UserRoles.Admin, StringComparer.OrdinalIgnoreCase))
|
||||
return ApiResultMapper.ToBadRequest(new ServiceError("forbidden", "Admin role is required."));
|
||||
return ApiResultMapper.ToBadRequest(new("forbidden", "Admin role is required."));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(databaseFile.Path) || !File.Exists(databaseFile.Path))
|
||||
return ApiResultMapper.ToBadRequest(new ServiceError("database_unavailable", "SQLite database file is not available."));
|
||||
return ApiResultMapper.ToBadRequest(new("database_unavailable", "SQLite database file is not available."));
|
||||
|
||||
var stream = new FileStream(databaseFile.Path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||
return TypedResults.File(stream, "application/octet-stream", Path.GetFileName(databaseFile.Path));
|
||||
|
||||
@@ -14,11 +14,11 @@ internal static class ApiResultMapper
|
||||
if (result.Error!.Code == "unauthorized")
|
||||
return TypedResults.Unauthorized();
|
||||
|
||||
return TypedResults.BadRequest(new ApiError(result.Error.Message));
|
||||
return TypedResults.BadRequest(new ApiError(result.Error.Message, result.Error.Code));
|
||||
}
|
||||
|
||||
public static BadRequest<ApiError> ToBadRequest(ServiceError error)
|
||||
{
|
||||
return TypedResults.BadRequest(new ApiError(error.Message));
|
||||
return TypedResults.BadRequest(new ApiError(error.Message, error.Code));
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
using RpgRoller.Contracts;
|
||||
using RpgRoller.Services;
|
||||
|
||||
namespace RpgRoller.Api;
|
||||
|
||||
@@ -9,13 +9,13 @@ internal static class SkillEndpoints
|
||||
{
|
||||
group.MapPost("/characters/{characterId:guid}/skills", (Guid characterId, CreateSkillRequest request, HttpContext context, IGameService game) =>
|
||||
{
|
||||
var result = game.CreateSkill(context.GetRequiredSessionToken(), characterId, request.Name, request.DiceRollDefinition, request.WildDice, request.AllowFumble, request.SkillGroupId);
|
||||
var result = game.CreateSkill(context.GetRequiredSessionToken(), characterId, request.Name, request.DiceRollDefinition, request.WildDice, request.AllowFumble, request.SkillGroupId, request.FumbleRange, request.RolemasterAutoRetry);
|
||||
return ApiResultMapper.ToApiResult(result);
|
||||
});
|
||||
|
||||
group.MapPut("/skills/{skillId:guid}", (Guid skillId, UpdateSkillRequest request, HttpContext context, IGameService game) =>
|
||||
{
|
||||
var result = game.UpdateSkill(context.GetRequiredSessionToken(), skillId, request.Name, request.DiceRollDefinition, request.WildDice, request.AllowFumble, request.SkillGroupId);
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -27,13 +27,13 @@ internal static class SkillEndpoints
|
||||
|
||||
group.MapPost("/characters/{characterId:guid}/skill-groups", (Guid characterId, CreateSkillGroupRequest request, HttpContext context, IGameService game) =>
|
||||
{
|
||||
var result = game.CreateSkillGroup(context.GetRequiredSessionToken(), characterId, request.Name, request.DiceRollDefinition, request.WildDice, request.AllowFumble);
|
||||
var result = game.CreateSkillGroup(context.GetRequiredSessionToken(), characterId, request.Name, request.DiceRollDefinition, request.WildDice, request.AllowFumble, request.FumbleRange);
|
||||
return ApiResultMapper.ToApiResult(result);
|
||||
});
|
||||
|
||||
group.MapPut("/skill-groups/{skillGroupId:guid}", (Guid skillGroupId, UpdateSkillGroupRequest request, HttpContext context, IGameService game) =>
|
||||
{
|
||||
var result = game.UpdateSkillGroup(context.GetRequiredSessionToken(), skillGroupId, request.Name, request.DiceRollDefinition, request.WildDice, request.AllowFumble);
|
||||
var result = game.UpdateSkillGroup(context.GetRequiredSessionToken(), skillGroupId, request.Name, request.DiceRollDefinition, request.WildDice, request.AllowFumble, request.FumbleRange);
|
||||
return ApiResultMapper.ToApiResult(result);
|
||||
});
|
||||
|
||||
@@ -45,7 +45,13 @@ internal static class SkillEndpoints
|
||||
|
||||
group.MapPost("/skills/{skillId:guid}/roll", (Guid skillId, RollSkillRequest request, HttpContext context, IGameService game) =>
|
||||
{
|
||||
var result = game.RollSkill(context.GetRequiredSessionToken(), skillId, request.Visibility);
|
||||
var result = game.RollSkill(context.GetRequiredSessionToken(), skillId, request.Visibility, request.SituationalModifier);
|
||||
return ApiResultMapper.ToApiResult(result);
|
||||
});
|
||||
|
||||
group.MapPost("/characters/{characterId:guid}/custom-rolls", (Guid characterId, CustomRollRequest request, HttpContext context, IGameService game) =>
|
||||
{
|
||||
var result = game.RollCustom(context.GetRequiredSessionToken(), characterId, request.Expression, request.Visibility);
|
||||
return ApiResultMapper.ToApiResult(result);
|
||||
});
|
||||
|
||||
|
||||
@@ -12,9 +12,7 @@ internal static class StateEventEndpoints
|
||||
var sessionToken = context.GetRequiredSessionToken();
|
||||
var stateResult = game.GetCampaignStateSnapshot(sessionToken, campaignId);
|
||||
if (!stateResult.Succeeded)
|
||||
{
|
||||
return stateResult.Error!.Code == "unauthorized" ? TypedResults.Unauthorized() : TypedResults.BadRequest(new ApiError(stateResult.Error.Message));
|
||||
}
|
||||
return stateResult.Error!.Code == "unauthorized" ? TypedResults.Unauthorized() : TypedResults.BadRequest(new ApiError(stateResult.Error.Message, stateResult.Error.Code));
|
||||
|
||||
context.Response.Headers.CacheControl = "no-cache";
|
||||
context.Response.Headers.Connection = "keep-alive";
|
||||
@@ -58,11 +56,8 @@ internal static class StateEventEndpoints
|
||||
|
||||
private static Task WriteStateEventAsync(HttpResponse response, CampaignStateSnapshot snapshot)
|
||||
{
|
||||
var characterVersions = string.Join(
|
||||
",",
|
||||
snapshot.CharacterVersions.Select(version => $"{{\"characterId\":\"{version.CharacterId}\",\"version\":{version.Version}}}"));
|
||||
var characterVersions = string.Join(",", snapshot.CharacterVersions.Select(version => $"{{\"characterId\":\"{version.CharacterId}\",\"version\":{version.Version}}}"));
|
||||
|
||||
return response.WriteAsync(
|
||||
$"event: state\ndata: {{\"campaignId\":\"{snapshot.CampaignId}\",\"totalVersion\":{snapshot.TotalVersion},\"rosterVersion\":{snapshot.RosterVersion},\"logVersion\":{snapshot.LogVersion},\"characterVersions\":[{characterVersions}]}}\n\n");
|
||||
return response.WriteAsync($"event: state\ndata: {{\"campaignId\":\"{snapshot.CampaignId}\",\"totalVersion\":{snapshot.TotalVersion},\"rosterVersion\":{snapshot.RosterVersion},\"logVersion\":{snapshot.LogVersion},\"characterVersions\":[{characterVersions}]}}\n\n");
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<base href="@BaseHref"/>
|
||||
<title>RpgRoller</title>
|
||||
<link rel="stylesheet" href="styles.css"/>
|
||||
<link rel="stylesheet" href="@Assets["styles.css"]"/>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Baloo+2:wght@400;500;600;700&family=Nunito:wght@400;600;700&display=swap" rel="stylesheet">
|
||||
@@ -22,8 +22,9 @@
|
||||
</html>
|
||||
|
||||
@code {
|
||||
|
||||
[CascadingParameter]
|
||||
private Microsoft.AspNetCore.Http.HttpContext? HttpContext { get; set; }
|
||||
private HttpContext? HttpContext { get; set; }
|
||||
|
||||
private string BaseHref
|
||||
{
|
||||
@@ -36,4 +37,5 @@
|
||||
return pathBase.EndsWith('/') ? pathBase : $"{pathBase}/";
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -42,18 +42,28 @@ public sealed class CharacterFormModel
|
||||
public sealed class SkillFormModel
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string RulesetId { get; set; } = string.Empty;
|
||||
public string DiceRollDefinition { get; set; } = string.Empty;
|
||||
public string SkillGroupId { get; set; } = string.Empty;
|
||||
public int WildDice { get; set; }
|
||||
public bool AllowFumble { get; set; }
|
||||
public int? FumbleRange { get; set; }
|
||||
public bool RolemasterAutoRetry { get; set; }
|
||||
}
|
||||
|
||||
public sealed class SkillGroupFormModel
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string RulesetId { get; set; } = string.Empty;
|
||||
public string DiceRollDefinition { get; set; } = string.Empty;
|
||||
public int WildDice { get; set; }
|
||||
public bool AllowFumble { get; set; }
|
||||
public int? FumbleRange { get; set; }
|
||||
}
|
||||
|
||||
public sealed class CustomRollFormModel
|
||||
{
|
||||
public string Expression { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public enum HomeViewMode
|
||||
|
||||
@@ -39,9 +39,9 @@ public partial class Home
|
||||
|
||||
private Task OnLoggedInAsync()
|
||||
{
|
||||
CurrentView = HomeViewMode.Workspace;
|
||||
ClearStatus();
|
||||
return InvokeAsync(StateHasChanged);
|
||||
Navigation.NavigateTo("/", forceLoad: true);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private Task OnLoggedOutAsync(string? message)
|
||||
@@ -77,4 +77,7 @@ public partial class Home
|
||||
|
||||
[Inject]
|
||||
private RpgRollerApiClient ApiClient { get; set; } = null!;
|
||||
|
||||
[Inject]
|
||||
private NavigationManager Navigation { get; set; } = null!;
|
||||
}
|
||||
@@ -28,9 +28,7 @@ public partial class AdminHome
|
||||
if (!IsCurrentUserAdmin)
|
||||
return;
|
||||
|
||||
Users = (await ApiClient.RequestAsync<IReadOnlyList<AdminUserSummary>>("GET", "/api/admin/users"))
|
||||
.OrderBy(user => user.Username, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
Users = (await ApiClient.RequestAsync<IReadOnlyList<AdminUserSummary>>("GET", "/api/admin/users")).OrderBy(user => user.Username, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
}
|
||||
catch (ApiRequestException ex) when (ex.StatusCode == 401)
|
||||
{
|
||||
@@ -92,10 +90,7 @@ public partial class AdminHome
|
||||
try
|
||||
{
|
||||
IReadOnlyList<string> roles = HasAdminRole(user) ? Array.Empty<string>() : [UserRoles.Admin];
|
||||
_ = await ApiClient.RequestAsync<AdminUserSummary>(
|
||||
"PUT",
|
||||
$"/api/admin/users/{user.Id}/roles",
|
||||
new UpdateUserRolesRequest(roles));
|
||||
_ = await ApiClient.RequestAsync<AdminUserSummary>("PUT", $"/api/admin/users/{user.Id}/roles", new UpdateUserRolesRequest(roles));
|
||||
|
||||
await ReloadUsersAsync();
|
||||
SetStatus("User roles updated.", false);
|
||||
@@ -138,9 +133,7 @@ public partial class AdminHome
|
||||
|
||||
private async Task ReloadUsersAsync()
|
||||
{
|
||||
Users = (await ApiClient.RequestAsync<IReadOnlyList<AdminUserSummary>>("GET", "/api/admin/users"))
|
||||
.OrderBy(user => user.Username, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
Users = (await ApiClient.RequestAsync<IReadOnlyList<AdminUserSummary>>("GET", "/api/admin/users")).OrderBy(user => user.Username, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
}
|
||||
|
||||
private static bool HasAdminRole(UserSummary user)
|
||||
@@ -184,18 +177,28 @@ public partial class AdminHome
|
||||
private List<AdminUserSummary> Users { get; set; } = [];
|
||||
private string? StatusMessage { get; set; }
|
||||
private bool StatusIsError { get; set; }
|
||||
private IReadOnlyList<AppHeaderMenuItem> HeaderMenuItems
|
||||
{
|
||||
get
|
||||
{
|
||||
return
|
||||
|
||||
private IReadOnlyList<AppHeaderMenuItem> HeaderMenuItems =>
|
||||
[
|
||||
new AppHeaderMenuItem { Label = "Play", IsActive = false, OnSelected = OpenPlayAsync },
|
||||
new AppHeaderMenuItem { Label = "Campaign Management", IsActive = false, OnSelected = OpenCampaignManagementAsync },
|
||||
new AppHeaderMenuItem { Label = "Admin", IsActive = true, OnSelected = OpenAdminAsync }
|
||||
new()
|
||||
{
|
||||
Label = "Play",
|
||||
IsActive = false,
|
||||
OnSelected = OpenPlayAsync
|
||||
},
|
||||
new()
|
||||
{
|
||||
Label = "Campaign Management",
|
||||
IsActive = false,
|
||||
OnSelected = OpenCampaignManagementAsync
|
||||
},
|
||||
new()
|
||||
{
|
||||
Label = "Admin",
|
||||
IsActive = true,
|
||||
OnSelected = OpenAdminAsync
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<string?> LoggedOut { get; set; }
|
||||
|
||||
@@ -3,11 +3,15 @@
|
||||
<h1>@Title</h1>
|
||||
@if (User is null)
|
||||
{
|
||||
<p class="header-identity"><strong>Loading user...</strong></p>
|
||||
<p class="header-identity">
|
||||
<strong>Loading user...</strong>
|
||||
</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="header-identity"><strong>@User.DisplayName</strong> <span class="muted">(@User.Username)</span></p>
|
||||
<p class="header-identity">
|
||||
<strong>@User.DisplayName</strong> <span class="muted">(@User.Username)</span>
|
||||
</p>
|
||||
}
|
||||
@if (ShowCampaign)
|
||||
{
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<aside @ref="LogPanelRef" class="card log-panel">
|
||||
<div class="section-head"><h2>Campaign Log</h2></div>
|
||||
<div class="section-head">
|
||||
<h2>Campaign Log</h2>
|
||||
</div>
|
||||
<div @ref="LogFeedRef" class="log-panel-feed">
|
||||
@if (IsCampaignDataLoading)
|
||||
{
|
||||
<div class="skeleton-stack">
|
||||
@@ -18,7 +21,7 @@
|
||||
@foreach (var entry in CampaignLog)
|
||||
{
|
||||
var isExpanded = ExpandedRollId == entry.RollId;
|
||||
<li class="log-entry @LogEntryCssClass(entry) @(isExpanded ? "expanded" : string.Empty)">
|
||||
<li class="log-entry @LogEntryCssClass(entry, isExpanded, FreshRollId == entry.RollId)">
|
||||
<button type="button"
|
||||
class="log-entry-toggle"
|
||||
aria-expanded="@isExpanded"
|
||||
@@ -33,9 +36,25 @@
|
||||
</span>
|
||||
<span class="roll-total inline">@entry.Result</span>
|
||||
</span>
|
||||
<span class="log-meta"><span class="badge @entry.VisibilityStyle">@entry.VisibilityLabel</span>
|
||||
@if (HasSummary(entry))
|
||||
{
|
||||
<span class="log-summary-row">
|
||||
@foreach (var badge in GetEventBadges(entry))
|
||||
{
|
||||
<span class="log-event-badge @badge.Tone">@badge.Label</span>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(entry.SummaryText))
|
||||
{
|
||||
<span class="log-summary-text">@entry.SummaryText</span>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
<span class="log-meta">
|
||||
<span class="badge @entry.VisibilityStyle">@entry.VisibilityLabel</span>
|
||||
<time
|
||||
title="@entry.TimestampUtc.ToString("O")">@entry.TimestampUtc.ToLocalTime().ToString("g")</time>
|
||||
title="@entry.TimestampUtc.ToString("O")">
|
||||
@entry.TimestampUtc.ToLocalTime().ToString("g")
|
||||
</time>
|
||||
</span>
|
||||
</button>
|
||||
@if (isExpanded)
|
||||
@@ -60,4 +79,33 @@
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
|
||||
<section class="custom-roll-panel" aria-label="Custom roll panel">
|
||||
<form class="custom-roll-composer" @onsubmit="SubmitCustomRollAsync" @onsubmit:preventDefault>
|
||||
<div class="custom-roll-composer-head">
|
||||
<label for="custom-roll-expression" class="custom-roll-label">Custom roll</label>
|
||||
<span class="muted">@CustomRollStatusText</span>
|
||||
</div>
|
||||
<div class="custom-roll-composer-row">
|
||||
<input id="custom-roll-expression"
|
||||
@key="CustomRollInputVersion"
|
||||
@ref="CustomRollInputRef"
|
||||
class="@CustomRollInputCssClass"
|
||||
@bind="CustomRollExpression"
|
||||
@bind:event="oninput"
|
||||
placeholder="@CustomRollPlaceholder"
|
||||
title="@CustomRollInputTitle"
|
||||
aria-invalid="@HasCustomRollError"
|
||||
aria-describedby="@CustomRollInputDescribedBy"
|
||||
disabled="@IsCustomRollDisabled"/>
|
||||
<button type="submit" disabled="@IsCustomRollDisabled">Roll</button>
|
||||
</div>
|
||||
<p class="field-help">@CustomRollHelpText</p>
|
||||
@if (HasCustomRollError)
|
||||
{
|
||||
<p id="@CustomRollErrorElementId" class="field-error" role="alert">@CustomRollErrorMessage</p>
|
||||
}
|
||||
</form>
|
||||
</section>
|
||||
</aside>
|
||||
@@ -8,6 +8,8 @@ namespace RpgRoller.Components.Pages.HomeControls;
|
||||
[ExcludeFromCodeCoverage]
|
||||
public partial class CampaignLogPanel
|
||||
{
|
||||
private sealed record EventBadgeView(string Label, string Tone);
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
var currentLastRollId = CampaignLog.LastOrDefault()?.RollId;
|
||||
@@ -22,7 +24,7 @@ public partial class CampaignLogPanel
|
||||
{
|
||||
try
|
||||
{
|
||||
await JS.InvokeVoidAsync("rpgRollerApi.scrollElementToBottom", LogPanelRef);
|
||||
await JS.InvokeVoidAsync("rpgRollerApi.scrollElementToBottom", LogFeedRef);
|
||||
}
|
||||
catch (JSDisconnectedException)
|
||||
{
|
||||
@@ -36,12 +38,112 @@ public partial class CampaignLogPanel
|
||||
LastRenderedLogRollId = currentLastRollId;
|
||||
}
|
||||
|
||||
private async Task SubmitCustomRollAsync()
|
||||
{
|
||||
CustomRollState.ResetValidation();
|
||||
|
||||
var expression = CustomRollState.Model.Expression.Trim();
|
||||
if (string.IsNullOrWhiteSpace(expression))
|
||||
{
|
||||
SetCustomRollError("Enter a roll expression first.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!SelectedCharacterId.HasValue)
|
||||
{
|
||||
SetCustomRollError("Select a character to make a custom roll.");
|
||||
return;
|
||||
}
|
||||
|
||||
IsSubmittingCustomRoll = true;
|
||||
try
|
||||
{
|
||||
var roll = await ApiClient.RequestAsync<RollResult>("POST", $"/api/characters/{SelectedCharacterId.Value}/custom-rolls", new
|
||||
{
|
||||
expression,
|
||||
visibility = NormalizedRollVisibility
|
||||
});
|
||||
|
||||
CustomRollState.Model.Expression = string.Empty;
|
||||
CustomRollState.ResetValidation();
|
||||
CustomRollInputVersion += 1;
|
||||
await CustomRollCreated.InvokeAsync(roll);
|
||||
await JS.InvokeVoidAsync("rpgRollerApi.clearInputValue", CustomRollInputRef);
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
catch (ApiRequestException ex) when (string.Equals(ex.ErrorCode, "invalid_expression", StringComparison.Ordinal))
|
||||
{
|
||||
SetCustomRollError(ex.Message);
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
catch (ApiRequestException ex)
|
||||
{
|
||||
await ErrorOccurred.InvokeAsync(ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsSubmittingCustomRoll = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void SetCustomRollError(string message)
|
||||
{
|
||||
CustomRollState.Errors["expression"] = message;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<EventBadgeView> GetEventBadges(CampaignLogListEntry entry)
|
||||
{
|
||||
return (entry.EventBadges ?? []).Select(ToEventBadgeView).Where(badge => badge is not null).Cast<EventBadgeView>().ToArray();
|
||||
}
|
||||
|
||||
private static bool HasSummary(CampaignLogListEntry entry)
|
||||
{
|
||||
return (entry.EventBadges?.Length ?? 0) > 0 || !string.IsNullOrWhiteSpace(entry.SummaryText);
|
||||
}
|
||||
|
||||
private static EventBadgeView? ToEventBadgeView(string code)
|
||||
{
|
||||
return code switch
|
||||
{
|
||||
"w6" => new("Wild 6", "positive"),
|
||||
"w1" => new("Wild 1", "danger"),
|
||||
"n20" => new("Nat 20", "positive"),
|
||||
"n1" => new("Nat 1", "danger"),
|
||||
"rf" => new("Fumble", "danger"),
|
||||
"r100" => new("100", "rare"),
|
||||
"r66" => new("66", "rare"),
|
||||
"rs5" => new("Retry +5", "rare"),
|
||||
"rs10" => new("Retry +10", "rare"),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static string LogEntryCssClass(CampaignLogListEntry entry, bool isExpanded, bool isFresh)
|
||||
{
|
||||
var classes = new List<string> { entry.VisibilityStyle };
|
||||
if (isExpanded)
|
||||
classes.Add("expanded");
|
||||
|
||||
if (isFresh)
|
||||
classes.Add("fresh");
|
||||
|
||||
return string.Join(" ", classes);
|
||||
}
|
||||
|
||||
[Inject]
|
||||
private IJSRuntime JS { get; set; } = null!;
|
||||
|
||||
[Inject]
|
||||
private RpgRollerApiClient ApiClient { get; set; } = null!;
|
||||
|
||||
private ElementReference LogPanelRef { get; set; }
|
||||
private ElementReference LogFeedRef { get; set; }
|
||||
private ElementReference CustomRollInputRef { get; set; }
|
||||
private int LastRenderedLogCount { get; set; }
|
||||
private Guid? LastRenderedLogRollId { get; set; }
|
||||
private int CustomRollInputVersion { get; set; }
|
||||
private FormState<CustomRollFormModel> CustomRollState { get; } = new();
|
||||
private bool IsSubmittingCustomRoll { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public bool IsCampaignDataLoading { get; set; }
|
||||
@@ -52,6 +154,9 @@ public partial class CampaignLogPanel
|
||||
[Parameter]
|
||||
public Guid? ExpandedRollId { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public Guid? FreshRollId { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<Guid> ToggleRollDetailRequested { get; set; }
|
||||
|
||||
@@ -64,8 +169,63 @@ public partial class CampaignLogPanel
|
||||
[Parameter]
|
||||
public Func<Guid, string?> GetRollDetailError { get; set; } = _ => null;
|
||||
|
||||
private static string LogEntryCssClass(CampaignLogListEntry entry)
|
||||
[Parameter]
|
||||
public Guid? SelectedCharacterId { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public string? SelectedCharacterName { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public string SelectedCampaignRulesetId { get; set; } = string.Empty;
|
||||
|
||||
[Parameter]
|
||||
public string RollVisibility { get; set; } = "public";
|
||||
|
||||
[Parameter]
|
||||
public bool IsMutating { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<RollResult> CustomRollCreated { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<string> ErrorOccurred { get; set; }
|
||||
|
||||
private bool HasCustomRollError => CustomRollState.Errors.ContainsKey("expression");
|
||||
private string? CustomRollErrorMessage => CustomRollState.Errors.GetValueOrDefault("expression");
|
||||
private bool IsCustomRollDisabled => IsCampaignDataLoading || IsMutating || IsSubmittingCustomRoll || !SelectedCharacterId.HasValue;
|
||||
private string CustomRollInputCssClass => HasCustomRollError ? "custom-roll-input error" : "custom-roll-input";
|
||||
private string? CustomRollInputTitle => HasCustomRollError ? CustomRollErrorMessage : null;
|
||||
private string CustomRollErrorElementId => "custom-roll-expression-error";
|
||||
private string? CustomRollInputDescribedBy => HasCustomRollError ? CustomRollErrorElementId : null;
|
||||
|
||||
private string CustomRollPlaceholder => SelectedCampaignRulesetId.ToLowerInvariant() switch
|
||||
{
|
||||
return entry.VisibilityStyle;
|
||||
RulesetFormHelpers.RulesetIds.D6 => "e.g. 5D+4",
|
||||
RulesetFormHelpers.RulesetIds.Dnd5e => "e.g. 2d12+2",
|
||||
RulesetFormHelpers.RulesetIds.Rolemaster => "e.g. d10, 15d10, d100!+85",
|
||||
_ => "Enter a roll expression"
|
||||
};
|
||||
|
||||
private string CustomRollStatusText => SelectedCharacterId.HasValue && !string.IsNullOrWhiteSpace(SelectedCharacterName) ? $"For {SelectedCharacterName} • {RollVisibilityLabel}" : "Select a character to enable";
|
||||
|
||||
private string CustomRollHelpText => SelectedCampaignRulesetId.ToLowerInvariant() switch
|
||||
{
|
||||
RulesetFormHelpers.RulesetIds.D6 => "Uses the campaign ruleset. D6 custom rolls use one wild die and allow fumbles.",
|
||||
RulesetFormHelpers.RulesetIds.Rolemaster => $"{RulesetFormHelpers.RolemasterExampleText()}. Logged for the selected character.",
|
||||
_ => "Uses the selected campaign ruleset and current visibility."
|
||||
};
|
||||
|
||||
private string RollVisibilityLabel => string.Equals(NormalizedRollVisibility, "private", StringComparison.OrdinalIgnoreCase) ? "Private" : "Public";
|
||||
private string NormalizedRollVisibility => string.Equals(RollVisibility, "private", StringComparison.OrdinalIgnoreCase) ? "private" : "public";
|
||||
|
||||
private string CustomRollExpression
|
||||
{
|
||||
get => CustomRollState.Model.Expression;
|
||||
set
|
||||
{
|
||||
CustomRollState.Model.Expression = value;
|
||||
if (HasCustomRollError)
|
||||
CustomRollState.ResetValidation();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,9 +54,7 @@ public partial class CharacterFormModal
|
||||
character = await ApiClient.RequestAsync<CharacterSummary>("PUT", $"/api/characters/{EditingCharacterId.Value}", new UpdateCharacterRequest(FormState.Model.Name.Trim(), campaignId, ownerUsername));
|
||||
}
|
||||
else
|
||||
{
|
||||
character = await ApiClient.RequestAsync<CharacterSummary>("POST", "/api/characters", new CreateCharacterRequest(FormState.Model.Name.Trim(), campaignId!.Value));
|
||||
}
|
||||
|
||||
await CharacterSaved.InvokeAsync(character.CampaignId);
|
||||
}
|
||||
|
||||
@@ -41,8 +41,12 @@
|
||||
<span aria-hidden="true" class="emoji">✏️</span>
|
||||
<span class="sr-only">Edit character</span>
|
||||
</button>
|
||||
<h3 class="skills-heading">@SelectedCharacter.Name <span
|
||||
class="muted">| Owner: @OwnerLabel(SelectedCharacter.OwnerUserId) | Campaign: @SelectedCampaign.Name</span>
|
||||
<h3 class="skills-heading">
|
||||
@SelectedCharacter.Name
|
||||
<span
|
||||
class="muted">
|
||||
| Owner: @OwnerLabel(SelectedCharacter.OwnerUserId) | Campaign: @SelectedCampaign.Name
|
||||
</span>
|
||||
</h3>
|
||||
<div class="skill-filter-wrap">
|
||||
<label class="sr-only" for="skill-filter-input">Filter skills</label>
|
||||
@@ -130,6 +134,7 @@
|
||||
</button>
|
||||
</article>
|
||||
}
|
||||
|
||||
<div class="character-panel-fill" aria-hidden="true"></div>
|
||||
}
|
||||
</section>
|
||||
@@ -152,13 +157,14 @@
|
||||
}
|
||||
|
||||
<label for="skill-group-expression">Prototype expression</label>
|
||||
<input id="skill-group-expression" @bind="SkillGroupState.Model.DiceRollDefinition" @bind:event="oninput"/>
|
||||
<input id="skill-group-expression" value="@SkillGroupState.Model.DiceRollDefinition" @oninput="OnSkillGroupExpressionChanged"/>
|
||||
<p class="field-help">@SkillGroupExpressionHelpText</p>
|
||||
@if (SkillGroupState.Errors.TryGetValue("diceRollDefinition", out var expressionError))
|
||||
{
|
||||
<p class="field-error">@expressionError</p>
|
||||
}
|
||||
|
||||
@if (IsD6)
|
||||
@if (IsD6Ruleset)
|
||||
{
|
||||
<label for="skill-group-wild-dice">Prototype wild dice</label>
|
||||
<input id="skill-group-wild-dice" type="number" min="1" step="1" @bind="SkillGroupState.Model.WildDice"/>
|
||||
@@ -170,6 +176,19 @@
|
||||
<label for="skill-group-allow-fumble">Prototype allow fumble</label>
|
||||
<input id="skill-group-allow-fumble" type="checkbox" @bind="SkillGroupState.Model.AllowFumble"/>
|
||||
}
|
||||
else if (IsRolemasterRuleset)
|
||||
{
|
||||
@if (IsSkillGroupRolemasterOpenEnded)
|
||||
{
|
||||
<label for="skill-group-fumble-range">Prototype fumble range</label>
|
||||
<input id="skill-group-fumble-range" type="number" min="0" max="95" step="1" @bind="SkillGroupState.Model.FumbleRange"/>
|
||||
<p class="field-help">Used only for open-ended percentile skills created from this group.</p>
|
||||
@if (SkillGroupState.Errors.TryGetValue("fumbleRange", out var fumbleRangeError))
|
||||
{
|
||||
<p class="field-error">@fumbleRangeError</p>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@if (SkillGroupState.Errors.TryGetValue("character", out var characterError))
|
||||
{
|
||||
@@ -192,7 +211,7 @@
|
||||
<SkillFormModal
|
||||
Visible="ShowCreateSkillModal"
|
||||
AutoFocusName="true"
|
||||
IsD6="IsD6"
|
||||
RulesetId="@SelectedCampaignRulesetId"
|
||||
Title="Create Skill"
|
||||
SubmitLabel="Create Skill"
|
||||
NameInputId="skill-create-name"
|
||||
@@ -200,6 +219,7 @@
|
||||
SkillGroupInputId="skill-create-group"
|
||||
WildDiceInputId="skill-create-wild-dice"
|
||||
AllowFumbleInputId="skill-create-allow-fumble"
|
||||
FumbleRangeInputId="skill-create-fumble-range"
|
||||
InitialModel="CreateSkillInitialModel"
|
||||
FormVersion="CreateSkillFormVersion"
|
||||
SelectedCharacterId="SelectedCharacterId"
|
||||
@@ -211,7 +231,7 @@
|
||||
|
||||
<SkillFormModal
|
||||
Visible="ShowEditSkillModal"
|
||||
IsD6="IsD6"
|
||||
RulesetId="@SelectedCampaignRulesetId"
|
||||
Title="Edit Skill"
|
||||
SubmitLabel="Save Skill"
|
||||
NameInputId="skill-edit-name"
|
||||
@@ -219,6 +239,7 @@
|
||||
SkillGroupInputId="skill-edit-group"
|
||||
WildDiceInputId="skill-edit-wild-dice"
|
||||
AllowFumbleInputId="skill-edit-allow-fumble"
|
||||
FumbleRangeInputId="skill-edit-fumble-range"
|
||||
InitialModel="EditSkillInitialModel"
|
||||
FormVersion="EditSkillFormVersion"
|
||||
SelectedCharacterId="SelectedCharacterId"
|
||||
|
||||
@@ -9,19 +9,23 @@ public partial class CharacterPanel
|
||||
{
|
||||
private void OpenCreateSkillModal(Guid? skillGroupId = null)
|
||||
{
|
||||
var selectedGroup = skillGroupId.HasValue
|
||||
? SelectedCharacterSkillGroups.FirstOrDefault(group => group.Id == skillGroupId.Value)
|
||||
: null;
|
||||
var selectedGroup = skillGroupId.HasValue ? SelectedCharacterSkillGroups.FirstOrDefault(group => group.Id == skillGroupId.Value) : null;
|
||||
|
||||
CreateSkillInitialModel = new()
|
||||
{
|
||||
Name = string.Empty,
|
||||
RulesetId = SelectedCampaignRulesetId,
|
||||
DiceRollDefinition = selectedGroup?.DiceRollDefinition ?? string.Empty,
|
||||
SkillGroupId = selectedGroup?.Id.ToString() ?? string.Empty,
|
||||
WildDice = selectedGroup?.WildDice ?? (IsD6 ? 1 : 0),
|
||||
AllowFumble = selectedGroup?.AllowFumble ?? IsD6
|
||||
WildDice = selectedGroup?.WildDice ?? (IsD6Ruleset ? 1 : 0),
|
||||
AllowFumble = selectedGroup?.AllowFumble ?? IsD6Ruleset,
|
||||
FumbleRange = selectedGroup?.FumbleRange,
|
||||
RolemasterAutoRetry = false
|
||||
};
|
||||
|
||||
if (IsRolemasterRuleset && string.IsNullOrWhiteSpace(CreateSkillInitialModel.DiceRollDefinition))
|
||||
CreateSkillInitialModel.DiceRollDefinition = "d100";
|
||||
|
||||
CreateSkillFormVersion++;
|
||||
ShowCreateSkillModal = true;
|
||||
}
|
||||
@@ -32,10 +36,13 @@ public partial class CharacterPanel
|
||||
EditSkillInitialModel = new()
|
||||
{
|
||||
Name = skill.Name,
|
||||
RulesetId = SelectedCampaignRulesetId,
|
||||
DiceRollDefinition = skill.DiceRollDefinition,
|
||||
SkillGroupId = skill.SkillGroupId?.ToString() ?? string.Empty,
|
||||
WildDice = skill.WildDice,
|
||||
AllowFumble = skill.AllowFumble
|
||||
AllowFumble = skill.AllowFumble,
|
||||
FumbleRange = skill.FumbleRange,
|
||||
RolemasterAutoRetry = skill.RolemasterAutoRetry
|
||||
};
|
||||
|
||||
EditSkillFormVersion++;
|
||||
@@ -67,9 +74,9 @@ public partial class CharacterPanel
|
||||
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)
|
||||
@@ -96,9 +103,13 @@ public partial class CharacterPanel
|
||||
private void OpenCreateSkillGroupModal()
|
||||
{
|
||||
SkillGroupState.Model.Name = string.Empty;
|
||||
SkillGroupState.Model.RulesetId = SelectedCampaignRulesetId;
|
||||
SkillGroupState.Model.DiceRollDefinition = string.Empty;
|
||||
SkillGroupState.Model.WildDice = IsD6 ? 1 : 0;
|
||||
SkillGroupState.Model.AllowFumble = IsD6;
|
||||
SkillGroupState.Model.WildDice = IsD6Ruleset ? 1 : 0;
|
||||
SkillGroupState.Model.AllowFumble = IsD6Ruleset;
|
||||
SkillGroupState.Model.FumbleRange = null;
|
||||
if (IsRolemasterRuleset)
|
||||
SkillGroupState.Model.DiceRollDefinition = "d100";
|
||||
SkillGroupState.ResetValidation();
|
||||
ShowCreateSkillGroupModal = true;
|
||||
}
|
||||
@@ -107,9 +118,12 @@ public partial class CharacterPanel
|
||||
{
|
||||
EditingSkillGroupId = skillGroup.Id;
|
||||
SkillGroupState.Model.Name = skillGroup.Name;
|
||||
SkillGroupState.Model.RulesetId = SelectedCampaignRulesetId;
|
||||
SkillGroupState.Model.DiceRollDefinition = skillGroup.DiceRollDefinition;
|
||||
SkillGroupState.Model.WildDice = skillGroup.WildDice;
|
||||
SkillGroupState.Model.AllowFumble = skillGroup.AllowFumble;
|
||||
SkillGroupState.Model.FumbleRange = skillGroup.FumbleRange;
|
||||
NormalizeSkillGroupFumbleRange();
|
||||
SkillGroupState.ResetValidation();
|
||||
ShowEditSkillGroupModal = true;
|
||||
}
|
||||
@@ -132,9 +146,23 @@ public partial class CharacterPanel
|
||||
if (string.IsNullOrWhiteSpace(SkillGroupState.Model.DiceRollDefinition))
|
||||
SkillGroupState.Errors["diceRollDefinition"] = "Expression is required.";
|
||||
|
||||
if (IsD6 && SkillGroupState.Model.WildDice < 1)
|
||||
if (IsD6Ruleset && SkillGroupState.Model.WildDice < 1)
|
||||
SkillGroupState.Errors["wildDice"] = "D6 groups require at least one wild die.";
|
||||
|
||||
if (IsRolemasterRuleset)
|
||||
{
|
||||
if (IsSkillGroupRolemasterOpenEnded && !SkillGroupState.Model.FumbleRange.HasValue)
|
||||
SkillGroupState.Errors["fumbleRange"] = "Open-ended Rolemaster groups require a fumble range.";
|
||||
}
|
||||
else
|
||||
SkillGroupState.Model.FumbleRange = null;
|
||||
|
||||
if (!IsD6Ruleset)
|
||||
{
|
||||
SkillGroupState.Model.WildDice = 0;
|
||||
SkillGroupState.Model.AllowFumble = false;
|
||||
}
|
||||
|
||||
if (!SelectedCharacterId.HasValue)
|
||||
SkillGroupState.Errors["character"] = "Select a character first.";
|
||||
|
||||
@@ -148,14 +176,7 @@ public partial class CharacterPanel
|
||||
try
|
||||
{
|
||||
var selectedCharacterId = SelectedCharacterId!.Value;
|
||||
var createdGroup = await ApiClient.RequestAsync<SkillGroupSummary>(
|
||||
"POST",
|
||||
$"/api/characters/{selectedCharacterId}/skill-groups",
|
||||
new CreateSkillGroupRequest(
|
||||
SkillGroupState.Model.Name.Trim(),
|
||||
SkillGroupState.Model.DiceRollDefinition.Trim(),
|
||||
SkillGroupState.Model.WildDice,
|
||||
SkillGroupState.Model.AllowFumble));
|
||||
var createdGroup = await ApiClient.RequestAsync<SkillGroupSummary>("POST", $"/api/characters/{selectedCharacterId}/skill-groups", new CreateSkillGroupRequest(SkillGroupState.Model.Name.Trim(), SkillGroupState.Model.DiceRollDefinition.Trim(), SkillGroupState.Model.WildDice, SkillGroupState.Model.AllowFumble, SkillGroupState.Model.FumbleRange));
|
||||
CloseSkillGroupModals();
|
||||
await SkillGroupCreated.InvokeAsync(createdGroup.Id);
|
||||
}
|
||||
@@ -179,9 +200,23 @@ public partial class CharacterPanel
|
||||
if (string.IsNullOrWhiteSpace(SkillGroupState.Model.DiceRollDefinition))
|
||||
SkillGroupState.Errors["diceRollDefinition"] = "Expression is required.";
|
||||
|
||||
if (IsD6 && SkillGroupState.Model.WildDice < 1)
|
||||
if (IsD6Ruleset && SkillGroupState.Model.WildDice < 1)
|
||||
SkillGroupState.Errors["wildDice"] = "D6 groups require at least one wild die.";
|
||||
|
||||
if (IsRolemasterRuleset)
|
||||
{
|
||||
if (IsSkillGroupRolemasterOpenEnded && !SkillGroupState.Model.FumbleRange.HasValue)
|
||||
SkillGroupState.Errors["fumbleRange"] = "Open-ended Rolemaster groups require a fumble range.";
|
||||
}
|
||||
else
|
||||
SkillGroupState.Model.FumbleRange = null;
|
||||
|
||||
if (!IsD6Ruleset)
|
||||
{
|
||||
SkillGroupState.Model.WildDice = 0;
|
||||
SkillGroupState.Model.AllowFumble = false;
|
||||
}
|
||||
|
||||
if (!EditingSkillGroupId.HasValue)
|
||||
SkillGroupState.Errors["group"] = "Select a skill group first.";
|
||||
|
||||
@@ -195,14 +230,7 @@ public partial class CharacterPanel
|
||||
try
|
||||
{
|
||||
var editingSkillGroupId = EditingSkillGroupId!.Value;
|
||||
var updatedGroup = await ApiClient.RequestAsync<SkillGroupSummary>(
|
||||
"PUT",
|
||||
$"/api/skill-groups/{editingSkillGroupId}",
|
||||
new UpdateSkillGroupRequest(
|
||||
SkillGroupState.Model.Name.Trim(),
|
||||
SkillGroupState.Model.DiceRollDefinition.Trim(),
|
||||
SkillGroupState.Model.WildDice,
|
||||
SkillGroupState.Model.AllowFumble));
|
||||
var updatedGroup = await ApiClient.RequestAsync<SkillGroupSummary>("PUT", $"/api/skill-groups/{editingSkillGroupId}", new UpdateSkillGroupRequest(SkillGroupState.Model.Name.Trim(), SkillGroupState.Model.DiceRollDefinition.Trim(), SkillGroupState.Model.WildDice, SkillGroupState.Model.AllowFumble, SkillGroupState.Model.FumbleRange));
|
||||
CloseSkillGroupModals();
|
||||
await SkillGroupUpdated.InvokeAsync(updatedGroup.Id);
|
||||
}
|
||||
@@ -248,8 +276,7 @@ public partial class CharacterPanel
|
||||
return true;
|
||||
|
||||
var filter = SkillFilterText.Trim();
|
||||
return skill.Name.Contains(filter, StringComparison.OrdinalIgnoreCase) ||
|
||||
skill.DiceRollDefinition.Contains(filter, StringComparison.OrdinalIgnoreCase);
|
||||
return skill.Name.Contains(filter, StringComparison.OrdinalIgnoreCase) || skill.DiceRollDefinition.Contains(filter, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string InitialsFor(string value)
|
||||
@@ -264,6 +291,36 @@ public partial class CharacterPanel
|
||||
return string.Concat(words[0][0], words[1][0]).ToUpperInvariant();
|
||||
}
|
||||
|
||||
private void OnSkillGroupExpressionChanged(ChangeEventArgs args)
|
||||
{
|
||||
SkillGroupState.Model.DiceRollDefinition = args.Value?.ToString() ?? string.Empty;
|
||||
if (IsRolemasterRuleset)
|
||||
NormalizeSkillGroupFumbleRange();
|
||||
}
|
||||
|
||||
private void NormalizeSkillGroupFumbleRange()
|
||||
{
|
||||
if (!IsRolemasterRuleset)
|
||||
{
|
||||
SkillGroupState.Model.FumbleRange = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (IsSkillGroupRolemasterOpenEnded)
|
||||
{
|
||||
SkillGroupState.Model.FumbleRange ??= 5;
|
||||
return;
|
||||
}
|
||||
|
||||
SkillGroupState.Model.FumbleRange = null;
|
||||
}
|
||||
|
||||
private bool IsD6Ruleset => RulesetFormHelpers.IsD6(SelectedCampaignRulesetId);
|
||||
private bool IsRolemasterRuleset => RulesetFormHelpers.IsRolemaster(SelectedCampaignRulesetId);
|
||||
private bool IsSkillGroupRolemasterOpenEnded => RulesetFormHelpers.IsRolemasterOpenEndedExpression(SkillGroupState.Model.DiceRollDefinition);
|
||||
|
||||
private string SkillGroupExpressionHelpText => IsRolemasterRuleset ? $"{RulesetFormHelpers.RolemasterExampleText()}. Negative modifiers are allowed." : "Enter the default expression for skills created in this group.";
|
||||
|
||||
private bool ShowCreateSkillModal { get; set; }
|
||||
private bool ShowEditSkillModal { get; set; }
|
||||
private bool ShowCreateSkillGroupModal { get; set; }
|
||||
@@ -303,7 +360,7 @@ public partial class CharacterPanel
|
||||
public IReadOnlyList<CharacterSheetSkillGroup> SelectedCharacterSkillGroups { get; set; } = [];
|
||||
|
||||
[Parameter]
|
||||
public bool IsD6 { get; set; }
|
||||
public string SelectedCampaignRulesetId { get; set; } = string.Empty;
|
||||
|
||||
[Parameter]
|
||||
public string RollVisibility { get; set; } = "public";
|
||||
@@ -351,5 +408,5 @@ public partial class CharacterPanel
|
||||
public EventCallback<string> ErrorOccurred { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<Guid> RollRequested { get; set; }
|
||||
public EventCallback<CharacterSheetSkill> RollRequested { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
@if (Visible)
|
||||
{
|
||||
<div class="modal-overlay" role="presentation" @onclick="HandleOverlayClickAsync">
|
||||
<section class="modal-card rolemaster-roll-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Rolemaster situational modifier"
|
||||
tabindex="-1"
|
||||
@onclick:stopPropagation="true"
|
||||
@onkeydown="HandleKeyDownAsync">
|
||||
<h2>Rolemaster skill roll</h2>
|
||||
<p class="muted">Roll <strong>@SkillName</strong> using <code>@Expression</code>.</p>
|
||||
@if (!string.IsNullOrWhiteSpace(ErrorMessage))
|
||||
{
|
||||
<p class="form-error">@ErrorMessage</p>
|
||||
}
|
||||
<form class="form-grid" @onsubmit="SubmitAsync" @onsubmit:preventDefault>
|
||||
<label for="@ModifierInputId">Situational modifier</label>
|
||||
<input id="@ModifierInputId"
|
||||
@ref="ModifierInputElement"
|
||||
value="@CurrentModifierText"
|
||||
@oninput="OnModifierInput"
|
||||
placeholder="Blank = 0"
|
||||
inputmode="numeric"
|
||||
autocomplete="off"/>
|
||||
<p class="field-help">Optional one-shot bonus or penalty. Examples: <code>20</code>, <code>-15</code>, or blank for <code>0</code>.</p>
|
||||
<div class="inline-actions">
|
||||
<button type="submit" disabled="@(IsMutating || IsSubmitting)">Roll</button>
|
||||
<button type="button" class="ghost" disabled="@(IsMutating || IsSubmitting)" @onclick="CancelRequested">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
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; }
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="roll-dice-strip" aria-label="@AriaLabel">
|
||||
@foreach (var die in Dice)
|
||||
{
|
||||
<span class="@RollDieCssClass(die)" title="@RollDieTitle(die)">@RollDieGlyph(die.Roll)</span>
|
||||
<span class="@RollDieCssClass(die)" title="@RollDieTitle(die)">@RollDieDisplay(die)</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -21,6 +21,27 @@ public partial class RollDiceStrip
|
||||
};
|
||||
}
|
||||
|
||||
private static string RollDieDisplay(RollDieResult die)
|
||||
{
|
||||
if (string.Equals(die.Kind, RollDieKinds.RolemasterOpenEndedInitial, StringComparison.Ordinal) && !die.SignedContribution.HasValue)
|
||||
return $"({die.Roll:00})";
|
||||
|
||||
if (IsRolemasterDie(die))
|
||||
{
|
||||
return die.Kind switch
|
||||
{
|
||||
RollDieKinds.RolemasterOpenEndedHigh => $"+{die.Roll:00}",
|
||||
RollDieKinds.RolemasterOpenEndedLowSubtract => $"-{die.Roll:00}",
|
||||
_ => die.Roll.ToString("00")
|
||||
};
|
||||
}
|
||||
|
||||
return die.Kind switch
|
||||
{
|
||||
_ => RollDieGlyph(die.Roll)
|
||||
};
|
||||
}
|
||||
|
||||
private static string RollDieCssClass(RollDieResult die)
|
||||
{
|
||||
var classes = new List<string> { "die-chip" };
|
||||
@@ -39,12 +60,39 @@ public partial class RollDiceStrip
|
||||
if (die.Added)
|
||||
classes.Add("added");
|
||||
|
||||
switch (die.Kind)
|
||||
{
|
||||
case RollDieKinds.RolemasterStandard:
|
||||
classes.Add("rolemaster-standard");
|
||||
break;
|
||||
case RollDieKinds.RolemasterOpenEndedInitial:
|
||||
classes.Add("rolemaster-open-ended-initial");
|
||||
break;
|
||||
case RollDieKinds.RolemasterOpenEndedHigh:
|
||||
classes.Add("rolemaster-open-ended-high");
|
||||
break;
|
||||
case RollDieKinds.RolemasterOpenEndedLowSubtract:
|
||||
classes.Add("rolemaster-open-ended-low-subtract");
|
||||
break;
|
||||
}
|
||||
|
||||
return string.Join(" ", classes);
|
||||
}
|
||||
|
||||
private static bool IsRolemasterDie(RollDieResult die)
|
||||
{
|
||||
return die.Kind is RollDieKinds.RolemasterStandard or RollDieKinds.RolemasterOpenEndedInitial or RollDieKinds.RolemasterOpenEndedHigh or RollDieKinds.RolemasterOpenEndedLowSubtract;
|
||||
}
|
||||
|
||||
private static string RollDieTitle(RollDieResult die)
|
||||
{
|
||||
var labels = new List<string> { $"Roll {die.Roll}" };
|
||||
if (die.Sequence.HasValue)
|
||||
labels.Add($"step {die.Sequence.Value}");
|
||||
|
||||
if (die.Attempt.HasValue)
|
||||
labels.Add(die.Attempt.Value == 1 ? "attempt 1" : $"retry attempt {die.Attempt.Value}");
|
||||
|
||||
if (die.Wild)
|
||||
labels.Add("wild");
|
||||
|
||||
@@ -60,6 +108,22 @@ public partial class RollDiceStrip
|
||||
if (die.Added)
|
||||
labels.Add("added");
|
||||
|
||||
switch (die.Kind)
|
||||
{
|
||||
case RollDieKinds.RolemasterStandard:
|
||||
labels.Add("Rolemaster roll");
|
||||
break;
|
||||
case RollDieKinds.RolemasterOpenEndedInitial:
|
||||
labels.Add(die.SignedContribution.HasValue ? "Rolemaster open-ended initial" : "Rolemaster low-end trigger (ignored in total)");
|
||||
break;
|
||||
case RollDieKinds.RolemasterOpenEndedHigh:
|
||||
labels.Add($"Rolemaster high follow-up (+{die.Roll})");
|
||||
break;
|
||||
case RollDieKinds.RolemasterOpenEndedLowSubtract:
|
||||
labels.Add($"Rolemaster low-end subtraction (-{die.Roll})");
|
||||
break;
|
||||
}
|
||||
|
||||
return string.Join(", ", labels);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using RpgRoller.Domain;
|
||||
using RpgRoller.Services;
|
||||
|
||||
namespace RpgRoller.Components.Pages.HomeControls;
|
||||
|
||||
[ExcludeFromCodeCoverage]
|
||||
internal static class RulesetFormHelpers
|
||||
{
|
||||
internal static class RulesetIds
|
||||
{
|
||||
public const string D6 = "d6";
|
||||
public const string Dnd5e = "dnd5e";
|
||||
public const string Rolemaster = "rolemaster";
|
||||
}
|
||||
|
||||
public static bool IsD6(string? rulesetId)
|
||||
{
|
||||
return string.Equals(rulesetId, RulesetIds.D6, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public static bool IsRolemaster(string? rulesetId)
|
||||
{
|
||||
return string.Equals(rulesetId, RulesetIds.Rolemaster, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public static bool IsRolemasterOpenEndedExpression(string? expression)
|
||||
{
|
||||
var parseResult = TryParseRolemasterExpression(expression);
|
||||
return parseResult.Succeeded && parseResult.Value is not null && parseResult.Value.Kind == DiceExpressionKind.RolemasterOpenEndedPercentile;
|
||||
}
|
||||
|
||||
public static string DescribeRolemasterExpression(string expression, int? fumbleRange, bool rolemasterAutoRetry = false)
|
||||
{
|
||||
var parseResult = TryParseRolemasterExpression(expression);
|
||||
if (!parseResult.Succeeded || parseResult.Value is null)
|
||||
return expression;
|
||||
|
||||
return parseResult.Value.Kind switch
|
||||
{
|
||||
DiceExpressionKind.RolemasterOpenEndedPercentile => DescribeOpenEndedExpression(parseResult.Value.Canonical, fumbleRange, rolemasterAutoRetry),
|
||||
_ => $"Rolemaster: {parseResult.Value.Canonical}"
|
||||
};
|
||||
}
|
||||
|
||||
public static string RolemasterExampleText()
|
||||
{
|
||||
return "Examples: d10, 15d10, d100-15, d100!+85";
|
||||
}
|
||||
|
||||
private static ServiceResult<DiceExpression> TryParseRolemasterExpression(string? expression)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(expression))
|
||||
return ServiceResult<DiceExpression>.Failure("invalid_expression", "Expression is required.");
|
||||
|
||||
return DiceRules.ParseExpression(RulesetKind.Rolemaster, expression);
|
||||
}
|
||||
|
||||
private static string DescribeOpenEndedExpression(string canonicalExpression, int? fumbleRange, bool rolemasterAutoRetry)
|
||||
{
|
||||
var parts = new List<string> { $"Open-ended percentile: {canonicalExpression}" };
|
||||
|
||||
if (fumbleRange.HasValue)
|
||||
parts.Add($"fumble <= {fumbleRange.Value}");
|
||||
|
||||
if (rolemasterAutoRetry)
|
||||
parts.Add("auto retry");
|
||||
|
||||
return string.Join(", ", parts);
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,8 @@
|
||||
<p class="field-error">@skillNameError</p>
|
||||
}
|
||||
<label for="@ExpressionInputId">Expression</label>
|
||||
<input id="@ExpressionInputId" @bind="FormState.Model.DiceRollDefinition" @bind:event="oninput"/>
|
||||
<input id="@ExpressionInputId" value="@FormState.Model.DiceRollDefinition" @oninput="OnExpressionChanged"/>
|
||||
<p class="field-help">@ExpressionHelpText</p>
|
||||
@if (FormState.Errors.TryGetValue("diceRollDefinition", out var expressionError))
|
||||
{
|
||||
<p class="field-error">@expressionError</p>
|
||||
@@ -32,7 +33,7 @@
|
||||
{
|
||||
<p class="field-error">@skillGroupError</p>
|
||||
}
|
||||
@if (IsD6)
|
||||
@if (IsD6Ruleset)
|
||||
{
|
||||
<label for="@WildDiceInputId">Wild dice</label>
|
||||
<input id="@WildDiceInputId" type="number" min="1" step="1" @bind="FormState.Model.WildDice"/>
|
||||
@@ -44,6 +45,23 @@
|
||||
<label for="@AllowFumbleInputId">Allow fumble</label>
|
||||
<input id="@AllowFumbleInputId" type="checkbox" @bind="FormState.Model.AllowFumble"/>
|
||||
}
|
||||
else if (IsRolemasterRuleset)
|
||||
{
|
||||
@if (IsRolemasterOpenEndedSelected)
|
||||
{
|
||||
<label for="@FumbleRangeInputId">Fumble range</label>
|
||||
<input id="@FumbleRangeInputId" type="number" min="0" max="95" step="1" @bind="FormState.Model.FumbleRange"/>
|
||||
<p class="field-help">Used only for low-end open-ended rolls. Allowed range: 0 to 95.</p>
|
||||
@if (FormState.Errors.TryGetValue("fumbleRange", out var fumbleRangeError))
|
||||
{
|
||||
<p class="field-error">@fumbleRangeError</p>
|
||||
}
|
||||
|
||||
<label for="skill-auto-retry">Automatic retry</label>
|
||||
<input id="skill-auto-retry" type="checkbox" @bind="FormState.Model.RolemasterAutoRetry"/>
|
||||
<p class="field-help">When later enabled in rolling, retry bands are 76-90 and 91-110.</p>
|
||||
}
|
||||
}
|
||||
<div class="inline-actions">
|
||||
<button type="submit" disabled="@(IsMutating || IsSubmitting)">@SubmitLabel</button>
|
||||
<button type="button" class="ghost" @onclick="CancelRequested">Cancel</button>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Web;
|
||||
using RpgRoller.Contracts;
|
||||
|
||||
namespace RpgRoller.Components.Pages.HomeControls;
|
||||
@@ -14,10 +13,14 @@ public partial class SkillFormModal
|
||||
return;
|
||||
|
||||
FormState.Model.Name = InitialModel.Name;
|
||||
FormState.Model.RulesetId = RulesetId;
|
||||
FormState.Model.DiceRollDefinition = InitialModel.DiceRollDefinition;
|
||||
FormState.Model.SkillGroupId = InitialModel.SkillGroupId;
|
||||
FormState.Model.WildDice = InitialModel.WildDice;
|
||||
FormState.Model.AllowFumble = InitialModel.AllowFumble;
|
||||
FormState.Model.FumbleRange = InitialModel.FumbleRange;
|
||||
FormState.Model.RolemasterAutoRetry = InitialModel.RolemasterAutoRetry;
|
||||
SynchronizeRulesetSpecificFields();
|
||||
FormState.ResetValidation();
|
||||
AppliedFormVersion = FormVersion;
|
||||
PendingNameFocus = AutoFocusName;
|
||||
@@ -42,9 +45,26 @@ public partial class SkillFormModal
|
||||
if (string.IsNullOrWhiteSpace(FormState.Model.DiceRollDefinition))
|
||||
FormState.Errors["diceRollDefinition"] = "Expression is required.";
|
||||
|
||||
if (IsD6 && FormState.Model.WildDice < 1)
|
||||
if (IsD6Ruleset && FormState.Model.WildDice < 1)
|
||||
FormState.Errors["wildDice"] = "D6 skills require at least one wild die.";
|
||||
|
||||
if (IsRolemasterRuleset)
|
||||
{
|
||||
if (IsRolemasterOpenEndedSelected && !FormState.Model.FumbleRange.HasValue)
|
||||
FormState.Errors["fumbleRange"] = "Open-ended Rolemaster skills require a fumble range.";
|
||||
}
|
||||
else
|
||||
{
|
||||
FormState.Model.FumbleRange = null;
|
||||
FormState.Model.RolemasterAutoRetry = false;
|
||||
}
|
||||
|
||||
if (!IsD6Ruleset)
|
||||
{
|
||||
FormState.Model.WildDice = 0;
|
||||
FormState.Model.AllowFumble = false;
|
||||
}
|
||||
|
||||
Guid? skillGroupId = null;
|
||||
if (!string.IsNullOrWhiteSpace(FormState.Model.SkillGroupId))
|
||||
{
|
||||
@@ -65,9 +85,7 @@ public partial class SkillFormModal
|
||||
{
|
||||
SkillSummary skill;
|
||||
if (EditingSkillId.HasValue)
|
||||
{
|
||||
skill = await ApiClient.RequestAsync<SkillSummary>("PUT", $"/api/skills/{EditingSkillId.Value}", new UpdateSkillRequest(FormState.Model.Name.Trim(), FormState.Model.DiceRollDefinition.Trim(), FormState.Model.WildDice, FormState.Model.AllowFumble, skillGroupId));
|
||||
}
|
||||
skill = await ApiClient.RequestAsync<SkillSummary>("PUT", $"/api/skills/{EditingSkillId.Value}", new UpdateSkillRequest(FormState.Model.Name.Trim(), FormState.Model.DiceRollDefinition.Trim(), FormState.Model.WildDice, FormState.Model.AllowFumble, skillGroupId, FormState.Model.FumbleRange, FormState.Model.RolemasterAutoRetry));
|
||||
else
|
||||
{
|
||||
if (!SelectedCharacterId.HasValue)
|
||||
@@ -76,7 +94,7 @@ public partial class SkillFormModal
|
||||
return;
|
||||
}
|
||||
|
||||
skill = await ApiClient.RequestAsync<SkillSummary>("POST", $"/api/characters/{SelectedCharacterId.Value}/skills", new CreateSkillRequest(FormState.Model.Name.Trim(), FormState.Model.DiceRollDefinition.Trim(), FormState.Model.WildDice, FormState.Model.AllowFumble, skillGroupId));
|
||||
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);
|
||||
@@ -91,6 +109,48 @@ public partial class SkillFormModal
|
||||
}
|
||||
}
|
||||
|
||||
private void OnExpressionChanged(ChangeEventArgs args)
|
||||
{
|
||||
FormState.Model.DiceRollDefinition = args.Value?.ToString() ?? string.Empty;
|
||||
if (IsRolemasterRuleset)
|
||||
NormalizeRolemasterFumbleRange();
|
||||
}
|
||||
|
||||
private void SynchronizeRulesetSpecificFields()
|
||||
{
|
||||
if (!IsRolemasterRuleset)
|
||||
{
|
||||
FormState.Model.RolemasterAutoRetry = false;
|
||||
return;
|
||||
}
|
||||
|
||||
NormalizeRolemasterFumbleRange();
|
||||
}
|
||||
|
||||
private void NormalizeRolemasterFumbleRange()
|
||||
{
|
||||
if (!IsRolemasterRuleset)
|
||||
{
|
||||
FormState.Model.FumbleRange = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (IsRolemasterOpenEndedSelected)
|
||||
{
|
||||
FormState.Model.FumbleRange ??= 5;
|
||||
return;
|
||||
}
|
||||
|
||||
FormState.Model.FumbleRange = null;
|
||||
FormState.Model.RolemasterAutoRetry = false;
|
||||
}
|
||||
|
||||
private bool IsD6Ruleset => RulesetFormHelpers.IsD6(RulesetId);
|
||||
private bool IsRolemasterRuleset => RulesetFormHelpers.IsRolemaster(RulesetId);
|
||||
private bool IsRolemasterOpenEndedSelected => RulesetFormHelpers.IsRolemasterOpenEndedExpression(FormState.Model.DiceRollDefinition);
|
||||
|
||||
private string ExpressionHelpText => IsRolemasterRuleset ? $"{RulesetFormHelpers.RolemasterExampleText()}. Negative modifiers are allowed." : "Enter the dice expression used for this skill.";
|
||||
|
||||
[Inject]
|
||||
private RpgRollerApiClient ApiClient { get; set; } = null!;
|
||||
|
||||
@@ -104,7 +164,7 @@ public partial class SkillFormModal
|
||||
public bool Visible { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public bool IsD6 { get; set; }
|
||||
public string RulesetId { get; set; } = string.Empty;
|
||||
|
||||
[Parameter]
|
||||
public string Title { get; set; } = "Skill";
|
||||
@@ -127,6 +187,9 @@ public partial class SkillFormModal
|
||||
[Parameter]
|
||||
public string AllowFumbleInputId { get; set; } = "skill-fumble";
|
||||
|
||||
[Parameter]
|
||||
public string FumbleRangeInputId { get; set; } = "skill-fumble-range";
|
||||
|
||||
[Parameter]
|
||||
public SkillFormModel InitialModel { get; set; } = new();
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
class="chip-button"
|
||||
title="Roll skill"
|
||||
disabled="@(IsMutating)"
|
||||
@onclick="() => RollSkillRequested.InvokeAsync(skill.Id)">
|
||||
@onclick="() => RollSkillRequested.InvokeAsync(skill)">
|
||||
<span aria-hidden="true" class="emoji">🎲</span>
|
||||
<span class="sr-only">Roll @skill.Name</span>
|
||||
</button>
|
||||
|
||||
@@ -47,7 +47,7 @@ public partial class SkillGroupBlock
|
||||
public EventCallback<CharacterSheetSkill> EditSkillRequested { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<Guid> RollSkillRequested { get; set; }
|
||||
public EventCallback<CharacterSheetSkill> RollSkillRequested { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<Guid> DeleteSkillRequested { get; set; }
|
||||
|
||||
@@ -1,103 +1,113 @@
|
||||
@using RpgRoller.Components.Pages.HomeControls
|
||||
<div class="@AppCssClass">
|
||||
<p class="sr-only" aria-live="polite">@LiveAnnouncement</p>
|
||||
<div class="@State.AppCssClass">
|
||||
<p class="sr-only" aria-live="polite">@State.LiveAnnouncement</p>
|
||||
|
||||
@if (HasHealthIssue)
|
||||
@if (State.HasHealthIssue)
|
||||
{
|
||||
<section class="health-banner" role="alert">
|
||||
<div>
|
||||
<strong>API currently unavailable.</strong>
|
||||
<p>@HealthIssueMessage</p>
|
||||
<p>@State.HealthIssueMessage</p>
|
||||
</div>
|
||||
<button type="button" @onclick="RetryAfterHealthIssueAsync">Retry</button>
|
||||
<button type="button" @onclick="Session.RetryAfterHealthIssueAsync">Retry</button>
|
||||
</section>
|
||||
}
|
||||
|
||||
<div class="workspace-shell">
|
||||
<AppHeader
|
||||
User="User"
|
||||
User="State.User"
|
||||
ShowCampaign="true"
|
||||
CampaignName="@SelectedCampaignName"
|
||||
CampaignName="@State.SelectedCampaignName"
|
||||
ShowConnectionState="true"
|
||||
ConnectionStateLabel="@ConnectionStateLabel"
|
||||
ConnectionStateCssClass="@ConnectionStateCssClass"
|
||||
IsMenuOpen="IsScreenMenuOpen"
|
||||
ConnectionStateLabel="@State.ConnectionStateLabel"
|
||||
ConnectionStateCssClass="@State.ConnectionStateCssClass"
|
||||
IsMenuOpen="State.IsScreenMenuOpen"
|
||||
MenuButtonId="workspace-screen-menu-button"
|
||||
MenuId="workspace-screen-menu"
|
||||
MenuItems="HeaderMenuItems"
|
||||
ToggleMenuRequested="ToggleScreenMenu"
|
||||
LogoutRequested="LogoutAsync"/>
|
||||
LogoutRequested="Session.LogoutAsync"/>
|
||||
|
||||
@if (IsPlayScreen)
|
||||
@if (State.IsPlayScreen)
|
||||
{
|
||||
<main class="play-screen @(MobilePanel == "log" ? "mobile-log" : "mobile-character")">
|
||||
<main class="play-screen @(State.MobilePanel == "log" ? "mobile-log" : "mobile-character")">
|
||||
<CharacterPanel
|
||||
IsCampaignDataLoading="IsCampaignDataLoading"
|
||||
SelectedCampaign="PlaySelectedCampaign"
|
||||
SelectedCharacterId="PlaySelectedCharacterId"
|
||||
SelectedCharacter="PlaySelectedCharacter"
|
||||
IsMutating="IsMutating"
|
||||
SelectedCharacterSkills="PlaySelectedCharacterSkills"
|
||||
SelectedCharacterSkillGroups="PlaySelectedCharacterSkillGroups"
|
||||
IsD6="IsSelectedCampaignD6"
|
||||
RollVisibility="RollVisibility"
|
||||
RollVisibilityChanged="OnRollVisibilityChanged"
|
||||
OwnerLabel="OwnerLabel"
|
||||
SkillDefinitionLabel="SkillDefinitionLabel"
|
||||
CanEditCharacter="CanEditCharacter"
|
||||
CanEditSkill="CanEditSkill"
|
||||
CharacterSelected="SelectCharacterAsync"
|
||||
EditCharacterRequested="OpenEditCharacterModal"
|
||||
SkillCreated="OnSkillCreatedAsync"
|
||||
SkillUpdated="OnSkillUpdatedAsync"
|
||||
SkillGroupCreated="OnSkillGroupCreatedAsync"
|
||||
SkillGroupUpdated="OnSkillGroupUpdatedAsync"
|
||||
SkillDeleted="OnSkillDeletedAsync"
|
||||
SkillGroupDeleted="OnSkillGroupDeletedAsync"
|
||||
ErrorOccurred="OnCharacterPanelErrorAsync"
|
||||
RollRequested="RollSkillAsync"/>
|
||||
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="IsCampaignDataLoading"
|
||||
CampaignLog="PlayVisibleCampaignLog"
|
||||
ExpandedRollId="ExpandedCampaignLogRollId"
|
||||
ToggleRollDetailRequested="ToggleRollDetailAsync"
|
||||
ResolveRollDetail="ResolveRollDetail"
|
||||
IsRollDetailLoading="IsRollDetailLoading"
|
||||
GetRollDetailError="GetRollDetailError"/>
|
||||
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 @(MobilePanel == "character" ? "active" : string.Empty)"
|
||||
@onclick="SetMobilePanelCharacterAsync">Character
|
||||
<button type="button" class="switch @(State.MobilePanel == "character" ? "active" : string.Empty)"
|
||||
@onclick='() => Scope.SetMobilePanelAsync("character")'>
|
||||
Character
|
||||
</button>
|
||||
<button type="button" class="switch @(MobilePanel == "log" ? "active" : string.Empty)"
|
||||
@onclick="SetMobilePanelLogAsync">Log
|
||||
<button type="button" class="switch @(State.MobilePanel == "log" ? "active" : string.Empty)"
|
||||
@onclick='() => Scope.SetMobilePanelAsync("log")'>
|
||||
Log
|
||||
</button>
|
||||
</nav>
|
||||
}
|
||||
else if (IsManagementScreen)
|
||||
else if (State.IsManagementScreen)
|
||||
{
|
||||
<CampaignManagementPanel
|
||||
Campaigns="Campaigns"
|
||||
SelectedCampaignId="SelectedCampaignId"
|
||||
SelectedCampaign="SelectedCampaign"
|
||||
Rulesets="Rulesets"
|
||||
IsMutating="IsMutating"
|
||||
OwnerLabel="OwnerLabel"
|
||||
CanEditCharacter="CanEditCharacter"
|
||||
CanDeleteCharacter="CanDeleteCharacter"
|
||||
CanDeleteCampaign="CanDeleteSelectedCampaign"
|
||||
CampaignSelectionChanged="OnCampaignSelectionChangedAsync"
|
||||
CampaignCreated="OnCampaignCreatedAsync"
|
||||
DeleteCampaignRequested="DeleteSelectedCampaignAsync"
|
||||
CreateCharacterRequested="OpenCreateCharacterModal"
|
||||
EditCharacterRequested="OpenEditCharacterModal"
|
||||
DeleteCharacterRequested="DeleteCharacterAsync"/>
|
||||
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 (IsAdminScreen)
|
||||
else if (State.IsAdminScreen)
|
||||
{
|
||||
<main class="management-screen">
|
||||
@if (IsCurrentUserAdmin)
|
||||
@if (State.IsCurrentUserAdmin)
|
||||
{
|
||||
<section class="card">
|
||||
<div class="section-head">
|
||||
@@ -113,22 +123,22 @@
|
||||
<div class="section-head">
|
||||
<h2>User Management</h2>
|
||||
</div>
|
||||
@if (IsAdminDataLoading)
|
||||
@if (State.IsAdminDataLoading)
|
||||
{
|
||||
<p class="empty">Loading users...</p>
|
||||
}
|
||||
else if (!IsCurrentUserAdmin)
|
||||
else if (!State.IsCurrentUserAdmin)
|
||||
{
|
||||
<p class="empty">Admin role is required to manage users.</p>
|
||||
}
|
||||
else if (AdminUsers.Count == 0)
|
||||
else if (State.AdminUsers.Count == 0)
|
||||
{
|
||||
<p class="empty">No users found.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<ul class="management-list">
|
||||
@foreach (var user in AdminUsers)
|
||||
@foreach (var user in State.AdminUsers)
|
||||
{
|
||||
<li>
|
||||
<div>
|
||||
@@ -139,15 +149,15 @@
|
||||
<div class="skill-chip-actions">
|
||||
<button type="button"
|
||||
class="chip-button"
|
||||
disabled="@(IsMutating || user.Id == User?.Id)"
|
||||
@onclick="() => ToggleAdminRoleAsync(user)">
|
||||
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="@(IsMutating || user.Id == User?.Id)"
|
||||
@onclick="() => DeleteUserAsync(user)">
|
||||
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>
|
||||
@@ -161,10 +171,10 @@
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (Toasts.Count > 0)
|
||||
@if (State.Toasts.Count > 0)
|
||||
{
|
||||
<div class="toast-stack" aria-live="polite" aria-atomic="false">
|
||||
@foreach (var toast in Toasts)
|
||||
@foreach (var toast in State.Toasts)
|
||||
{
|
||||
<div class="toast @(toast.IsError ? "error" : "success")" role="status">
|
||||
<p>@toast.Message</p>
|
||||
@@ -175,35 +185,47 @@
|
||||
</div>
|
||||
|
||||
<CharacterFormModal
|
||||
Visible="ShowCreateCharacterModal"
|
||||
Visible="State.ShowCreateCharacterModal"
|
||||
Title="Create Character"
|
||||
SubmitLabel="Create Character"
|
||||
NameInputId="character-create-name"
|
||||
CampaignInputId="character-create-campaign"
|
||||
OwnerUsernameInputId="character-create-owner"
|
||||
InitialModel="CreateCharacterInitialModel"
|
||||
FormVersion="CreateCharacterFormVersion"
|
||||
InitialModel="State.CreateCharacterInitialModel"
|
||||
FormVersion="State.CreateCharacterFormVersion"
|
||||
EditingCharacterId="null"
|
||||
CampaignOptions="CharacterCampaignOptions"
|
||||
IsMutating="IsMutating"
|
||||
CampaignOptions="State.CharacterCampaignOptions"
|
||||
IsMutating="State.IsMutating"
|
||||
AllowOwnerEdit="false"
|
||||
AvailableUsernames="KnownUsernames"
|
||||
CharacterSaved="OnCharacterCreatedAsync"
|
||||
CancelRequested="CloseCharacterModals"/>
|
||||
AvailableUsernames="State.KnownUsernames"
|
||||
CharacterSaved="Campaigns.OnCharacterCreatedAsync"
|
||||
CancelRequested="Campaigns.CloseCharacterModals"/>
|
||||
|
||||
<CharacterFormModal
|
||||
Visible="ShowEditCharacterModal"
|
||||
Visible="State.ShowEditCharacterModal"
|
||||
Title="Edit Character"
|
||||
SubmitLabel="Save Character"
|
||||
NameInputId="character-edit-name"
|
||||
CampaignInputId="character-edit-campaign"
|
||||
OwnerUsernameInputId="character-edit-owner"
|
||||
InitialModel="EditCharacterInitialModel"
|
||||
FormVersion="EditCharacterFormVersion"
|
||||
EditingCharacterId="EditingCharacterId"
|
||||
CampaignOptions="CharacterCampaignOptions"
|
||||
IsMutating="IsMutating"
|
||||
AllowOwnerEdit="CanEditCharacterOwner"
|
||||
AvailableUsernames="KnownUsernames"
|
||||
CharacterSaved="OnCharacterUpdatedAsync"
|
||||
CancelRequested="CloseCharacterModals"/>
|
||||
InitialModel="State.EditCharacterInitialModel"
|
||||
FormVersion="State.EditCharacterFormVersion"
|
||||
EditingCharacterId="State.EditingCharacterId"
|
||||
CampaignOptions="State.CharacterCampaignOptions"
|
||||
IsMutating="State.IsMutating"
|
||||
AllowOwnerEdit="State.CanEditCharacterOwner"
|
||||
AvailableUsernames="State.KnownUsernames"
|
||||
CharacterSaved="Campaigns.OnCharacterUpdatedAsync"
|
||||
CancelRequested="Campaigns.CloseCharacterModals"/>
|
||||
|
||||
<RolemasterSkillRollModal
|
||||
Visible="State.ShowRolemasterSkillRollModal"
|
||||
SkillName="@(State.PendingRolemasterSkillRoll?.Name ?? string.Empty)"
|
||||
Expression="@(State.PendingRolemasterSkillRoll?.DiceRollDefinition ?? string.Empty)"
|
||||
ModifierText="@State.PendingRolemasterSituationalModifier"
|
||||
ModifierTextChanged="@(text => State.PendingRolemasterSituationalModifier = text)"
|
||||
ErrorMessage="@State.PendingRolemasterSkillRollError"
|
||||
IsMutating="State.IsMutating"
|
||||
IsSubmitting="State.IsSubmittingRolemasterSkillRoll"
|
||||
ConfirmRequested="Play.SubmitRolemasterSkillRollAsync"
|
||||
CancelRequested="Play.CancelRolemasterSkillRollAsync"/>
|
||||
File diff suppressed because it is too large
Load Diff
98
RpgRoller/Components/Pages/WorkspaceAdminCoordinator.cs
Normal file
98
RpgRoller/Components/Pages/WorkspaceAdminCoordinator.cs
Normal file
@@ -0,0 +1,98 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.JSInterop;
|
||||
using RpgRoller.Contracts;
|
||||
using RpgRoller.Domain;
|
||||
|
||||
namespace RpgRoller.Components.Pages;
|
||||
|
||||
[ExcludeFromCodeCoverage]
|
||||
public sealed class WorkspaceAdminCoordinator(WorkspaceState state, WorkspaceFeedbackService feedback, IJSRuntime js, RpgRollerApiClient apiClient, WorkspaceQueryService workspaceQuery, Action clearAuthenticatedState, Func<Task> stopStateEventsAsync, Func<string?, Task> onLoggedOutAsync)
|
||||
{
|
||||
public async Task EnsureAdminUsersLoadedAsync()
|
||||
{
|
||||
if (!state.IsCurrentUserAdmin || state.HasLoadedAdminUsers || state.IsAdminDataLoading)
|
||||
return;
|
||||
|
||||
state.IsAdminDataLoading = true;
|
||||
try
|
||||
{
|
||||
await ReloadAdminUsersAsync();
|
||||
}
|
||||
catch (ApiRequestException ex) when (ex.StatusCode == 401)
|
||||
{
|
||||
clearAuthenticatedState();
|
||||
await stopStateEventsAsync();
|
||||
await onLoggedOutAsync("Session expired. Please log in again.");
|
||||
}
|
||||
catch (ApiRequestException ex)
|
||||
{
|
||||
feedback.SetStatus(ex.Message, true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
state.IsAdminDataLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ToggleAdminRoleAsync(AdminUserSummary user)
|
||||
{
|
||||
if (state.IsMutating || state.User is null || !state.IsCurrentUserAdmin || user.Id == state.User.Id)
|
||||
return;
|
||||
|
||||
state.IsMutating = true;
|
||||
try
|
||||
{
|
||||
IReadOnlyList<string> roles = HasAdminRole(user) ? Array.Empty<string>() : [UserRoles.Admin];
|
||||
_ = await apiClient.RequestAsync<AdminUserSummary>("PUT", $"/api/admin/users/{user.Id}/roles", new UpdateUserRolesRequest(roles));
|
||||
|
||||
await ReloadAdminUsersAsync();
|
||||
feedback.SetStatus("User roles updated.", false);
|
||||
}
|
||||
catch (ApiRequestException ex)
|
||||
{
|
||||
feedback.SetStatus(ex.Message, true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
state.IsMutating = false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DeleteUserAsync(AdminUserSummary user)
|
||||
{
|
||||
if (state.IsMutating || state.User is null || !state.IsCurrentUserAdmin || user.Id == state.User.Id)
|
||||
return;
|
||||
|
||||
var confirmed = await js.InvokeAsync<bool>("confirm", $"Delete user '{user.Username}'?");
|
||||
if (!confirmed)
|
||||
return;
|
||||
|
||||
state.IsMutating = true;
|
||||
try
|
||||
{
|
||||
_ = await apiClient.RequestAsync<bool>("DELETE", $"/api/admin/users/{user.Id}");
|
||||
await ReloadAdminUsersAsync();
|
||||
feedback.SetStatus("User deleted.", false);
|
||||
}
|
||||
catch (ApiRequestException ex)
|
||||
{
|
||||
feedback.SetStatus(ex.Message, true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
state.IsMutating = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReloadAdminUsersAsync()
|
||||
{
|
||||
state.AdminUsers = (await workspaceQuery.GetAdminUsersAsync()).OrderBy(user => user.Username, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
|
||||
state.HasLoadedAdminUsers = true;
|
||||
}
|
||||
|
||||
private static bool HasAdminRole(AdminUserSummary user)
|
||||
{
|
||||
return user.Roles.Contains(UserRoles.Admin, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
161
RpgRoller/Components/Pages/WorkspaceCampaignCoordinator.cs
Normal file
161
RpgRoller/Components/Pages/WorkspaceCampaignCoordinator.cs
Normal file
@@ -0,0 +1,161 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.JSInterop;
|
||||
using RpgRoller.Contracts;
|
||||
|
||||
namespace RpgRoller.Components.Pages;
|
||||
|
||||
[ExcludeFromCodeCoverage]
|
||||
public sealed class WorkspaceCampaignCoordinator(WorkspaceState state, WorkspaceFeedbackService feedback, IJSRuntime js, RpgRollerApiClient apiClient, Func<Task> loadKnownUsernamesAsync, Func<Guid?, Task> reloadCampaignsAsync, Func<Task> reloadCharacterCampaignOptionsAsync, Func<Task> refreshCampaignScopeAsync, Func<Task> syncStateEventsAsync)
|
||||
{
|
||||
public async Task OnCampaignSelectionChangedAsync(ChangeEventArgs args)
|
||||
{
|
||||
if (!Guid.TryParse(args.Value?.ToString(), out var campaignId))
|
||||
return;
|
||||
|
||||
state.SelectedCampaignId = campaignId;
|
||||
await js.InvokeVoidAsync("rpgRollerApi.setSessionValue", CampaignSessionKey, campaignId.ToString());
|
||||
await refreshCampaignScopeAsync();
|
||||
await syncStateEventsAsync();
|
||||
state.IsScreenMenuOpen = false;
|
||||
}
|
||||
|
||||
public async Task OnCampaignCreatedAsync(Guid campaignId)
|
||||
{
|
||||
await reloadCampaignsAsync(campaignId);
|
||||
await reloadCharacterCampaignOptionsAsync();
|
||||
await refreshCampaignScopeAsync();
|
||||
await syncStateEventsAsync();
|
||||
feedback.SetStatus("Campaign created.", false);
|
||||
}
|
||||
|
||||
public void OpenCreateCharacterModal()
|
||||
{
|
||||
state.CreateCharacterInitialModel = new()
|
||||
{
|
||||
Name = string.Empty,
|
||||
CampaignId = state.SelectedCampaignId?.ToString() ?? state.CharacterCampaignOptions.FirstOrDefault()?.Id.ToString() ?? string.Empty,
|
||||
OwnerUsername = string.Empty
|
||||
};
|
||||
|
||||
state.CreateCharacterFormVersion += 1;
|
||||
state.CanEditCharacterOwner = false;
|
||||
state.ShowCreateCharacterModal = true;
|
||||
}
|
||||
|
||||
public async Task OpenEditCharacterModal(CharacterSummary character)
|
||||
{
|
||||
if (state.IsCurrentUserGm || state.IsCurrentUserAdmin)
|
||||
await loadKnownUsernamesAsync();
|
||||
|
||||
state.EditingCharacterId = character.Id;
|
||||
state.EditCharacterInitialModel = new()
|
||||
{
|
||||
Name = character.Name,
|
||||
CampaignId = character.CampaignId?.ToString() ?? string.Empty,
|
||||
OwnerUsername = string.Empty
|
||||
};
|
||||
|
||||
state.EditCharacterFormVersion += 1;
|
||||
state.CanEditCharacterOwner = state.IsCurrentUserGm || state.IsCurrentUserAdmin;
|
||||
state.ShowEditCharacterModal = true;
|
||||
}
|
||||
|
||||
public void CloseCharacterModals()
|
||||
{
|
||||
state.ShowCreateCharacterModal = false;
|
||||
state.ShowEditCharacterModal = false;
|
||||
state.CanEditCharacterOwner = false;
|
||||
state.EditingCharacterId = null;
|
||||
}
|
||||
|
||||
public async Task OnCharacterCreatedAsync(Guid? campaignId)
|
||||
{
|
||||
CloseCharacterModals();
|
||||
await reloadCampaignsAsync(campaignId);
|
||||
await reloadCharacterCampaignOptionsAsync();
|
||||
await refreshCampaignScopeAsync();
|
||||
await syncStateEventsAsync();
|
||||
feedback.SetStatus("Character created.", false);
|
||||
}
|
||||
|
||||
public async Task OnCharacterUpdatedAsync(Guid? campaignId)
|
||||
{
|
||||
CloseCharacterModals();
|
||||
await reloadCampaignsAsync(campaignId);
|
||||
await reloadCharacterCampaignOptionsAsync();
|
||||
await refreshCampaignScopeAsync();
|
||||
await syncStateEventsAsync();
|
||||
feedback.SetStatus(campaignId.HasValue ? "Character updated." : "Character unlinked from campaign.", false);
|
||||
}
|
||||
|
||||
public async Task DeleteSelectedCampaignAsync()
|
||||
{
|
||||
if (state.SelectedCampaign is null || state.IsMutating || !state.CanDeleteSelectedCampaign)
|
||||
return;
|
||||
|
||||
var confirmed = await js.InvokeAsync<bool>("confirm", $"Delete campaign '{state.SelectedCampaign.Name}'?");
|
||||
if (!confirmed)
|
||||
return;
|
||||
|
||||
state.IsMutating = true;
|
||||
try
|
||||
{
|
||||
_ = await apiClient.RequestAsync<bool>("DELETE", $"/api/campaigns/{state.SelectedCampaign.Id}");
|
||||
await reloadCampaignsAsync(null);
|
||||
await reloadCharacterCampaignOptionsAsync();
|
||||
await refreshCampaignScopeAsync();
|
||||
await syncStateEventsAsync();
|
||||
feedback.SetStatus("Campaign deleted.", false);
|
||||
}
|
||||
catch (ApiRequestException ex)
|
||||
{
|
||||
feedback.SetStatus(ex.Message, true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
state.IsMutating = false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DeleteCharacterAsync(CharacterSummary character)
|
||||
{
|
||||
if (state.IsMutating || !CanDeleteCharacter(character))
|
||||
return;
|
||||
|
||||
var confirmed = await js.InvokeAsync<bool>("confirm", $"Delete character '{character.Name}'?");
|
||||
if (!confirmed)
|
||||
return;
|
||||
|
||||
state.IsMutating = true;
|
||||
try
|
||||
{
|
||||
_ = await apiClient.RequestAsync<bool>("DELETE", $"/api/characters/{character.Id}");
|
||||
await reloadCampaignsAsync(state.SelectedCampaignId);
|
||||
await reloadCharacterCampaignOptionsAsync();
|
||||
await refreshCampaignScopeAsync();
|
||||
await syncStateEventsAsync();
|
||||
feedback.SetStatus("Character deleted.", false);
|
||||
}
|
||||
catch (ApiRequestException ex)
|
||||
{
|
||||
feedback.SetStatus(ex.Message, true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
state.IsMutating = false;
|
||||
}
|
||||
}
|
||||
|
||||
public bool CanEditCharacter(CharacterSummary character)
|
||||
{
|
||||
return state.User is not null && (character.OwnerUserId == state.User.Id || state.IsCurrentUserGm || state.IsCurrentUserAdmin);
|
||||
}
|
||||
|
||||
public bool CanDeleteCharacter(CharacterSummary character)
|
||||
{
|
||||
return state.User is not null && (character.OwnerUserId == state.User.Id || state.IsCurrentUserAdmin);
|
||||
}
|
||||
|
||||
private const string CampaignSessionKey = "campaign";
|
||||
}
|
||||
123
RpgRoller/Components/Pages/WorkspaceCampaignScopeCoordinator.cs
Normal file
123
RpgRoller/Components/Pages/WorkspaceCampaignScopeCoordinator.cs
Normal file
@@ -0,0 +1,123 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace RpgRoller.Components.Pages;
|
||||
|
||||
[ExcludeFromCodeCoverage]
|
||||
public sealed class WorkspaceCampaignScopeCoordinator(WorkspaceState state, WorkspaceFeedbackService feedback, IJSRuntime js, WorkspaceQueryService workspaceQuery, Func<Task> ensureSelectedCharacterActiveAsync, Func<Task> refreshSelectedCharacterSheetAsync, Func<Guid?, Task> refreshCampaignLogAsync, Action resetCampaignLogDetailState, Action resetCampaignStateTracking, Action clearAuthenticatedState, Func<Task> stopStateEventsAsync, Func<string?, Task> onLoggedOutAsync)
|
||||
{
|
||||
public async Task ReloadCampaignsAsync(Guid? preferredCampaignId)
|
||||
{
|
||||
var campaigns = await workspaceQuery.GetCampaignsAsync();
|
||||
state.Campaigns = campaigns.OrderBy(campaign => campaign.Name, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
|
||||
if (state.Campaigns.Count == 0)
|
||||
{
|
||||
state.SelectedCampaignId = null;
|
||||
await js.InvokeVoidAsync("rpgRollerApi.setSessionValue", CampaignSessionKey, null);
|
||||
return;
|
||||
}
|
||||
|
||||
var campaignIds = state.Campaigns.Select(campaign => campaign.Id).ToHashSet();
|
||||
if (preferredCampaignId.HasValue && campaignIds.Contains(preferredCampaignId.Value))
|
||||
state.SelectedCampaignId = preferredCampaignId.Value;
|
||||
else if (!state.SelectedCampaignId.HasValue || !campaignIds.Contains(state.SelectedCampaignId.Value))
|
||||
state.SelectedCampaignId = state.Campaigns[0].Id;
|
||||
|
||||
await js.InvokeVoidAsync("rpgRollerApi.setSessionValue", CampaignSessionKey, state.SelectedCampaignId?.ToString());
|
||||
}
|
||||
|
||||
public async Task ReloadCharacterCampaignOptionsAsync()
|
||||
{
|
||||
var campaignOptions = await workspaceQuery.GetCharacterCampaignOptionsAsync();
|
||||
state.CharacterCampaignOptions = campaignOptions.OrderBy(campaign => campaign.Name, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
}
|
||||
|
||||
public async Task RefreshCampaignRosterAsync()
|
||||
{
|
||||
if (!state.SelectedCampaignId.HasValue)
|
||||
{
|
||||
state.SelectedCampaign = null;
|
||||
state.SelectedCharacterId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
state.SelectedCampaign = await workspaceQuery.GetCampaignAsync(state.SelectedCampaignId.Value);
|
||||
SyncSelectedCharacter();
|
||||
|
||||
if (state.IsPlayScreen && state.PlaySelectedCharacterId.HasValue && state.SelectedCharacterId != state.PlaySelectedCharacterId)
|
||||
state.SelectedCharacterId = state.PlaySelectedCharacterId;
|
||||
|
||||
await ensureSelectedCharacterActiveAsync();
|
||||
}
|
||||
|
||||
public async Task RefreshCampaignScopeAsync()
|
||||
{
|
||||
if (!state.SelectedCampaignId.HasValue)
|
||||
{
|
||||
state.SelectedCampaign = null;
|
||||
state.SelectedCharacterSkills = [];
|
||||
state.SelectedCharacterSkillGroups = [];
|
||||
state.CampaignLog = [];
|
||||
state.SelectedCharacterId = null;
|
||||
state.ConnectionState = "offline";
|
||||
state.CurrentCampaignState = null;
|
||||
state.CampaignLogCursor = null;
|
||||
resetCampaignLogDetailState();
|
||||
return;
|
||||
}
|
||||
|
||||
state.IsCampaignDataLoading = true;
|
||||
try
|
||||
{
|
||||
await RefreshCampaignRosterAsync();
|
||||
await refreshSelectedCharacterSheetAsync();
|
||||
await refreshCampaignLogAsync(null);
|
||||
resetCampaignStateTracking();
|
||||
}
|
||||
catch (ApiRequestException ex) when (ex.StatusCode == 401)
|
||||
{
|
||||
clearAuthenticatedState();
|
||||
await stopStateEventsAsync();
|
||||
await onLoggedOutAsync("Session expired. Please log in again.");
|
||||
}
|
||||
catch (ApiRequestException ex)
|
||||
{
|
||||
feedback.SetStatus(ex.Message, true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
state.IsCampaignDataLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SetMobilePanelAsync(string panel)
|
||||
{
|
||||
state.MobilePanel = string.Equals(panel, "log", StringComparison.OrdinalIgnoreCase) ? "log" : "character";
|
||||
await js.InvokeVoidAsync("rpgRollerApi.setSessionValue", MobilePanelSessionKey, state.MobilePanel);
|
||||
}
|
||||
|
||||
private void SyncSelectedCharacter()
|
||||
{
|
||||
if (state.SelectedCampaign is null || state.SelectedCampaign.Characters.Length == 0)
|
||||
{
|
||||
state.SelectedCharacterId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
var candidateIds = state.SelectedCampaign.Characters.Select(character => character.Id).ToHashSet();
|
||||
if (state.SelectedCharacterId.HasValue && candidateIds.Contains(state.SelectedCharacterId.Value))
|
||||
return;
|
||||
|
||||
if (state.ActiveCharacterId.HasValue && candidateIds.Contains(state.ActiveCharacterId.Value))
|
||||
{
|
||||
state.SelectedCharacterId = state.ActiveCharacterId;
|
||||
return;
|
||||
}
|
||||
|
||||
state.SelectedCharacterId = state.SelectedCampaign.Characters[0].Id;
|
||||
}
|
||||
|
||||
private const string CampaignSessionKey = "campaign";
|
||||
private const string MobilePanelSessionKey = "play-panel";
|
||||
}
|
||||
45
RpgRoller/Components/Pages/WorkspaceFeedbackService.cs
Normal file
45
RpgRoller/Components/Pages/WorkspaceFeedbackService.cs
Normal file
@@ -0,0 +1,45 @@
|
||||
namespace RpgRoller.Components.Pages;
|
||||
|
||||
public sealed class WorkspaceFeedbackService(WorkspaceState state, Func<Task> requestRefreshAsync)
|
||||
{
|
||||
public void SetStatus(string message, bool isError)
|
||||
{
|
||||
Announce(message);
|
||||
AddToast(message, isError);
|
||||
}
|
||||
|
||||
public void Announce(string message)
|
||||
{
|
||||
state.LiveAnnouncement = message;
|
||||
}
|
||||
|
||||
public void ClearToasts()
|
||||
{
|
||||
state.Toasts.Clear();
|
||||
}
|
||||
|
||||
private void AddToast(string message, bool isError)
|
||||
{
|
||||
var toastId = Guid.NewGuid();
|
||||
state.Toasts.Add(new(toastId, message, isError));
|
||||
_ = DismissToastLaterAsync(toastId);
|
||||
}
|
||||
|
||||
private async Task DismissToastLaterAsync(Guid toastId)
|
||||
{
|
||||
await Task.Delay(ToastDurationMs);
|
||||
|
||||
if (state.Toasts.RemoveAll(toast => toast.Id == toastId) == 0)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
await requestRefreshAsync();
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private const int ToastDurationMs = 3200;
|
||||
}
|
||||
99
RpgRoller/Components/Pages/WorkspaceLiveStateController.cs
Normal file
99
RpgRoller/Components/Pages/WorkspaceLiveStateController.cs
Normal file
@@ -0,0 +1,99 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using RpgRoller.Contracts;
|
||||
|
||||
namespace RpgRoller.Components.Pages;
|
||||
|
||||
[ExcludeFromCodeCoverage]
|
||||
public sealed class WorkspaceLiveStateController(WorkspaceState state, WorkspaceFeedbackService feedback, Func<Guid, Task> startStateEventsAsync, Func<Task> stopStateEventsCoreAsync, Func<Task> refreshCampaignRosterAsync, Func<Task> refreshSelectedCharacterSheetAsync, Func<Guid?, Task> refreshCampaignLogAsync, Func<Task> requestRefreshAsync)
|
||||
{
|
||||
public async Task OnStateEventReceivedAsync(CampaignStateSnapshot state1)
|
||||
{
|
||||
if (state.StateRefreshInProgress)
|
||||
return;
|
||||
|
||||
if (!state.SelectedCampaignId.HasValue || state1.CampaignId != state.SelectedCampaignId.Value)
|
||||
return;
|
||||
|
||||
state.StateRefreshInProgress = true;
|
||||
try
|
||||
{
|
||||
if (state.CurrentCampaignState is null)
|
||||
{
|
||||
state.CurrentCampaignState = state1;
|
||||
return;
|
||||
}
|
||||
|
||||
var previousState = state.CurrentCampaignState;
|
||||
var previousSelectedCharacterId = state.SelectedCharacterId;
|
||||
var previousSelectedCharacterVersion = GetCharacterVersion(previousState, previousSelectedCharacterId);
|
||||
var rosterChanged = state1.RosterVersion != previousState.RosterVersion;
|
||||
var logChanged = state.IsPlayScreen && state1.LogVersion != previousState.LogVersion;
|
||||
|
||||
if (rosterChanged)
|
||||
await refreshCampaignRosterAsync();
|
||||
|
||||
var selectedCharacterChanged = previousSelectedCharacterId != state.SelectedCharacterId;
|
||||
var selectedCharacterVersionChanged = state.IsPlayScreen && !selectedCharacterChanged && GetCharacterVersion(state1, state.SelectedCharacterId) != previousSelectedCharacterVersion;
|
||||
|
||||
if (state.IsPlayScreen && (selectedCharacterChanged || selectedCharacterVersionChanged))
|
||||
await refreshSelectedCharacterSheetAsync();
|
||||
|
||||
if (logChanged)
|
||||
await refreshCampaignLogAsync(state.CampaignLogCursor);
|
||||
|
||||
state.CurrentCampaignState = state1;
|
||||
}
|
||||
finally
|
||||
{
|
||||
state.StateRefreshInProgress = false;
|
||||
await requestRefreshAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task OnConnectionStateChangedAsync(string state1)
|
||||
{
|
||||
state.ConnectionState = state1 switch
|
||||
{
|
||||
"connected" => "connected",
|
||||
"reconnecting" => "reconnecting",
|
||||
_ => "offline"
|
||||
};
|
||||
|
||||
if (state.ConnectionState == "reconnecting")
|
||||
feedback.Announce("Reconnecting to live updates.");
|
||||
|
||||
if (state.ConnectionState == "offline")
|
||||
feedback.Announce("Live updates offline. Use manual refresh.");
|
||||
|
||||
await requestRefreshAsync();
|
||||
}
|
||||
|
||||
public async Task SyncStateEventsAsync()
|
||||
{
|
||||
if (state.User is null || !state.SelectedCampaignId.HasValue || state.IsAdminScreen)
|
||||
{
|
||||
await StopStateEventsAsync();
|
||||
state.ConnectionState = "offline";
|
||||
return;
|
||||
}
|
||||
|
||||
await startStateEventsAsync(state.SelectedCampaignId.Value);
|
||||
state.ConnectionState = "reconnecting";
|
||||
}
|
||||
|
||||
public async Task StopStateEventsAsync()
|
||||
{
|
||||
if (!state.HasInteractiveRenderStarted)
|
||||
return;
|
||||
|
||||
await stopStateEventsCoreAsync();
|
||||
}
|
||||
|
||||
private static long GetCharacterVersion(CampaignStateSnapshot snapshot, Guid? characterId)
|
||||
{
|
||||
if (!characterId.HasValue)
|
||||
return 0;
|
||||
|
||||
return snapshot.CharacterVersions.FirstOrDefault(version => version.CharacterId == characterId.Value)?.Version ?? 0;
|
||||
}
|
||||
}
|
||||
407
RpgRoller/Components/Pages/WorkspacePlayCoordinator.cs
Normal file
407
RpgRoller/Components/Pages/WorkspacePlayCoordinator.cs
Normal file
@@ -0,0 +1,407 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using RpgRoller.Components.Pages.HomeControls;
|
||||
using RpgRoller.Contracts;
|
||||
|
||||
namespace RpgRoller.Components.Pages;
|
||||
|
||||
[ExcludeFromCodeCoverage]
|
||||
public sealed class WorkspacePlayCoordinator(WorkspaceState state, WorkspaceFeedbackService feedback, RpgRollerApiClient apiClient, WorkspaceQueryService workspaceQuery, Func<CharacterSummary, bool> canEditCharacter, Func<Task> requestRefreshAsync)
|
||||
{
|
||||
public async Task RefreshCampaignLogAsync(Guid? afterRollId = null)
|
||||
{
|
||||
if (!state.SelectedCampaignId.HasValue || !state.IsPlayScreen)
|
||||
{
|
||||
state.CampaignLog = [];
|
||||
state.CampaignLogCursor = null;
|
||||
ResetCampaignLogDetailState();
|
||||
return;
|
||||
}
|
||||
|
||||
var previousLogCount = state.CampaignLog.Count;
|
||||
var page = await workspaceQuery.GetCampaignLogPageAsync(state.SelectedCampaignId.Value, afterRollId, CampaignLogWindowSize);
|
||||
Guid? newestRollId = null;
|
||||
if (!afterRollId.HasValue || page.ResetRequired)
|
||||
state.CampaignLog = page.Entries.ToList();
|
||||
else if (page.Entries.Length > 0)
|
||||
{
|
||||
state.CampaignLog.AddRange(page.Entries);
|
||||
if (state.CampaignLog.Count > CampaignLogWindowSize)
|
||||
state.CampaignLog = state.CampaignLog.TakeLast(CampaignLogWindowSize).ToList();
|
||||
}
|
||||
|
||||
var shouldAutoExpandNewest = afterRollId.HasValue && page.Entries.Length > 0;
|
||||
if (!shouldAutoExpandNewest && !afterRollId.HasValue && state.CurrentCampaignState is not null && previousLogCount == 0 && page.Entries.Length > 0)
|
||||
shouldAutoExpandNewest = true;
|
||||
|
||||
if (shouldAutoExpandNewest)
|
||||
{
|
||||
newestRollId = page.Entries[^1].RollId;
|
||||
state.ExpandedCampaignLogRollId = newestRollId;
|
||||
state.FreshCampaignLogRollId = newestRollId;
|
||||
}
|
||||
else if (!afterRollId.HasValue)
|
||||
state.FreshCampaignLogRollId = null;
|
||||
|
||||
state.CampaignLogCursor = page.Cursor ?? afterRollId;
|
||||
TrimCampaignLogDetails();
|
||||
|
||||
if (newestRollId.HasValue)
|
||||
await EnsureRollDetailLoadedAsync(newestRollId.Value);
|
||||
}
|
||||
|
||||
public async Task SelectCharacterAsync(Guid characterId)
|
||||
{
|
||||
state.SelectedCharacterId = characterId;
|
||||
await RefreshSelectedCharacterSheetAsync();
|
||||
await EnsureSelectedCharacterActiveAsync();
|
||||
}
|
||||
|
||||
public async Task RefreshSelectedCharacterSheetAsync()
|
||||
{
|
||||
if (!state.SelectedCharacterId.HasValue || state.SelectedCampaign is null || !state.IsPlayScreen)
|
||||
{
|
||||
state.SelectedCharacterSkills = [];
|
||||
state.SelectedCharacterSkillGroups = [];
|
||||
return;
|
||||
}
|
||||
|
||||
var sheet = await workspaceQuery.GetCharacterSheetAsync(state.SelectedCharacterId.Value);
|
||||
state.SelectedCharacterSkillGroups = sheet.SkillGroups.OrderBy(group => group.Name, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
state.SelectedCharacterSkills = sheet.Skills.OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
}
|
||||
|
||||
public Task EnsureSelectedCharacterActiveAsync()
|
||||
{
|
||||
return EnsureSelectedCharacterActiveCoreAsync();
|
||||
}
|
||||
|
||||
public async Task ToggleRollDetailAsync(Guid rollId)
|
||||
{
|
||||
if (state.ExpandedCampaignLogRollId == rollId)
|
||||
{
|
||||
state.ExpandedCampaignLogRollId = null;
|
||||
if (state.FreshCampaignLogRollId == rollId)
|
||||
state.FreshCampaignLogRollId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
state.ExpandedCampaignLogRollId = rollId;
|
||||
state.FreshCampaignLogRollId = null;
|
||||
await EnsureRollDetailLoadedAsync(rollId);
|
||||
}
|
||||
|
||||
public async Task OnSkillCreatedAsync(Guid _)
|
||||
{
|
||||
await RefreshSelectedCharacterSheetAsync();
|
||||
ResetCampaignStateTracking();
|
||||
feedback.SetStatus("Skill created.", false);
|
||||
}
|
||||
|
||||
public async Task OnSkillUpdatedAsync(Guid _)
|
||||
{
|
||||
await RefreshSelectedCharacterSheetAsync();
|
||||
ResetCampaignStateTracking();
|
||||
feedback.SetStatus("Skill updated.", false);
|
||||
}
|
||||
|
||||
public async Task OnSkillGroupCreatedAsync(Guid _)
|
||||
{
|
||||
await RefreshSelectedCharacterSheetAsync();
|
||||
ResetCampaignStateTracking();
|
||||
feedback.SetStatus("Skill group created.", false);
|
||||
}
|
||||
|
||||
public async Task OnSkillGroupUpdatedAsync(Guid _)
|
||||
{
|
||||
await RefreshSelectedCharacterSheetAsync();
|
||||
ResetCampaignStateTracking();
|
||||
feedback.SetStatus("Skill group updated.", false);
|
||||
}
|
||||
|
||||
public async Task OnSkillDeletedAsync(Guid _)
|
||||
{
|
||||
await RefreshSelectedCharacterSheetAsync();
|
||||
ResetCampaignStateTracking();
|
||||
feedback.SetStatus("Skill deleted.", false);
|
||||
}
|
||||
|
||||
public async Task OnSkillGroupDeletedAsync(Guid _)
|
||||
{
|
||||
await RefreshSelectedCharacterSheetAsync();
|
||||
ResetCampaignStateTracking();
|
||||
feedback.SetStatus("Skill group deleted.", false);
|
||||
}
|
||||
|
||||
public Task OnCharacterPanelErrorAsync(string message)
|
||||
{
|
||||
feedback.SetStatus(message, true);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task OnCampaignLogPanelErrorAsync(string message)
|
||||
{
|
||||
feedback.SetStatus(message, true);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task RollSkillAsync(CharacterSheetSkill skill)
|
||||
{
|
||||
if (state.SelectedCampaign is null)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
try
|
||||
{
|
||||
var roll = await apiClient.RequestAsync<RollResult>("POST", $"/api/skills/{skillId}/roll", new RollSkillRequest(state.RollVisibility, situationalModifier));
|
||||
CloseRolemasterSkillRollModal();
|
||||
await HandleRecordedRollAsync(roll);
|
||||
}
|
||||
catch (ApiRequestException ex)
|
||||
{
|
||||
if (keepModalOpenOnError)
|
||||
state.PendingRolemasterSkillRollError = ex.Message;
|
||||
else
|
||||
feedback.SetStatus(ex.Message, true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
state.IsMutating = false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task OnCustomRollCreatedAsync(RollResult roll)
|
||||
{
|
||||
state.IsMutating = true;
|
||||
try
|
||||
{
|
||||
await HandleRecordedRollAsync(roll);
|
||||
}
|
||||
finally
|
||||
{
|
||||
state.IsMutating = false;
|
||||
}
|
||||
}
|
||||
|
||||
public bool CanEditSkill(CharacterSheetSkill skill)
|
||||
{
|
||||
if (state.SelectedCharacter is null)
|
||||
return false;
|
||||
|
||||
return canEditCharacter(state.SelectedCharacter);
|
||||
}
|
||||
|
||||
public CampaignRollDetail? ResolveRollDetail(Guid rollId)
|
||||
{
|
||||
return state.CampaignLogDetails.GetValueOrDefault(rollId);
|
||||
}
|
||||
|
||||
public bool IsRollDetailLoading(Guid rollId)
|
||||
{
|
||||
return state.CampaignLogDetailsLoading.Contains(rollId);
|
||||
}
|
||||
|
||||
public string? GetRollDetailError(Guid rollId)
|
||||
{
|
||||
return state.CampaignLogDetailErrors.GetValueOrDefault(rollId);
|
||||
}
|
||||
|
||||
public void ResetCampaignLogDetailState()
|
||||
{
|
||||
state.ExpandedCampaignLogRollId = null;
|
||||
state.FreshCampaignLogRollId = null;
|
||||
state.CampaignLogDetails.Clear();
|
||||
state.CampaignLogDetailsLoading.Clear();
|
||||
state.CampaignLogDetailErrors.Clear();
|
||||
}
|
||||
|
||||
public void ResetCampaignStateTracking()
|
||||
{
|
||||
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()
|
||||
{
|
||||
if (!state.SelectedCharacterId.HasValue || state.SelectedCampaign is null)
|
||||
return;
|
||||
|
||||
var character = state.SelectedCampaign.Characters.FirstOrDefault(c => c.Id == state.SelectedCharacterId.Value);
|
||||
if (character is null || !CanActivateCharacter(character, state.User) || state.ActiveCharacterId == character.Id)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
await apiClient.RequestWithoutPayloadAsync("POST", $"/api/characters/{character.Id}/activate");
|
||||
state.ActiveCharacterId = character.Id;
|
||||
}
|
||||
catch (ApiRequestException ex)
|
||||
{
|
||||
feedback.SetStatus(ex.Message, true);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleRecordedRollAsync(RollResult roll)
|
||||
{
|
||||
state.LastRoll = roll;
|
||||
state.CampaignLogDetails[roll.RollId] = ToCampaignRollDetail(roll);
|
||||
state.CampaignLogDetailErrors.Remove(roll.RollId);
|
||||
|
||||
await RefreshCampaignLogAsync(state.CampaignLogCursor);
|
||||
PromoteFreshRoll(roll.RollId);
|
||||
ResetCampaignStateTracking();
|
||||
feedback.SetStatus("Roll recorded.", false);
|
||||
feedback.Announce("Roll result updated.");
|
||||
}
|
||||
|
||||
private void TrimCampaignLogDetails()
|
||||
{
|
||||
var visibleRollIds = state.CampaignLog.Select(entry => entry.RollId).ToHashSet();
|
||||
|
||||
foreach (var rollId in state.CampaignLogDetails.Keys.Where(rollId => !visibleRollIds.Contains(rollId)).ToArray())
|
||||
state.CampaignLogDetails.Remove(rollId);
|
||||
|
||||
foreach (var rollId in state.CampaignLogDetailsLoading.Where(rollId => !visibleRollIds.Contains(rollId)).ToArray())
|
||||
state.CampaignLogDetailsLoading.Remove(rollId);
|
||||
|
||||
foreach (var rollId in state.CampaignLogDetailErrors.Keys.Where(rollId => !visibleRollIds.Contains(rollId)).ToArray())
|
||||
state.CampaignLogDetailErrors.Remove(rollId);
|
||||
|
||||
if (state.ExpandedCampaignLogRollId.HasValue && !visibleRollIds.Contains(state.ExpandedCampaignLogRollId.Value))
|
||||
state.ExpandedCampaignLogRollId = null;
|
||||
|
||||
if (state.FreshCampaignLogRollId.HasValue && !visibleRollIds.Contains(state.FreshCampaignLogRollId.Value))
|
||||
state.FreshCampaignLogRollId = null;
|
||||
}
|
||||
|
||||
private async Task EnsureRollDetailLoadedAsync(Guid rollId)
|
||||
{
|
||||
state.CampaignLogDetailErrors.Remove(rollId);
|
||||
if (state.CampaignLogDetails.ContainsKey(rollId) || state.CampaignLogDetailsLoading.Contains(rollId))
|
||||
return;
|
||||
|
||||
state.CampaignLogDetailsLoading.Add(rollId);
|
||||
try
|
||||
{
|
||||
state.CampaignLogDetails[rollId] = await workspaceQuery.GetRollDetailAsync(rollId);
|
||||
}
|
||||
catch (ApiRequestException ex)
|
||||
{
|
||||
state.CampaignLogDetailErrors[rollId] = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
state.CampaignLogDetailsLoading.Remove(rollId);
|
||||
await requestRefreshAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private void PromoteFreshRoll(Guid rollId)
|
||||
{
|
||||
if (!state.CampaignLog.Any(entry => entry.RollId == rollId))
|
||||
return;
|
||||
|
||||
state.ExpandedCampaignLogRollId = rollId;
|
||||
state.FreshCampaignLogRollId = rollId;
|
||||
}
|
||||
|
||||
private static bool CanActivateCharacter(CharacterSummary character, UserSummary? user)
|
||||
{
|
||||
return user is not null && character.OwnerUserId == user.Id;
|
||||
}
|
||||
|
||||
private static CampaignRollDetail ToCampaignRollDetail(RollResult roll)
|
||||
{
|
||||
return new(roll.RollId, roll.Breakdown, roll.Dice.ToArray());
|
||||
}
|
||||
|
||||
private const int CampaignLogWindowSize = 25;
|
||||
private const int MaxSituationalModifier = 1000;
|
||||
}
|
||||
256
RpgRoller/Components/Pages/WorkspaceSessionCoordinator.cs
Normal file
256
RpgRoller/Components/Pages/WorkspaceSessionCoordinator.cs
Normal file
@@ -0,0 +1,256 @@
|
||||
using Microsoft.JSInterop;
|
||||
using RpgRoller.Contracts;
|
||||
|
||||
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 async Task InitializeAsync()
|
||||
{
|
||||
var storedScreen = await js.InvokeAsync<string?>("rpgRollerApi.getSessionValue", ScreenSessionKey);
|
||||
state.CurrentScreen = NormalizeRequestedScreen(storedScreen) ?? ScreenPlay;
|
||||
|
||||
var storedPanel = await js.InvokeAsync<string?>("rpgRollerApi.getSessionValue", MobilePanelSessionKey);
|
||||
if (string.Equals(storedPanel, "log", StringComparison.OrdinalIgnoreCase))
|
||||
state.MobilePanel = "log";
|
||||
|
||||
var storedRollVisibility = await js.InvokeAsync<string?>("rpgRollerApi.getSessionValue", RollVisibilitySessionKey);
|
||||
state.RollVisibility = NormalizeRollVisibility(storedRollVisibility);
|
||||
|
||||
Guid? preferredCampaignId = null;
|
||||
var storedCampaignId = await js.InvokeAsync<string?>("rpgRollerApi.getSessionValue", CampaignSessionKey);
|
||||
if (Guid.TryParse(storedCampaignId, out var parsedCampaignId))
|
||||
preferredCampaignId = parsedCampaignId;
|
||||
|
||||
await CheckHealthAsync();
|
||||
await LoadRulesetsAsync();
|
||||
|
||||
var reloaded = await ReloadAuthenticatedSessionAsync(preferredCampaignId);
|
||||
if (!reloaded)
|
||||
await onLoggedOutAsync("Session expired. Please log in again.");
|
||||
}
|
||||
|
||||
public async Task RetryAfterHealthIssueAsync()
|
||||
{
|
||||
await CheckHealthAsync();
|
||||
if (!state.HasHealthIssue && state.User is not null)
|
||||
{
|
||||
var reloaded = await ReloadAuthenticatedSessionAsync(state.SelectedCampaignId);
|
||||
if (!reloaded)
|
||||
await onLoggedOutAsync("Session expired. Please log in again.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task LoadKnownUsernamesAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var usernames = await workspaceQuery.GetUsernamesAsync();
|
||||
state.KnownUsernames = usernames.OrderBy(username => username, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
}
|
||||
catch (ApiRequestException ex)
|
||||
{
|
||||
state.KnownUsernames = [];
|
||||
feedback.SetStatus(ex.Message, true);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task LogoutAsync()
|
||||
{
|
||||
if (state.IsMutating)
|
||||
return;
|
||||
|
||||
state.IsMutating = true;
|
||||
try
|
||||
{
|
||||
await apiClient.RequestWithoutPayloadAsync("POST", "/api/auth/logout");
|
||||
}
|
||||
catch (ApiRequestException)
|
||||
{
|
||||
}
|
||||
finally
|
||||
{
|
||||
state.IsMutating = false;
|
||||
}
|
||||
|
||||
ClearAuthenticatedState();
|
||||
await stopStateEventsAsync();
|
||||
await onLoggedOutAsync("Logged out.");
|
||||
}
|
||||
|
||||
public async Task SwitchScreenAsync(string screen)
|
||||
{
|
||||
var targetScreen = NormalizeRequestedScreen(screen) ?? ScreenPlay;
|
||||
if (string.Equals(targetScreen, ScreenAdmin, StringComparison.OrdinalIgnoreCase) && !state.IsCurrentUserAdmin)
|
||||
targetScreen = ScreenPlay;
|
||||
|
||||
state.CurrentScreen = targetScreen;
|
||||
state.IsScreenMenuOpen = false;
|
||||
await PersistScreenPreferenceAsync(state.CurrentScreen);
|
||||
await requestRefreshAsync();
|
||||
|
||||
if (state.User is not null)
|
||||
{
|
||||
await refreshCampaignScopeAsync();
|
||||
await syncStateEventsAsync();
|
||||
}
|
||||
|
||||
if (state.IsAdminScreen)
|
||||
{
|
||||
await ensureAdminUsersLoadedAsync();
|
||||
await requestRefreshAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task OnRollVisibilityChangedAsync(string visibility)
|
||||
{
|
||||
state.RollVisibility = NormalizeRollVisibility(visibility);
|
||||
await js.InvokeVoidAsync("rpgRollerApi.setSessionValue", RollVisibilitySessionKey, state.RollVisibility);
|
||||
}
|
||||
|
||||
public void ClearAuthenticatedState()
|
||||
{
|
||||
state.User = null;
|
||||
state.ActiveCharacterId = null;
|
||||
state.SelectedCampaignId = null;
|
||||
state.SelectedCampaign = null;
|
||||
state.Campaigns = [];
|
||||
state.CharacterCampaignOptions = [];
|
||||
state.SelectedCharacterSkills = [];
|
||||
state.SelectedCharacterSkillGroups = [];
|
||||
state.CampaignLog = [];
|
||||
state.CampaignLogCursor = null;
|
||||
resetCampaignLogDetailState();
|
||||
state.SelectedCharacterId = null;
|
||||
state.LastRoll = null;
|
||||
state.KnownUsernames = [];
|
||||
state.ShowCreateCharacterModal = false;
|
||||
state.ShowEditCharacterModal = false;
|
||||
state.CanEditCharacterOwner = false;
|
||||
state.CreateCharacterInitialModel = new();
|
||||
state.EditCharacterInitialModel = new();
|
||||
state.CreateCharacterFormVersion = 0;
|
||||
state.EditCharacterFormVersion = 0;
|
||||
state.AdminUsers = [];
|
||||
state.HasLoadedAdminUsers = false;
|
||||
state.IsAdminDataLoading = false;
|
||||
feedback.ClearToasts();
|
||||
}
|
||||
|
||||
private async Task CheckHealthAsync()
|
||||
{
|
||||
state.HasHealthIssue = false;
|
||||
state.HealthIssueMessage = string.Empty;
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task LoadRulesetsAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
state.Rulesets = (await workspaceQuery.GetRulesetsAsync()).ToList();
|
||||
}
|
||||
catch (ApiRequestException ex)
|
||||
{
|
||||
feedback.SetStatus(ex.Message, true);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> ReloadAuthenticatedSessionAsync(Guid? preferredCampaignId)
|
||||
{
|
||||
var me = await TryGetMeAsync();
|
||||
if (me is null)
|
||||
{
|
||||
ClearAuthenticatedState();
|
||||
await stopStateEventsAsync();
|
||||
return false;
|
||||
}
|
||||
|
||||
state.User = me.User;
|
||||
state.ActiveCharacterId = me.ActiveCharacterId;
|
||||
await EnsureScreenAccessAsync();
|
||||
|
||||
await reloadCampaignsAsync(preferredCampaignId ?? me.CurrentCampaignId);
|
||||
await reloadCharacterCampaignOptionsAsync();
|
||||
await refreshCampaignScopeAsync();
|
||||
await syncStateEventsAsync();
|
||||
|
||||
if (state.IsAdminScreen)
|
||||
await ensureAdminUsersLoadedAsync();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<MeResponse?> TryGetMeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
return await workspaceQuery.GetMeAsync();
|
||||
}
|
||||
catch (ApiRequestException ex) when (ex.StatusCode == 401)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task EnsureScreenAccessAsync()
|
||||
{
|
||||
if (state.IsCurrentUserAdmin)
|
||||
return;
|
||||
|
||||
state.AdminUsers = [];
|
||||
state.HasLoadedAdminUsers = false;
|
||||
|
||||
if (!state.IsAdminScreen)
|
||||
return;
|
||||
|
||||
state.CurrentScreen = ScreenPlay;
|
||||
await PersistScreenPreferenceAsync(state.CurrentScreen);
|
||||
}
|
||||
|
||||
private async Task PersistScreenPreferenceAsync(string screen)
|
||||
{
|
||||
try
|
||||
{
|
||||
await js.InvokeVoidAsync("rpgRollerApi.setSessionValue", ScreenSessionKey, screen);
|
||||
}
|
||||
catch (JSDisconnectedException)
|
||||
{
|
||||
}
|
||||
catch (InvalidOperationException ex) when (IsStaticRenderInteropException(ex))
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeRollVisibility(string? visibility)
|
||||
{
|
||||
return string.Equals(visibility, "private", StringComparison.OrdinalIgnoreCase) ? "private" : "public";
|
||||
}
|
||||
|
||||
private static string? NormalizeRequestedScreen(string? screen)
|
||||
{
|
||||
if (string.Equals(screen, ScreenAdmin, StringComparison.OrdinalIgnoreCase))
|
||||
return ScreenAdmin;
|
||||
|
||||
if (string.Equals(screen, ScreenManagement, StringComparison.OrdinalIgnoreCase))
|
||||
return ScreenManagement;
|
||||
|
||||
if (string.Equals(screen, ScreenPlay, StringComparison.OrdinalIgnoreCase))
|
||||
return ScreenPlay;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool IsStaticRenderInteropException(InvalidOperationException exception)
|
||||
{
|
||||
return exception.Message.Contains("JavaScript interop calls cannot be issued", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private const string ScreenPlay = "play";
|
||||
private const string ScreenManagement = "management";
|
||||
private const string ScreenAdmin = "admin";
|
||||
private const string ScreenSessionKey = "screen";
|
||||
private const string CampaignSessionKey = "campaign";
|
||||
private const string MobilePanelSessionKey = "play-panel";
|
||||
private const string RollVisibilitySessionKey = "roll-visibility";
|
||||
}
|
||||
179
RpgRoller/Components/Pages/WorkspaceState.cs
Normal file
179
RpgRoller/Components/Pages/WorkspaceState.cs
Normal file
@@ -0,0 +1,179 @@
|
||||
using RpgRoller.Components.Pages.HomeControls;
|
||||
using RpgRoller.Contracts;
|
||||
using RpgRoller.Domain;
|
||||
|
||||
namespace RpgRoller.Components.Pages;
|
||||
|
||||
public sealed class WorkspaceState
|
||||
{
|
||||
public string OwnerLabel(Guid ownerUserId)
|
||||
{
|
||||
if (User is not null && ownerUserId == User.Id)
|
||||
return "You";
|
||||
|
||||
if (SelectedCampaign is null)
|
||||
return "Unknown owner";
|
||||
|
||||
if (ownerUserId == SelectedCampaign.Gm.Id)
|
||||
return $"{SelectedCampaign.Gm.DisplayName} (GM)";
|
||||
|
||||
var ownerDisplayName = SelectedCampaign.Characters.Where(character => character.OwnerUserId == ownerUserId).Select(character => character.OwnerDisplayName).FirstOrDefault(displayName => !string.IsNullOrWhiteSpace(displayName));
|
||||
|
||||
return string.IsNullOrWhiteSpace(ownerDisplayName) ? "Unknown owner" : ownerDisplayName;
|
||||
}
|
||||
|
||||
public string SkillDefinitionLabel(CharacterSheetSkill skill)
|
||||
{
|
||||
if (!string.Equals(SelectedCampaign?.RulesetId, "d6", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (string.Equals(SelectedCampaign?.RulesetId, RulesetFormHelpers.RulesetIds.Rolemaster, StringComparison.OrdinalIgnoreCase))
|
||||
return RulesetFormHelpers.DescribeRolemasterExpression(skill.DiceRollDefinition, skill.FumbleRange, skill.RolemasterAutoRetry);
|
||||
|
||||
return skill.DiceRollDefinition;
|
||||
}
|
||||
|
||||
var fumbleLabel = skill.AllowFumble ? "fumble on" : "fumble off";
|
||||
return $"{skill.DiceRollDefinition}, wild {skill.WildDice}, {fumbleLabel}";
|
||||
}
|
||||
|
||||
public UserSummary? User { get; set; }
|
||||
public Guid? ActiveCharacterId { get; set; }
|
||||
public Guid? SelectedCampaignId { get; set; }
|
||||
public CampaignRoster? SelectedCampaign { get; set; }
|
||||
public List<CampaignSummary> Campaigns { get; set; } = [];
|
||||
public List<CampaignOption> CharacterCampaignOptions { get; set; } = [];
|
||||
public List<CharacterSheetSkill> SelectedCharacterSkills { get; set; } = [];
|
||||
public List<CharacterSheetSkillGroup> SelectedCharacterSkillGroups { get; set; } = [];
|
||||
public List<CampaignLogListEntry> CampaignLog { get; set; } = [];
|
||||
public List<RulesetDefinition> Rulesets { get; set; } = [];
|
||||
public List<AdminUserSummary> AdminUsers { get; set; } = [];
|
||||
public Guid? SelectedCharacterId { get; set; }
|
||||
public RollResult? LastRoll { get; set; }
|
||||
public List<string> KnownUsernames { get; set; } = [];
|
||||
public string RollVisibility { get; set; } = "public";
|
||||
|
||||
public bool IsMutating { get; set; }
|
||||
public bool IsCampaignDataLoading { get; set; }
|
||||
public bool IsAdminDataLoading { get; set; }
|
||||
public bool HasLoadedAdminUsers { get; set; }
|
||||
public bool HasHealthIssue { get; set; }
|
||||
public string HealthIssueMessage { get; set; } = "Retry to restore the API connection.";
|
||||
public List<WorkspaceToast> Toasts { get; } = [];
|
||||
public string CurrentScreen { get; set; } = "play";
|
||||
public string MobilePanel { get; set; } = "character";
|
||||
public string ConnectionState { get; set; } = "offline";
|
||||
public string LiveAnnouncement { get; set; } = string.Empty;
|
||||
public bool IsScreenMenuOpen { get; set; }
|
||||
|
||||
public bool ShowCreateCharacterModal { get; set; }
|
||||
public bool ShowEditCharacterModal { get; set; }
|
||||
public bool ShowRolemasterSkillRollModal { get; set; }
|
||||
public bool CanEditCharacterOwner { 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 EditCharacterInitialModel { get; set; } = new();
|
||||
public int CreateCharacterFormVersion { get; set; }
|
||||
public int EditCharacterFormVersion { get; set; }
|
||||
public bool StateRefreshInProgress { get; set; }
|
||||
public bool HasInteractiveRenderStarted { get; set; }
|
||||
public CampaignStateSnapshot? CurrentCampaignState { get; set; }
|
||||
public Guid? CampaignLogCursor { get; set; }
|
||||
public Guid? ExpandedCampaignLogRollId { get; set; }
|
||||
public Guid? FreshCampaignLogRollId { get; set; }
|
||||
public Dictionary<Guid, CampaignRollDetail> CampaignLogDetails { get; } = [];
|
||||
public HashSet<Guid> CampaignLogDetailsLoading { get; } = [];
|
||||
public Dictionary<Guid, string> CampaignLogDetailErrors { get; } = [];
|
||||
|
||||
public string? SelectedCampaignName => SelectedCampaign?.Name ?? Campaigns.FirstOrDefault(campaign => campaign.Id == SelectedCampaignId)?.Name;
|
||||
|
||||
public CharacterSummary? SelectedCharacter =>
|
||||
SelectedCampaign?.Characters.FirstOrDefault(character => character.Id == SelectedCharacterId);
|
||||
|
||||
public CampaignRoster? PlaySelectedCampaign
|
||||
{
|
||||
get
|
||||
{
|
||||
if (SelectedCampaign is null)
|
||||
return null;
|
||||
|
||||
if (User is null)
|
||||
return new(SelectedCampaign.Id, SelectedCampaign.Name, SelectedCampaign.RulesetId, SelectedCampaign.Gm, []);
|
||||
|
||||
var ownedCharacters = SelectedCampaign.Characters.Where(character => character.OwnerUserId == User.Id).ToArray();
|
||||
|
||||
return new(SelectedCampaign.Id, SelectedCampaign.Name, SelectedCampaign.RulesetId, SelectedCampaign.Gm, ownedCharacters);
|
||||
}
|
||||
}
|
||||
|
||||
public CharacterSummary? PlaySelectedCharacter
|
||||
{
|
||||
get
|
||||
{
|
||||
var playSelectedCampaign = PlaySelectedCampaign;
|
||||
if (playSelectedCampaign is null || playSelectedCampaign.Characters.Length == 0)
|
||||
return null;
|
||||
|
||||
if (SelectedCharacterId.HasValue)
|
||||
{
|
||||
var selectedCharacter = playSelectedCampaign.Characters.FirstOrDefault(character => character.Id == SelectedCharacterId.Value);
|
||||
if (selectedCharacter is not null)
|
||||
return selectedCharacter;
|
||||
}
|
||||
|
||||
if (ActiveCharacterId.HasValue)
|
||||
{
|
||||
var activeCharacter = playSelectedCampaign.Characters.FirstOrDefault(character => character.Id == ActiveCharacterId.Value);
|
||||
if (activeCharacter is not null)
|
||||
return activeCharacter;
|
||||
}
|
||||
|
||||
return playSelectedCampaign.Characters[0];
|
||||
}
|
||||
}
|
||||
|
||||
public Guid? PlaySelectedCharacterId => PlaySelectedCharacter?.Id;
|
||||
|
||||
public List<CharacterSheetSkill> PlaySelectedCharacterSkills =>
|
||||
PlaySelectedCampaign is null || !PlaySelectedCharacterId.HasValue ? [] : SelectedCharacterSkills;
|
||||
|
||||
public List<CharacterSheetSkillGroup> PlaySelectedCharacterSkillGroups =>
|
||||
PlaySelectedCampaign is null || !PlaySelectedCharacterId.HasValue ? [] : SelectedCharacterSkillGroups;
|
||||
|
||||
public List<CampaignLogListEntry> PlayVisibleCampaignLog => CampaignLog;
|
||||
|
||||
public bool IsCurrentUserGm =>
|
||||
SelectedCampaign is not null && User is not null && SelectedCampaign.Gm.Id == User.Id;
|
||||
|
||||
public bool IsCurrentUserAdmin =>
|
||||
User is not null && User.Roles.Contains(UserRoles.Admin, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public bool CanDeleteSelectedCampaign =>
|
||||
SelectedCampaign is not null && User is not null && (SelectedCampaign.Gm.Id == User.Id || IsCurrentUserAdmin);
|
||||
|
||||
public bool IsSelectedCampaignD6 =>
|
||||
string.Equals(SelectedCampaign?.RulesetId, "d6", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public bool IsPlayScreen => string.Equals(CurrentScreen, "play", StringComparison.OrdinalIgnoreCase);
|
||||
public bool IsManagementScreen => string.Equals(CurrentScreen, "management", StringComparison.OrdinalIgnoreCase);
|
||||
public bool IsAdminScreen => string.Equals(CurrentScreen, "admin", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public string ConnectionStateLabel => ConnectionState switch
|
||||
{
|
||||
"connected" => "Connected",
|
||||
"reconnecting" => "Reconnecting",
|
||||
_ => "Offline fallback"
|
||||
};
|
||||
|
||||
public string ConnectionStateCssClass => ConnectionState switch
|
||||
{
|
||||
"connected" => "ok",
|
||||
"reconnecting" => "warn",
|
||||
_ => "offline"
|
||||
};
|
||||
|
||||
public string AppCssClass => IsPlayScreen ? "rr-app app-play" : "rr-app";
|
||||
}
|
||||
3
RpgRoller/Components/Pages/WorkspaceToast.cs
Normal file
3
RpgRoller/Components/Pages/WorkspaceToast.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
namespace RpgRoller.Components.Pages;
|
||||
|
||||
public sealed record WorkspaceToast(Guid Id, string Message, bool IsError);
|
||||
@@ -4,26 +4,22 @@ using RpgRoller.Contracts;
|
||||
|
||||
namespace RpgRoller.Components;
|
||||
|
||||
public sealed class RpgRollerApiClient
|
||||
public sealed class RpgRollerApiClient(IJSRuntime js)
|
||||
{
|
||||
private sealed class JsApiResponse
|
||||
{
|
||||
public bool Ok { get; set; }
|
||||
public int Status { get; set; }
|
||||
public string? Error { get; set; }
|
||||
public string? Code { get; set; }
|
||||
public JsonElement Data { get; set; }
|
||||
}
|
||||
|
||||
public RpgRollerApiClient(IJSRuntime js)
|
||||
{
|
||||
m_Js = js;
|
||||
}
|
||||
|
||||
public async Task<T> RequestAsync<T>(string method, string path, object? payload = null)
|
||||
{
|
||||
var response = await m_Js.InvokeAsync<JsApiResponse>("rpgRollerApi.request", method, path, payload);
|
||||
var response = await js.InvokeAsync<JsApiResponse>("rpgRollerApi.request", method, path, payload);
|
||||
if (!response.Ok)
|
||||
throw new ApiRequestException(response.Status, response.Error ?? "Request failed.");
|
||||
throw new ApiRequestException(response.Status, response.Error ?? "Request failed.", response.Code);
|
||||
|
||||
if (response.Data.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null)
|
||||
return default!;
|
||||
@@ -33,21 +29,16 @@ public sealed class RpgRollerApiClient
|
||||
|
||||
public async Task RequestWithoutPayloadAsync(string method, string path)
|
||||
{
|
||||
var response = await m_Js.InvokeAsync<JsApiResponse>("rpgRollerApi.request", method, path, null);
|
||||
var response = await js.InvokeAsync<JsApiResponse>("rpgRollerApi.request", method, path, null);
|
||||
if (!response.Ok)
|
||||
throw new ApiRequestException(response.Status, response.Error ?? "Request failed.");
|
||||
throw new ApiRequestException(response.Status, response.Error ?? "Request failed.", response.Code);
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = RpgRollerJson.CreateSerializerOptions();
|
||||
private readonly IJSRuntime m_Js;
|
||||
}
|
||||
|
||||
public sealed class ApiRequestException : Exception
|
||||
public sealed class ApiRequestException(int statusCode, string message, string? errorCode = null) : Exception(message)
|
||||
{
|
||||
public ApiRequestException(int statusCode, string message) : base(message)
|
||||
{
|
||||
StatusCode = statusCode;
|
||||
}
|
||||
|
||||
public int StatusCode { get; }
|
||||
public int StatusCode { get; } = statusCode;
|
||||
public string? ErrorCode { get; } = errorCode;
|
||||
}
|
||||
@@ -3,72 +3,66 @@ using RpgRoller.Services;
|
||||
|
||||
namespace RpgRoller.Components;
|
||||
|
||||
public sealed class WorkspaceQueryService
|
||||
public sealed class WorkspaceQueryService(IGameService gameService, WorkspaceSessionTokenAccessor sessionTokenAccessor)
|
||||
{
|
||||
public WorkspaceQueryService(IGameService gameService, WorkspaceSessionTokenAccessor sessionTokenAccessor)
|
||||
{
|
||||
m_GameService = gameService;
|
||||
m_SessionTokenAccessor = sessionTokenAccessor;
|
||||
}
|
||||
|
||||
public Task<MeResponse> GetMeAsync()
|
||||
{
|
||||
return Task.FromResult(GetValue(m_GameService.GetMe(GetRequiredSessionToken())));
|
||||
return Task.FromResult(GetValue(gameService.GetMe(GetRequiredSessionToken())));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<RulesetDefinition>> GetRulesetsAsync()
|
||||
{
|
||||
return Task.FromResult(m_GameService.GetRulesets());
|
||||
return Task.FromResult(gameService.GetRulesets());
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<CampaignSummary>> GetCampaignsAsync()
|
||||
{
|
||||
return Task.FromResult(GetValue(m_GameService.GetCampaigns(GetRequiredSessionToken())));
|
||||
return Task.FromResult(GetValue(gameService.GetCampaigns(GetRequiredSessionToken())));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<CampaignOption>> GetCharacterCampaignOptionsAsync()
|
||||
{
|
||||
return Task.FromResult(GetValue(m_GameService.GetCharacterCampaignOptions(GetRequiredSessionToken())));
|
||||
return Task.FromResult(GetValue(gameService.GetCharacterCampaignOptions(GetRequiredSessionToken())));
|
||||
}
|
||||
|
||||
public Task<CampaignRoster> GetCampaignAsync(Guid campaignId)
|
||||
{
|
||||
return Task.FromResult(GetValue(m_GameService.GetCampaign(GetRequiredSessionToken(), campaignId)));
|
||||
return Task.FromResult(GetValue(gameService.GetCampaign(GetRequiredSessionToken(), campaignId)));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<string>> GetUsernamesAsync()
|
||||
{
|
||||
return Task.FromResult(GetValue(m_GameService.GetUsernames(GetRequiredSessionToken())));
|
||||
return Task.FromResult(GetValue(gameService.GetUsernames(GetRequiredSessionToken())));
|
||||
}
|
||||
|
||||
public Task<CharacterSheet> GetCharacterSheetAsync(Guid characterId)
|
||||
{
|
||||
return Task.FromResult(GetValue(m_GameService.GetCharacterSheet(GetRequiredSessionToken(), characterId)));
|
||||
return Task.FromResult(GetValue(gameService.GetCharacterSheet(GetRequiredSessionToken(), characterId)));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<CampaignLogEntry>> GetCampaignLogAsync(Guid campaignId)
|
||||
{
|
||||
return Task.FromResult(GetValue(m_GameService.GetCampaignLog(GetRequiredSessionToken(), campaignId)));
|
||||
return Task.FromResult(GetValue(gameService.GetCampaignLog(GetRequiredSessionToken(), campaignId)));
|
||||
}
|
||||
|
||||
public Task<CampaignLogPage> GetCampaignLogPageAsync(Guid campaignId, Guid? afterRollId = null, int? limit = null)
|
||||
{
|
||||
return Task.FromResult(GetValue(m_GameService.GetCampaignLogPage(GetRequiredSessionToken(), campaignId, afterRollId, limit)));
|
||||
return Task.FromResult(GetValue(gameService.GetCampaignLogPage(GetRequiredSessionToken(), campaignId, afterRollId, limit)));
|
||||
}
|
||||
|
||||
public Task<CampaignRollDetail> GetRollDetailAsync(Guid rollId)
|
||||
{
|
||||
return Task.FromResult(GetValue(m_GameService.GetRollDetail(GetRequiredSessionToken(), rollId)));
|
||||
return Task.FromResult(GetValue(gameService.GetRollDetail(GetRequiredSessionToken(), rollId)));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<AdminUserSummary>> GetAdminUsersAsync()
|
||||
{
|
||||
return Task.FromResult(GetValue(m_GameService.GetUsers(GetRequiredSessionToken())));
|
||||
return Task.FromResult(GetValue(gameService.GetUsers(GetRequiredSessionToken())));
|
||||
}
|
||||
|
||||
private string GetRequiredSessionToken()
|
||||
{
|
||||
return m_SessionTokenAccessor.GetRequiredSessionToken();
|
||||
return sessionTokenAccessor.GetRequiredSessionToken();
|
||||
}
|
||||
|
||||
private static T GetValue<T>(ServiceResult<T> result)
|
||||
@@ -82,9 +76,6 @@ public sealed class WorkspaceQueryService
|
||||
private static ApiRequestException ToApiRequestException(ServiceError error)
|
||||
{
|
||||
var statusCode = error.Code == "unauthorized" ? 401 : 400;
|
||||
return new ApiRequestException(statusCode, error.Message);
|
||||
return new(statusCode, error.Message, error.Code);
|
||||
}
|
||||
|
||||
private readonly IGameService m_GameService;
|
||||
private readonly WorkspaceSessionTokenAccessor m_SessionTokenAccessor;
|
||||
}
|
||||
@@ -10,9 +10,7 @@ public sealed class WorkspaceSessionTokenAccessor
|
||||
if (httpContext is null)
|
||||
return;
|
||||
|
||||
if (httpContext.Items.TryGetValue(SessionTokenItemKey, out var storedToken) &&
|
||||
storedToken is string sessionToken &&
|
||||
!string.IsNullOrWhiteSpace(sessionToken))
|
||||
if (httpContext.Items.TryGetValue(SessionTokenItemKey, out var storedToken) && storedToken is string sessionToken && !string.IsNullOrWhiteSpace(sessionToken))
|
||||
{
|
||||
m_SessionToken = sessionToken;
|
||||
return;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace RpgRoller.Contracts;
|
||||
|
||||
public sealed record HealthResponse(string Status);
|
||||
|
||||
public sealed record ApiError(string Error);
|
||||
public sealed record ApiError(string Error, string? Code = null);
|
||||
|
||||
public sealed record RegisterRequest(string Username, string Password, string DisplayName);
|
||||
|
||||
@@ -34,33 +36,92 @@ 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 CreateSkillRequest(string Name, string DiceRollDefinition, int WildDice, bool AllowFumble, Guid? SkillGroupId = 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);
|
||||
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);
|
||||
public sealed record SkillGroupSummary(Guid Id, Guid CharacterId, string Name, string DiceRollDefinition, int WildDice, bool AllowFumble, int? FumbleRange);
|
||||
|
||||
public sealed record CreateSkillGroupRequest(string Name, string DiceRollDefinition, int WildDice, bool AllowFumble);
|
||||
public sealed record CreateSkillGroupRequest(string Name, string DiceRollDefinition, int WildDice, bool AllowFumble, int? FumbleRange = null);
|
||||
|
||||
public sealed record UpdateSkillGroupRequest(string Name, string DiceRollDefinition, int WildDice, bool AllowFumble);
|
||||
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);
|
||||
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 RollDieResult(int Roll, bool Crit, bool Fumble, bool Wild, bool Removed, bool Added);
|
||||
public sealed record CustomRollRequest(string Expression, string Visibility);
|
||||
|
||||
public static class RollDieKinds
|
||||
{
|
||||
public const string RolemasterStandard = "rolemaster-standard";
|
||||
public const string RolemasterOpenEndedInitial = "rolemaster-open-ended-initial";
|
||||
public const string RolemasterOpenEndedHigh = "rolemaster-open-ended-high";
|
||||
public const string RolemasterOpenEndedLowSubtract = "rolemaster-open-ended-low-subtract";
|
||||
}
|
||||
|
||||
public sealed record RollDieResult
|
||||
{
|
||||
public RollDieResult()
|
||||
{
|
||||
}
|
||||
|
||||
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;
|
||||
Crit = crit;
|
||||
Fumble = fumble;
|
||||
Wild = wild;
|
||||
Removed = removed;
|
||||
Added = added;
|
||||
Sequence = sequence;
|
||||
Kind = kind;
|
||||
SignedContribution = signedContribution;
|
||||
Attempt = attempt;
|
||||
}
|
||||
|
||||
public int Roll { get; init; }
|
||||
public bool Crit { get; init; }
|
||||
public bool Fumble { get; init; }
|
||||
public bool Wild { get; init; }
|
||||
public bool Removed { get; init; }
|
||||
public bool Added { get; init; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public int? Sequence { get; init; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Kind { get; init; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
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 CharacterSheetSkillGroup(Guid Id, string Name, string DiceRollDefinition, int WildDice, bool AllowFumble);
|
||||
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);
|
||||
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 CampaignLogEntry(Guid RollId, Guid CampaignId, Guid CharacterId, string CharacterName, Guid SkillId, string SkillName, Guid RollerUserId, string RollerDisplayName, string Visibility, int Result, string Breakdown, IReadOnlyList<RollDieResult> Dice, DateTimeOffset TimestampUtc);
|
||||
|
||||
public sealed record CampaignLogListEntry(Guid RollId, string CharacterName, string SkillName, string RollerLabel, string VisibilityLabel, string VisibilityStyle, int Result, string SummaryText, DateTimeOffset TimestampUtc);
|
||||
public sealed record CampaignLogListEntry(
|
||||
Guid RollId,
|
||||
string CharacterName,
|
||||
string SkillName,
|
||||
string RollerLabel,
|
||||
string VisibilityLabel,
|
||||
string VisibilityStyle,
|
||||
int Result,
|
||||
string SummaryText,
|
||||
[property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
string[]? EventBadges,
|
||||
DateTimeOffset TimestampUtc);
|
||||
|
||||
public sealed record CampaignRollDetail(Guid RollId, string Breakdown, RollDieResult[] Dice);
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ namespace RpgRoller.Contracts;
|
||||
[JsonSerializable(typeof(CharacterSummary[]))]
|
||||
[JsonSerializable(typeof(CreateCampaignRequest))]
|
||||
[JsonSerializable(typeof(CreateCharacterRequest))]
|
||||
[JsonSerializable(typeof(CustomRollRequest))]
|
||||
[JsonSerializable(typeof(CreateSkillGroupRequest))]
|
||||
[JsonSerializable(typeof(CreateSkillRequest))]
|
||||
[JsonSerializable(typeof(HealthResponse))]
|
||||
|
||||
@@ -3,12 +3,8 @@ using RpgRoller.Domain;
|
||||
|
||||
namespace RpgRoller.Data;
|
||||
|
||||
public sealed class RpgRollerDbContext : DbContext
|
||||
public sealed class RpgRollerDbContext(DbContextOptions<RpgRollerDbContext> options) : DbContext(options)
|
||||
{
|
||||
public RpgRollerDbContext(DbContextOptions<RpgRollerDbContext> options) : base(options)
|
||||
{
|
||||
}
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<UserAccount>(entity =>
|
||||
@@ -54,6 +50,8 @@ public sealed class RpgRollerDbContext : DbContext
|
||||
entity.Property(x => x.DiceRollDefinition).IsRequired().HasMaxLength(128);
|
||||
entity.Property(x => x.WildDice).IsRequired();
|
||||
entity.Property(x => x.AllowFumble).IsRequired();
|
||||
entity.Property(x => x.FumbleRange).IsRequired(false);
|
||||
entity.Property(x => x.RolemasterAutoRetry).IsRequired().HasDefaultValue(false);
|
||||
entity.HasIndex(x => x.CharacterId);
|
||||
entity.HasIndex(x => x.SkillGroupId);
|
||||
});
|
||||
@@ -65,6 +63,7 @@ public sealed class RpgRollerDbContext : DbContext
|
||||
entity.Property(x => x.DiceRollDefinition).IsRequired().HasMaxLength(128);
|
||||
entity.Property(x => x.WildDice).IsRequired();
|
||||
entity.Property(x => x.AllowFumble).IsRequired();
|
||||
entity.Property(x => x.FumbleRange).IsRequired(false);
|
||||
entity.HasIndex(x => x.CharacterId);
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@ namespace RpgRoller.Domain;
|
||||
public enum RulesetKind
|
||||
{
|
||||
D6,
|
||||
Dnd5e
|
||||
Dnd5e,
|
||||
Rolemaster
|
||||
}
|
||||
|
||||
public enum RollVisibility
|
||||
@@ -60,6 +61,7 @@ public sealed class SkillGroup
|
||||
public required string DiceRollDefinition { get; set; }
|
||||
public required int WildDice { get; set; }
|
||||
public required bool AllowFumble { get; set; }
|
||||
public int? FumbleRange { get; set; }
|
||||
}
|
||||
|
||||
public sealed class Skill
|
||||
@@ -71,6 +73,8 @@ public sealed class Skill
|
||||
public required string DiceRollDefinition { get; set; }
|
||||
public required int WildDice { get; set; }
|
||||
public required bool AllowFumble { get; set; }
|
||||
public int? FumbleRange { get; set; }
|
||||
public bool RolemasterAutoRetry { get; set; }
|
||||
}
|
||||
|
||||
public sealed class RollLogEntry
|
||||
@@ -87,4 +91,10 @@ public sealed class RollLogEntry
|
||||
public required DateTimeOffset TimestampUtc { get; init; }
|
||||
}
|
||||
|
||||
public sealed record DiceExpression(int DiceCount, int Sides, int Modifier, string Canonical);
|
||||
public enum DiceExpressionKind
|
||||
{
|
||||
Standard,
|
||||
RolemasterOpenEndedPercentile
|
||||
}
|
||||
|
||||
public sealed record DiceExpression(int DiceCount, int Sides, int Modifier, string Canonical, DiceExpressionKind Kind = DiceExpressionKind.Standard);
|
||||
@@ -8,15 +8,23 @@ public static class SqliteSchemaUpgrader
|
||||
public static void ApplyPendingChanges(RpgRollerDbContext db)
|
||||
{
|
||||
if (db.Database.IsSqlite())
|
||||
{
|
||||
db.Database.OpenConnection();
|
||||
try
|
||||
{
|
||||
EnsureLegacySchemaHistory(db);
|
||||
EnsureSplitMigrationHistory(db);
|
||||
}
|
||||
finally
|
||||
{
|
||||
db.Database.CloseConnection();
|
||||
}
|
||||
}
|
||||
|
||||
db.Database.Migrate();
|
||||
}
|
||||
|
||||
private static void EnsureLegacySchemaHistory(RpgRollerDbContext db)
|
||||
{
|
||||
db.Database.OpenConnection();
|
||||
try
|
||||
{
|
||||
if (TableExists(db, "__EFMigrationsHistory"))
|
||||
return;
|
||||
@@ -36,28 +44,24 @@ public static class SqliteSchemaUpgrader
|
||||
""";
|
||||
_ = createHistoryCommand.ExecuteNonQuery();
|
||||
|
||||
using var insertHistoryCommand = db.Database.GetDbConnection().CreateCommand();
|
||||
insertHistoryCommand.CommandText = """
|
||||
INSERT OR IGNORE INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
|
||||
VALUES ($migrationId, $productVersion);
|
||||
""";
|
||||
|
||||
var migrationParameter = insertHistoryCommand.CreateParameter();
|
||||
migrationParameter.ParameterName = "$migrationId";
|
||||
migrationParameter.Value = InitialMigrationId;
|
||||
insertHistoryCommand.Parameters.Add(migrationParameter);
|
||||
|
||||
var productVersionParameter = insertHistoryCommand.CreateParameter();
|
||||
productVersionParameter.ParameterName = "$productVersion";
|
||||
productVersionParameter.Value = ProductVersion;
|
||||
insertHistoryCommand.Parameters.Add(productVersionParameter);
|
||||
|
||||
_ = insertHistoryCommand.ExecuteNonQuery();
|
||||
InsertMigrationHistory(db, InitialMigrationId);
|
||||
}
|
||||
finally
|
||||
|
||||
private static void EnsureSplitMigrationHistory(RpgRollerDbContext db)
|
||||
{
|
||||
db.Database.CloseConnection();
|
||||
}
|
||||
if (!TableExists(db, "__EFMigrationsHistory"))
|
||||
return;
|
||||
|
||||
if (!MigrationExists(db, CharactersCampaignDeletionMigrationId))
|
||||
return;
|
||||
|
||||
if (MigrationExists(db, AuthorizationRolesMigrationId))
|
||||
return;
|
||||
|
||||
if (!ColumnExists(db, "Users", "Roles"))
|
||||
return;
|
||||
|
||||
InsertMigrationHistory(db, AuthorizationRolesMigrationId);
|
||||
}
|
||||
|
||||
private static bool TableExists(RpgRollerDbContext db, string tableName)
|
||||
@@ -87,6 +91,46 @@ public static class SqliteSchemaUpgrader
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool MigrationExists(RpgRollerDbContext db, string migrationId)
|
||||
{
|
||||
using var command = db.Database.GetDbConnection().CreateCommand();
|
||||
command.CommandText = """
|
||||
SELECT 1
|
||||
FROM "__EFMigrationsHistory"
|
||||
WHERE "MigrationId" = $migrationId
|
||||
LIMIT 1;
|
||||
""";
|
||||
var migrationParameter = command.CreateParameter();
|
||||
migrationParameter.ParameterName = "$migrationId";
|
||||
migrationParameter.Value = migrationId;
|
||||
command.Parameters.Add(migrationParameter);
|
||||
|
||||
return command.ExecuteScalar() is not null;
|
||||
}
|
||||
|
||||
private static void InsertMigrationHistory(RpgRollerDbContext db, string migrationId)
|
||||
{
|
||||
using var insertHistoryCommand = db.Database.GetDbConnection().CreateCommand();
|
||||
insertHistoryCommand.CommandText = """
|
||||
INSERT OR IGNORE INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
|
||||
VALUES ($migrationId, $productVersion);
|
||||
""";
|
||||
|
||||
var migrationParameter = insertHistoryCommand.CreateParameter();
|
||||
migrationParameter.ParameterName = "$migrationId";
|
||||
migrationParameter.Value = migrationId;
|
||||
insertHistoryCommand.Parameters.Add(migrationParameter);
|
||||
|
||||
var productVersionParameter = insertHistoryCommand.CreateParameter();
|
||||
productVersionParameter.ParameterName = "$productVersion";
|
||||
productVersionParameter.Value = ProductVersion;
|
||||
insertHistoryCommand.Parameters.Add(productVersionParameter);
|
||||
|
||||
_ = insertHistoryCommand.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
private const string InitialMigrationId = "20260226084000_InitialSchema";
|
||||
private const string CharactersCampaignDeletionMigrationId = "20260226160859_AddAuthorizationRolesAndCampaignDeletion";
|
||||
private const string AuthorizationRolesMigrationId = "20260226170000_AddAuthorizationRoles";
|
||||
private const string ProductVersion = "10.0.2";
|
||||
}
|
||||
@@ -211,11 +211,6 @@ namespace RpgRoller.Migrations
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Roles")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
|
||||
@@ -11,14 +11,6 @@ namespace RpgRoller.Migrations
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Roles",
|
||||
table: "Users",
|
||||
type: "TEXT",
|
||||
maxLength: 256,
|
||||
nullable: false,
|
||||
defaultValue: "admin");
|
||||
|
||||
migrationBuilder.AlterColumn<Guid>(
|
||||
name: "CampaignId",
|
||||
table: "Characters",
|
||||
@@ -26,21 +18,11 @@ namespace RpgRoller.Migrations
|
||||
nullable: true,
|
||||
oldClrType: typeof(Guid),
|
||||
oldType: "TEXT");
|
||||
|
||||
migrationBuilder.Sql("""
|
||||
UPDATE Users
|
||||
SET Roles = 'admin'
|
||||
WHERE Roles IS NULL OR TRIM(Roles) = '';
|
||||
""");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Roles",
|
||||
table: "Users");
|
||||
|
||||
migrationBuilder.AlterColumn<Guid>(
|
||||
name: "CampaignId",
|
||||
table: "Characters",
|
||||
|
||||
258
RpgRoller/Migrations/20260226170000_AddAuthorizationRoles.Designer.cs
generated
Normal file
258
RpgRoller/Migrations/20260226170000_AddAuthorizationRoles.Designer.cs
generated
Normal file
@@ -0,0 +1,258 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using RpgRoller.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace RpgRoller.Migrations
|
||||
{
|
||||
[DbContext(typeof(RpgRollerDbContext))]
|
||||
[Migration("20260226170000_AddAuthorizationRoles")]
|
||||
partial class AddAuthorizationRoles
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.2");
|
||||
|
||||
modelBuilder.Entity("RpgRoller.Domain.Campaign", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("GmUserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Ruleset")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("Version")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("GmUserId");
|
||||
|
||||
b.ToTable("Campaigns");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("RpgRoller.Domain.Character", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("CampaignId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("OwnerUserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CampaignId");
|
||||
|
||||
b.HasIndex("OwnerUserId");
|
||||
|
||||
b.ToTable("Characters");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("RpgRoller.Domain.RollLogEntry", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Breakdown")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("CampaignId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("CharacterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Dice")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Result")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("RollerUserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("SkillId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("TimestampUtc")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Visibility")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CampaignId");
|
||||
|
||||
b.HasIndex("CharacterId");
|
||||
|
||||
b.HasIndex("RollerUserId");
|
||||
|
||||
b.HasIndex("SkillId");
|
||||
|
||||
b.ToTable("RollLogEntries");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("RpgRoller.Domain.Skill", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("AllowFumble")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("CharacterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DiceRollDefinition")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("SkillGroupId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("WildDice")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CharacterId");
|
||||
|
||||
b.HasIndex("SkillGroupId");
|
||||
|
||||
b.ToTable("Skills");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("RpgRoller.Domain.SkillGroup", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("AllowFumble")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("CharacterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DiceRollDefinition")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("WildDice")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CharacterId");
|
||||
|
||||
b.ToTable("SkillGroups");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("RpgRoller.Domain.UserAccount", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("ActiveCharacterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Roles")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("UsernameNormalized")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UsernameNormalized")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Users");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("RpgRoller.Domain.UserSession", b =>
|
||||
{
|
||||
b.Property<string>("Token")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Token");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("Sessions");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
30
RpgRoller/Migrations/20260226170000_AddAuthorizationRoles.cs
Normal file
30
RpgRoller/Migrations/20260226170000_AddAuthorizationRoles.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace RpgRoller.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddAuthorizationRoles : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Roles",
|
||||
table: "Users",
|
||||
type: "TEXT",
|
||||
maxLength: 256,
|
||||
nullable: false,
|
||||
defaultValue: "admin");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Roles",
|
||||
table: "Users");
|
||||
}
|
||||
}
|
||||
}
|
||||
264
RpgRoller/Migrations/20260402222501_AddRolemasterFumbleRange.Designer.cs
generated
Normal file
264
RpgRoller/Migrations/20260402222501_AddRolemasterFumbleRange.Designer.cs
generated
Normal file
@@ -0,0 +1,264 @@
|
||||
// <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("20260402222501_AddRolemasterFumbleRange")]
|
||||
partial class AddRolemasterFumbleRange
|
||||
{
|
||||
/// <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<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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace RpgRoller.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddRolemasterFumbleRange : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "FumbleRange",
|
||||
table: "Skills",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "FumbleRange",
|
||||
table: "SkillGroups",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "FumbleRange",
|
||||
table: "Skills");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "FumbleRange",
|
||||
table: "SkillGroups");
|
||||
}
|
||||
}
|
||||
}
|
||||
269
RpgRoller/Migrations/20260414204309_AddRolemasterAutoRetry.Designer.cs
generated
Normal file
269
RpgRoller/Migrations/20260414204309_AddRolemasterAutoRetry.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -138,11 +138,19 @@ namespace RpgRoller.Migrations
|
||||
.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");
|
||||
|
||||
@@ -175,6 +183,9 @@ namespace RpgRoller.Migrations
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("FumbleRange")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
|
||||
@@ -34,10 +34,10 @@ if (!string.IsNullOrWhiteSpace(configuredPathBase))
|
||||
}
|
||||
|
||||
app.UseResponseCompression();
|
||||
app.UseStaticFiles();
|
||||
app.UseAntiforgery();
|
||||
|
||||
app.MapRpgRollerApi();
|
||||
app.MapStaticAssets();
|
||||
app.MapRazorComponents<App>().AddInteractiveServerRenderMode();
|
||||
app.Run();
|
||||
|
||||
|
||||
116
RpgRoller/Services/CampaignLogSummaryBuilder.cs
Normal file
116
RpgRoller/Services/CampaignLogSummaryBuilder.cs
Normal file
@@ -0,0 +1,116 @@
|
||||
using RpgRoller.Contracts;
|
||||
using RpgRoller.Domain;
|
||||
|
||||
namespace RpgRoller.Services;
|
||||
|
||||
public static class CampaignLogSummaryBuilder
|
||||
{
|
||||
public static string BuildCompactLogSummary(IReadOnlyList<RollDieResult> dice, string? breakdown = null)
|
||||
{
|
||||
if (dice.Count == 0)
|
||||
return "No detail available.";
|
||||
|
||||
if (dice.Any(die => IsRolemasterDieKind(die.Kind)))
|
||||
return BuildRolemasterCompactLogSummary(dice, breakdown);
|
||||
|
||||
return string.Join(", ", dice.Select(die => die.Roll.ToString()));
|
||||
}
|
||||
|
||||
public static string[]? BuildCompactLogEventBadges(RulesetKind ruleset, string? expression, IReadOnlyList<RollDieResult> dice, string? breakdown = null)
|
||||
{
|
||||
var badges = new List<string>();
|
||||
|
||||
switch (ruleset)
|
||||
{
|
||||
case RulesetKind.D6:
|
||||
AddBadgeIfMissing(badges, dice.Any(die => die is { Wild: true, Roll: 6 }), "w6");
|
||||
AddBadgeIfMissing(badges, dice.Any(die => die is { Wild: true, Roll: 1 }), "w1");
|
||||
break;
|
||||
case RulesetKind.Dnd5e:
|
||||
if (!string.IsNullOrWhiteSpace(expression) && IsSingleD20Expression(expression))
|
||||
{
|
||||
AddBadgeIfMissing(badges, dice.Any(die => die.Roll == 20), "n20");
|
||||
AddBadgeIfMissing(badges, dice.Any(die => die.Roll == 1), "n1");
|
||||
}
|
||||
|
||||
break;
|
||||
case RulesetKind.Rolemaster:
|
||||
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 == 66), "r66");
|
||||
AddRetryBadgeIfPresent(badges, breakdown);
|
||||
break;
|
||||
}
|
||||
|
||||
return badges.Count == 0 ? null : badges.ToArray();
|
||||
}
|
||||
|
||||
public static string? ExtractCustomRollExpression(string breakdown, string separator)
|
||||
{
|
||||
var separatorIndex = breakdown.IndexOf(separator, StringComparison.Ordinal);
|
||||
if (separatorIndex <= 0)
|
||||
return null;
|
||||
|
||||
return breakdown[..separatorIndex];
|
||||
}
|
||||
|
||||
private static string BuildRolemasterCompactLogSummary(IReadOnlyList<RollDieResult> dice, string? breakdown)
|
||||
{
|
||||
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)
|
||||
{
|
||||
var highFollowUps = summaryDice.Where(die => string.Equals(die.Kind, RollDieKinds.RolemasterOpenEndedHigh, StringComparison.Ordinal)).Select(die => die.Roll.ToString()).ToArray();
|
||||
if (highFollowUps.Length > 0)
|
||||
return AppendRetryNote($"{openEndedInitial.Roll} + {string.Join(" + ", highFollowUps)} | open-ended high", retryBonus);
|
||||
|
||||
var lowFollowUps = summaryDice.Where(die => string.Equals(die.Kind, RollDieKinds.RolemasterOpenEndedLowSubtract, StringComparison.Ordinal)).Select(die => die.Roll.ToString()).ToArray();
|
||||
if (lowFollowUps.Length > 0)
|
||||
return AppendRetryNote($"({RollBreakdownFormatter.FormatRolemasterTriggerRoll(openEndedInitial.Roll)}) {string.Join(" ", lowFollowUps.Select(roll => $"-{roll}"))} | open-ended low", retryBonus);
|
||||
|
||||
return AppendRetryNote($"{openEndedInitial.Roll} | open-ended", retryBonus);
|
||||
}
|
||||
|
||||
if (summaryDice.Any(die => string.Equals(die.Kind, RollDieKinds.RolemasterStandard, StringComparison.Ordinal)))
|
||||
{
|
||||
var preview = string.Join(" + ", summaryDice.Select(die => die.Roll.ToString()));
|
||||
return AppendRetryNote($"{preview} | rolemaster", retryBonus);
|
||||
}
|
||||
|
||||
return AppendRetryNote(string.Join(", ", summaryDice.Select(die => die.Roll.ToString())), retryBonus);
|
||||
}
|
||||
|
||||
private static bool IsRolemasterDieKind(string? kind)
|
||||
{
|
||||
return kind is RollDieKinds.RolemasterStandard or RollDieKinds.RolemasterOpenEndedInitial or RollDieKinds.RolemasterOpenEndedHigh or RollDieKinds.RolemasterOpenEndedLowSubtract;
|
||||
}
|
||||
|
||||
private static void AddBadgeIfMissing(List<string> badges, bool condition, string code)
|
||||
{
|
||||
if (!condition || badges.Any(badge => string.Equals(badge, code, StringComparison.Ordinal)))
|
||||
return;
|
||||
|
||||
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)
|
||||
{
|
||||
var parsedExpression = DiceRules.ParseExpression(RulesetKind.Dnd5e, expression);
|
||||
return parsedExpression.Succeeded && parsedExpression.Value!.DiceCount == 1 && parsedExpression.Value.Sides == 20;
|
||||
}
|
||||
}
|
||||
18
RpgRoller/Services/CustomRollOptionsResolver.cs
Normal file
18
RpgRoller/Services/CustomRollOptionsResolver.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
using RpgRoller.Domain;
|
||||
|
||||
namespace RpgRoller.Services;
|
||||
|
||||
public static class CustomRollOptionsResolver
|
||||
{
|
||||
public static (int WildDice, bool AllowFumble, int? FumbleRange) Resolve(RulesetKind ruleset)
|
||||
{
|
||||
return ruleset switch
|
||||
{
|
||||
RulesetKind.D6 => (DefaultCustomD6WildDice, DefaultCustomD6AllowFumble, null),
|
||||
_ => (0, false, null)
|
||||
};
|
||||
}
|
||||
|
||||
private const int DefaultCustomD6WildDice = 1;
|
||||
private const bool DefaultCustomD6AllowFumble = true;
|
||||
}
|
||||
87
RpgRoller/Services/D6RollEngine.cs
Normal file
87
RpgRoller/Services/D6RollEngine.cs
Normal file
@@ -0,0 +1,87 @@
|
||||
using RpgRoller.Contracts;
|
||||
using RpgRoller.Domain;
|
||||
|
||||
namespace RpgRoller.Services;
|
||||
|
||||
public sealed class D6RollEngine(IDiceRoller diceRoller)
|
||||
{
|
||||
public (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) Roll(DiceExpression expression, int wildDice, bool allowFumble)
|
||||
{
|
||||
var initialDice = expression.DiceCount;
|
||||
var currentDice = initialDice;
|
||||
var pendingExplodingDice = 0;
|
||||
var pendingFumbles = 0;
|
||||
var dieResults = new List<RollDieResult>(initialDice);
|
||||
|
||||
for (var i = 0; i < currentDice; i += 1)
|
||||
{
|
||||
var roll = diceRoller.Roll(expression.Sides);
|
||||
var isWild = i < wildDice;
|
||||
var isCrit = false;
|
||||
var isFumble = false;
|
||||
var isAdded = false;
|
||||
|
||||
if (isWild)
|
||||
{
|
||||
if (roll == expression.Sides)
|
||||
{
|
||||
pendingExplodingDice += 1;
|
||||
currentDice += 1;
|
||||
isCrit = true;
|
||||
}
|
||||
else if (allowFumble && roll == 1)
|
||||
{
|
||||
pendingFumbles += 1;
|
||||
isFumble = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingExplodingDice > 0 && i >= initialDice)
|
||||
{
|
||||
pendingExplodingDice -= 1;
|
||||
isAdded = true;
|
||||
if (roll == expression.Sides)
|
||||
{
|
||||
pendingExplodingDice += 1;
|
||||
currentDice += 1;
|
||||
}
|
||||
}
|
||||
|
||||
dieResults.Add(new(roll, isCrit, isFumble, isWild, false, isAdded));
|
||||
}
|
||||
|
||||
for (var roll = expression.Sides; roll >= 1 && pendingFumbles > 0; roll -= 1)
|
||||
for (var i = currentDice - 1; i >= 0 && pendingFumbles > 0; i -= 1)
|
||||
{
|
||||
if (dieResults[i].Roll != roll)
|
||||
continue;
|
||||
|
||||
dieResults[i] = dieResults[i] with
|
||||
{
|
||||
Removed = true,
|
||||
Added = false,
|
||||
Crit = false,
|
||||
Fumble = false
|
||||
};
|
||||
pendingFumbles -= 1;
|
||||
}
|
||||
|
||||
var total = expression.Modifier;
|
||||
var includedDice = new List<int>(dieResults.Count);
|
||||
foreach (var die in dieResults)
|
||||
{
|
||||
if (die.Fumble)
|
||||
{
|
||||
total += 1;
|
||||
includedDice.Add(1);
|
||||
}
|
||||
else if (!die.Removed)
|
||||
{
|
||||
total += die.Roll;
|
||||
includedDice.Add(die.Roll);
|
||||
}
|
||||
}
|
||||
|
||||
return (total, RollBreakdownFormatter.BuildBreakdown(includedDice, expression.Modifier, total), dieResults);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,9 @@ public static partial class DiceRules
|
||||
if (string.Equals(rulesetId, "dnd5e", StringComparison.OrdinalIgnoreCase))
|
||||
return RulesetKind.Dnd5e;
|
||||
|
||||
if (string.Equals(rulesetId, "rolemaster", StringComparison.OrdinalIgnoreCase))
|
||||
return RulesetKind.Rolemaster;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -22,6 +25,7 @@ public static partial class DiceRules
|
||||
{
|
||||
RulesetKind.D6 => "d6",
|
||||
RulesetKind.Dnd5e => "dnd5e",
|
||||
RulesetKind.Rolemaster => "rolemaster",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(ruleset), ruleset, "Unknown ruleset.")
|
||||
};
|
||||
}
|
||||
@@ -36,6 +40,7 @@ public static partial class DiceRules
|
||||
{
|
||||
RulesetKind.D6 => ParseD6(trimmed),
|
||||
RulesetKind.Dnd5e => ParseDnd5e(trimmed),
|
||||
RulesetKind.Rolemaster => ParseRolemaster(trimmed),
|
||||
_ => ServiceResult<DiceExpression>.Failure("invalid_ruleset", "Unknown ruleset.")
|
||||
};
|
||||
}
|
||||
@@ -71,7 +76,31 @@ public static partial class DiceRules
|
||||
return ServiceResult<DiceExpression>.Success(new(diceCount, sides, modifier, $"{diceCount}d{sides}{FormatModifier(modifier)}"));
|
||||
}
|
||||
|
||||
private static ServiceResult<bool> ValidateDiceParts(int diceCount, int sides, int modifier)
|
||||
private static ServiceResult<DiceExpression> ParseRolemaster(string expression)
|
||||
{
|
||||
var match = RolemasterRegex().Match(expression);
|
||||
if (!match.Success)
|
||||
return ServiceResult<DiceExpression>.Failure("invalid_expression", "Expected Rolemaster format like d10+4, 15d10, d100-15, or d100!+85.");
|
||||
|
||||
var countValue = match.Groups["count"].Value;
|
||||
var diceCount = string.IsNullOrEmpty(countValue) ? 1 : int.Parse(countValue);
|
||||
var sides = int.Parse(match.Groups["sides"].Value);
|
||||
var modifier = ParseModifier(match.Groups["modifier"].Value);
|
||||
var validation = ValidateDiceParts(diceCount, sides, modifier, -MaxModifier);
|
||||
if (!validation.Succeeded)
|
||||
return ServiceResult<DiceExpression>.Failure(validation.Error!.Code, validation.Error.Message);
|
||||
|
||||
var isOpenEnded = match.Groups["openEnded"].Success;
|
||||
if (isOpenEnded && (diceCount != 1 || sides != 100))
|
||||
return ServiceResult<DiceExpression>.Failure("invalid_expression", "Open-ended Rolemaster rolls must use d100! with an implicit or explicit dice count of 1.");
|
||||
|
||||
var countPrefix = diceCount == 1 ? string.Empty : diceCount.ToString();
|
||||
var canonical = $"{countPrefix}d{sides}{(isOpenEnded ? "!" : string.Empty)}{FormatModifier(modifier)}";
|
||||
var kind = isOpenEnded ? DiceExpressionKind.RolemasterOpenEndedPercentile : DiceExpressionKind.Standard;
|
||||
return ServiceResult<DiceExpression>.Success(new(diceCount, sides, modifier, canonical, kind));
|
||||
}
|
||||
|
||||
private static ServiceResult<bool> ValidateDiceParts(int diceCount, int sides, int modifier, int minModifier = 0, int maxModifier = MaxModifier)
|
||||
{
|
||||
if (diceCount < 1 || diceCount > MaxDiceCount)
|
||||
return ServiceResult<bool>.Failure("invalid_expression", $"Dice count must be between 1 and {MaxDiceCount}.");
|
||||
@@ -79,8 +108,8 @@ public static partial class DiceRules
|
||||
if (sides < 2 || sides > MaxSides)
|
||||
return ServiceResult<bool>.Failure("invalid_expression", $"Dice sides must be between 2 and {MaxSides}.");
|
||||
|
||||
if (modifier < 0 || modifier > MaxModifier)
|
||||
return ServiceResult<bool>.Failure("invalid_expression", $"Modifier must be between 0 and {MaxModifier}.");
|
||||
if (modifier < minModifier || modifier > maxModifier)
|
||||
return ServiceResult<bool>.Failure("invalid_expression", $"Modifier must be between {minModifier} and {maxModifier}.");
|
||||
|
||||
return ServiceResult<bool>.Success(true);
|
||||
}
|
||||
@@ -92,7 +121,12 @@ public static partial class DiceRules
|
||||
|
||||
private static string FormatModifier(int modifier)
|
||||
{
|
||||
return modifier > 0 ? $"+{modifier}" : string.Empty;
|
||||
return modifier switch
|
||||
{
|
||||
> 0 => $"+{modifier}",
|
||||
< 0 => modifier.ToString(),
|
||||
_ => string.Empty
|
||||
};
|
||||
}
|
||||
|
||||
[GeneratedRegex("^(?<count>\\d+)D(?:\\+(?<modifier>\\d+))?$", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase)]
|
||||
@@ -101,6 +135,9 @@ public static partial class DiceRules
|
||||
[GeneratedRegex("^(?<count>\\d+)d(?<sides>\\d+)(?:\\+(?<modifier>\\d+))?$", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase)]
|
||||
private static partial Regex Dnd5eRegex();
|
||||
|
||||
[GeneratedRegex("^(?<count>\\d+)?d(?<sides>\\d+)(?<openEnded>!)?(?<modifier>[+-]\\d+)?$", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase)]
|
||||
private static partial Regex RolemasterRegex();
|
||||
|
||||
private const int MaxDiceCount = 50;
|
||||
private const int MaxSides = 1000;
|
||||
private const int MaxModifier = 1000;
|
||||
@@ -108,6 +145,7 @@ public static partial class DiceRules
|
||||
public static readonly IReadOnlyList<(RulesetKind Kind, string Id, string Name, string DiceSyntax)> SupportedRulesets =
|
||||
[
|
||||
(RulesetKind.D6, "d6", "D6 System", "countD(+modifier), e.g. 5D+4"),
|
||||
(RulesetKind.Dnd5e, "dnd5e", "D&D 5e", "countdSides(+modifier), e.g. 2d12+2")
|
||||
(RulesetKind.Dnd5e, "dnd5e", "D&D 5e", "countdSides(+modifier), e.g. 2d12+2"),
|
||||
(RulesetKind.Rolemaster, "rolemaster", "Rolemaster", "countdSides(+/-modifier), e.g. d10, 15d10, d100-15, d100!+85")
|
||||
];
|
||||
}
|
||||
133
RpgRoller/Services/GameAuthService.cs
Normal file
133
RpgRoller/Services/GameAuthService.cs
Normal file
@@ -0,0 +1,133 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using RpgRoller.Contracts;
|
||||
using RpgRoller.Domain;
|
||||
|
||||
namespace RpgRoller.Services;
|
||||
|
||||
public sealed class GameAuthService(GameStateStore stateStore, IPasswordHasher<UserAccount> passwordHasher, GamePersistenceService persistenceService)
|
||||
{
|
||||
public ServiceResult<UserSummary> Register(string username, string password, string displayName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(username))
|
||||
return ServiceResult<UserSummary>.Failure("invalid_username", "Username is required.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(displayName))
|
||||
return ServiceResult<UserSummary>.Failure("invalid_display_name", "Display name is required.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(password) || password.Length < 8)
|
||||
return ServiceResult<UserSummary>.Failure("invalid_password", "Password must be at least 8 characters.");
|
||||
|
||||
lock (stateStore.Gate)
|
||||
{
|
||||
var trimmedUsername = username.Trim();
|
||||
var normalizedUsername = NormalizeUsername(trimmedUsername);
|
||||
if (stateStore.UserIdsByUsername.ContainsKey(normalizedUsername))
|
||||
return ServiceResult<UserSummary>.Failure("duplicate_username", "Username is already taken.");
|
||||
|
||||
var user = new UserAccount
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Username = trimmedUsername,
|
||||
UsernameNormalized = normalizedUsername,
|
||||
DisplayName = displayName.Trim(),
|
||||
PasswordHash = string.Empty,
|
||||
Roles = stateStore.UsersById.Count == 0 ? UserRoles.Admin : string.Empty,
|
||||
ActiveCharacterId = null
|
||||
};
|
||||
|
||||
user.PasswordHash = passwordHasher.HashPassword(user, password);
|
||||
|
||||
stateStore.UsersById[user.Id] = user;
|
||||
stateStore.UserIdsByUsername[user.UsernameNormalized] = user.Id;
|
||||
|
||||
persistenceService.PersistStateLocked();
|
||||
return ServiceResult<UserSummary>.Success(GameDtoMapper.ToUserSummary(user));
|
||||
}
|
||||
}
|
||||
|
||||
public ServiceResult<(UserSummary User, string SessionToken)> Login(string username, string password)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
|
||||
return ServiceResult<(UserSummary User, string SessionToken)>.Failure("invalid_credentials", "Invalid username or password.");
|
||||
|
||||
lock (stateStore.Gate)
|
||||
{
|
||||
var normalizedUsername = NormalizeUsername(username.Trim());
|
||||
if (!stateStore.UserIdsByUsername.TryGetValue(normalizedUsername, out var userId))
|
||||
return ServiceResult<(UserSummary User, string SessionToken)>.Failure("invalid_credentials", "Invalid username or password.");
|
||||
|
||||
var user = stateStore.UsersById[userId];
|
||||
var verification = passwordHasher.VerifyHashedPassword(user, user.PasswordHash, password);
|
||||
if (verification == PasswordVerificationResult.Failed)
|
||||
return ServiceResult<(UserSummary User, string SessionToken)>.Failure("invalid_credentials", "Invalid username or password.");
|
||||
|
||||
if (verification == PasswordVerificationResult.SuccessRehashNeeded)
|
||||
user.PasswordHash = passwordHasher.HashPassword(user, password);
|
||||
|
||||
var session = CreateSession(userId);
|
||||
persistenceService.PersistStateLocked();
|
||||
return ServiceResult<(UserSummary User, string SessionToken)>.Success((GameDtoMapper.ToUserSummary(user), session.Token));
|
||||
}
|
||||
}
|
||||
|
||||
public void Logout(string sessionToken)
|
||||
{
|
||||
lock (stateStore.Gate)
|
||||
{
|
||||
if (stateStore.SessionsByToken.Remove(sessionToken))
|
||||
persistenceService.PersistStateLocked();
|
||||
}
|
||||
}
|
||||
|
||||
public UserSummary? GetUserBySession(string sessionToken)
|
||||
{
|
||||
lock (stateStore.Gate)
|
||||
{
|
||||
var user = GameContextResolver.ResolveUserLocked(stateStore, sessionToken);
|
||||
return user is null ? null : GameDtoMapper.ToUserSummary(user);
|
||||
}
|
||||
}
|
||||
|
||||
public ServiceResult<MeResponse> GetMe(string sessionToken)
|
||||
{
|
||||
lock (stateStore.Gate)
|
||||
{
|
||||
var user = GameContextResolver.ResolveUserLocked(stateStore, sessionToken);
|
||||
if (user is null)
|
||||
return ServiceResult<MeResponse>.Failure("unauthorized", "You must be logged in.");
|
||||
|
||||
Guid? campaignId = null;
|
||||
if (user.ActiveCharacterId is Guid activeCharacterId)
|
||||
{
|
||||
if (!stateStore.CharactersById.TryGetValue(activeCharacterId, out var activeCharacter))
|
||||
{
|
||||
user.ActiveCharacterId = null;
|
||||
persistenceService.PersistStateLocked();
|
||||
}
|
||||
else
|
||||
campaignId = activeCharacter.CampaignId;
|
||||
}
|
||||
|
||||
return ServiceResult<MeResponse>.Success(new(GameDtoMapper.ToUserSummary(user), user.ActiveCharacterId, campaignId));
|
||||
}
|
||||
}
|
||||
|
||||
private UserSession CreateSession(Guid userId)
|
||||
{
|
||||
var token = Guid.NewGuid().ToString("N");
|
||||
var session = new UserSession
|
||||
{
|
||||
Token = token,
|
||||
UserId = userId,
|
||||
CreatedAtUtc = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
stateStore.SessionsByToken[token] = session;
|
||||
return session;
|
||||
}
|
||||
|
||||
private static string NormalizeUsername(string username)
|
||||
{
|
||||
return username.ToUpperInvariant();
|
||||
}
|
||||
}
|
||||
33
RpgRoller/Services/GameAuthorization.cs
Normal file
33
RpgRoller/Services/GameAuthorization.cs
Normal file
@@ -0,0 +1,33 @@
|
||||
using RpgRoller.Domain;
|
||||
|
||||
namespace RpgRoller.Services;
|
||||
|
||||
public static class GameAuthorization
|
||||
{
|
||||
public static bool HasRole(UserAccount user, string role)
|
||||
{
|
||||
return RoleSerializer.HasRole(user.Roles, role);
|
||||
}
|
||||
|
||||
public static bool CanViewCampaign(GameStateStore stateStore, Guid actorUserId, Guid campaignId)
|
||||
{
|
||||
if (stateStore.UsersById.TryGetValue(actorUserId, out var user) && HasRole(user, UserRoles.Admin))
|
||||
return true;
|
||||
|
||||
var campaign = stateStore.CampaignsById[campaignId];
|
||||
if (campaign.GmUserId == actorUserId)
|
||||
return true;
|
||||
|
||||
return stateStore.CharactersById.Values.Any(character => character.CampaignId == campaignId && character.OwnerUserId == actorUserId);
|
||||
}
|
||||
|
||||
public static bool CanEditCharacter(Guid actorUserId, Character character, Campaign campaign)
|
||||
{
|
||||
return character.OwnerUserId == actorUserId || campaign.GmUserId == actorUserId;
|
||||
}
|
||||
|
||||
public static bool CanViewRoll(GameStateStore stateStore, Guid actorUserId, Campaign campaign, RollLogEntry entry)
|
||||
{
|
||||
return CanViewCampaign(stateStore, actorUserId, campaign.Id) && (entry.Visibility == RollVisibility.Public || entry.RollerUserId == actorUserId || campaign.GmUserId == actorUserId);
|
||||
}
|
||||
}
|
||||
111
RpgRoller/Services/GameCampaignService.cs
Normal file
111
RpgRoller/Services/GameCampaignService.cs
Normal file
@@ -0,0 +1,111 @@
|
||||
using RpgRoller.Contracts;
|
||||
using RpgRoller.Domain;
|
||||
|
||||
namespace RpgRoller.Services;
|
||||
|
||||
public sealed class GameCampaignService(GameStateStore stateStore, GamePersistenceService persistenceService)
|
||||
{
|
||||
public ServiceResult<CampaignSummary> CreateCampaign(string sessionToken, string name, string rulesetId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return ServiceResult<CampaignSummary>.Failure("invalid_campaign_name", "Campaign name is required.");
|
||||
|
||||
var ruleset = DiceRules.TryParseRulesetId(rulesetId);
|
||||
if (ruleset is null)
|
||||
return ServiceResult<CampaignSummary>.Failure("invalid_ruleset", "Unknown ruleset.");
|
||||
|
||||
lock (stateStore.Gate)
|
||||
{
|
||||
var user = GameContextResolver.ResolveUserLocked(stateStore, sessionToken);
|
||||
if (user is null)
|
||||
return ServiceResult<CampaignSummary>.Failure("unauthorized", "You must be logged in.");
|
||||
|
||||
var campaign = new Campaign
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
GmUserId = user.Id,
|
||||
Name = name.Trim(),
|
||||
Ruleset = ruleset.Value,
|
||||
Version = 1
|
||||
};
|
||||
|
||||
stateStore.CampaignsById[campaign.Id] = campaign;
|
||||
persistenceService.PersistStateLocked();
|
||||
return ServiceResult<CampaignSummary>.Success(GameDtoMapper.ToCampaignSummary(stateStore, campaign));
|
||||
}
|
||||
}
|
||||
|
||||
public ServiceResult<IReadOnlyList<CampaignSummary>> GetCampaigns(string sessionToken)
|
||||
{
|
||||
lock (stateStore.Gate)
|
||||
{
|
||||
var user = GameContextResolver.ResolveUserLocked(stateStore, sessionToken);
|
||||
if (user is null)
|
||||
return ServiceResult<IReadOnlyList<CampaignSummary>>.Failure("unauthorized", "You must be logged in.");
|
||||
|
||||
var results = stateStore.CampaignsById.Values.Where(campaign => GameAuthorization.CanViewCampaign(stateStore, user.Id, campaign.Id)).OrderBy(campaign => campaign.Name, StringComparer.OrdinalIgnoreCase).Select(campaign => GameDtoMapper.ToCampaignSummary(stateStore, campaign)).ToArray();
|
||||
|
||||
return ServiceResult<IReadOnlyList<CampaignSummary>>.Success(results);
|
||||
}
|
||||
}
|
||||
|
||||
public ServiceResult<IReadOnlyList<CampaignOption>> GetCharacterCampaignOptions(string sessionToken)
|
||||
{
|
||||
lock (stateStore.Gate)
|
||||
{
|
||||
var user = GameContextResolver.ResolveUserLocked(stateStore, sessionToken);
|
||||
if (user is null)
|
||||
return ServiceResult<IReadOnlyList<CampaignOption>>.Failure("unauthorized", "You must be logged in.");
|
||||
|
||||
var options = stateStore.CampaignsById.Values.OrderBy(campaign => campaign.Name, StringComparer.OrdinalIgnoreCase).Select(GameDtoMapper.ToCampaignOption).ToArray();
|
||||
|
||||
return ServiceResult<IReadOnlyList<CampaignOption>>.Success(options);
|
||||
}
|
||||
}
|
||||
|
||||
public ServiceResult<CampaignRoster> GetCampaign(string sessionToken, Guid campaignId)
|
||||
{
|
||||
lock (stateStore.Gate)
|
||||
{
|
||||
var context = GameContextResolver.ResolveCampaignContextLocked(stateStore, sessionToken, campaignId);
|
||||
if (!context.Succeeded)
|
||||
return ServiceResult<CampaignRoster>.Failure(context.Error!.Code, context.Error.Message);
|
||||
|
||||
var (_, campaign) = context.Value;
|
||||
return ServiceResult<CampaignRoster>.Success(GameDtoMapper.ToCampaignRoster(stateStore, campaign));
|
||||
}
|
||||
}
|
||||
|
||||
public ServiceResult<bool> DeleteCampaign(string sessionToken, Guid campaignId)
|
||||
{
|
||||
lock (stateStore.Gate)
|
||||
{
|
||||
var user = GameContextResolver.ResolveUserLocked(stateStore, sessionToken);
|
||||
if (user is null)
|
||||
return ServiceResult<bool>.Failure("unauthorized", "You must be logged in.");
|
||||
|
||||
if (!stateStore.CampaignsById.TryGetValue(campaignId, out var campaign))
|
||||
return ServiceResult<bool>.Failure("campaign_not_found", "Campaign was not found.");
|
||||
|
||||
if (campaign.GmUserId != user.Id && !GameAuthorization.HasRole(user, UserRoles.Admin))
|
||||
return ServiceResult<bool>.Failure("forbidden", "Only the campaign owner or admin can delete this campaign.");
|
||||
|
||||
DeleteCampaignLocked(campaignId);
|
||||
persistenceService.PersistStateLocked();
|
||||
return ServiceResult<bool>.Success(true);
|
||||
}
|
||||
}
|
||||
|
||||
private void DeleteCampaignLocked(Guid campaignId)
|
||||
{
|
||||
if (!stateStore.CampaignsById.Remove(campaignId))
|
||||
return;
|
||||
|
||||
var affectedCharacterIds = stateStore.CharactersById.Values.Where(character => character.CampaignId == campaignId).Select(character => character.Id).ToArray();
|
||||
foreach (var characterId in affectedCharacterIds)
|
||||
stateStore.CharactersById[characterId].CampaignId = null;
|
||||
|
||||
stateStore.RollLog.RemoveAll(entry => entry.CampaignId == campaignId);
|
||||
stateStore.CampaignStateById.Remove(campaignId);
|
||||
}
|
||||
}
|
||||
184
RpgRoller/Services/GameCharacterService.cs
Normal file
184
RpgRoller/Services/GameCharacterService.cs
Normal file
@@ -0,0 +1,184 @@
|
||||
using RpgRoller.Contracts;
|
||||
using RpgRoller.Domain;
|
||||
|
||||
namespace RpgRoller.Services;
|
||||
|
||||
public sealed class GameCharacterService(GameStateStore stateStore, GamePersistenceService persistenceService)
|
||||
{
|
||||
public ServiceResult<CharacterSummary> CreateCharacter(string sessionToken, string name, Guid campaignId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return ServiceResult<CharacterSummary>.Failure("invalid_character_name", "Character name is required.");
|
||||
|
||||
lock (stateStore.Gate)
|
||||
{
|
||||
var user = GameContextResolver.ResolveUserLocked(stateStore, sessionToken);
|
||||
if (user is null)
|
||||
return ServiceResult<CharacterSummary>.Failure("unauthorized", "You must be logged in.");
|
||||
|
||||
if (!stateStore.CampaignsById.ContainsKey(campaignId))
|
||||
return ServiceResult<CharacterSummary>.Failure("campaign_not_found", "Campaign was not found.");
|
||||
|
||||
var character = new Character
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OwnerUserId = user.Id,
|
||||
CampaignId = campaignId,
|
||||
Name = name.Trim()
|
||||
};
|
||||
|
||||
stateStore.CharactersById[character.Id] = character;
|
||||
stateStore.AddCharacterStateLocked(character.CampaignId, character.Id);
|
||||
stateStore.TouchRosterLocked(character.CampaignId);
|
||||
|
||||
persistenceService.PersistStateLocked();
|
||||
return ServiceResult<CharacterSummary>.Success(GameDtoMapper.ToCharacterSummary(stateStore, character));
|
||||
}
|
||||
}
|
||||
|
||||
public ServiceResult<CharacterSummary> UpdateCharacter(string sessionToken, Guid characterId, string name, Guid? campaignId, string? ownerUsername = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return ServiceResult<CharacterSummary>.Failure("invalid_character_name", "Character name is required.");
|
||||
|
||||
lock (stateStore.Gate)
|
||||
{
|
||||
var user = GameContextResolver.ResolveUserLocked(stateStore, sessionToken);
|
||||
if (user is null)
|
||||
return ServiceResult<CharacterSummary>.Failure("unauthorized", "You must be logged in.");
|
||||
|
||||
if (!stateStore.CharactersById.TryGetValue(characterId, out var character))
|
||||
return ServiceResult<CharacterSummary>.Failure("character_not_found", "Character was not found.");
|
||||
|
||||
Campaign? targetCampaign = null;
|
||||
if (campaignId.HasValue && !stateStore.CampaignsById.TryGetValue(campaignId.Value, out targetCampaign))
|
||||
return ServiceResult<CharacterSummary>.Failure("campaign_not_found", "Campaign was not found.");
|
||||
|
||||
var isOwner = character.OwnerUserId == user.Id;
|
||||
var isAdmin = GameAuthorization.HasRole(user, UserRoles.Admin);
|
||||
var isSourceGm = character.CampaignId.HasValue && stateStore.CampaignsById.TryGetValue(character.CampaignId.Value, out var sourceCampaign) && sourceCampaign.GmUserId == user.Id;
|
||||
var isTargetGm = targetCampaign is not null && targetCampaign.GmUserId == user.Id;
|
||||
if (!isOwner && !isAdmin && !isSourceGm && !isTargetGm)
|
||||
return ServiceResult<CharacterSummary>.Failure("forbidden", "Only the owner, GM, or admin can edit this character.");
|
||||
|
||||
var sourceCampaignId = character.CampaignId;
|
||||
var previousOwnerUserId = character.OwnerUserId;
|
||||
character.Name = name.Trim();
|
||||
character.CampaignId = campaignId;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(ownerUsername))
|
||||
{
|
||||
var trimmedOwnerUsername = ownerUsername.Trim();
|
||||
var normalizedOwnerUsername = NormalizeUsername(trimmedOwnerUsername);
|
||||
if (!stateStore.UserIdsByUsername.TryGetValue(normalizedOwnerUsername, out var targetOwnerUserId))
|
||||
return ServiceResult<CharacterSummary>.Failure("owner_not_found", "Owner username was not found.");
|
||||
|
||||
if (targetOwnerUserId != character.OwnerUserId && !isAdmin && !isSourceGm && !isTargetGm)
|
||||
return ServiceResult<CharacterSummary>.Failure("forbidden", "Only the GM or admin can change character owner.");
|
||||
|
||||
character.OwnerUserId = targetOwnerUserId;
|
||||
if (character.OwnerUserId != previousOwnerUserId && stateStore.UsersById.TryGetValue(previousOwnerUserId, out var previousOwner) && previousOwner.ActiveCharacterId == character.Id)
|
||||
previousOwner.ActiveCharacterId = null;
|
||||
}
|
||||
|
||||
if (sourceCampaignId != character.CampaignId)
|
||||
{
|
||||
stateStore.RemoveCharacterStateLocked(sourceCampaignId, character.Id);
|
||||
stateStore.AddCharacterStateLocked(character.CampaignId, character.Id);
|
||||
}
|
||||
|
||||
stateStore.TouchRosterLocked(sourceCampaignId);
|
||||
if (sourceCampaignId != character.CampaignId)
|
||||
stateStore.TouchRosterLocked(character.CampaignId);
|
||||
|
||||
persistenceService.PersistStateLocked();
|
||||
return ServiceResult<CharacterSummary>.Success(GameDtoMapper.ToCharacterSummary(stateStore, character));
|
||||
}
|
||||
}
|
||||
|
||||
public ServiceResult<bool> DeleteCharacter(string sessionToken, Guid characterId)
|
||||
{
|
||||
lock (stateStore.Gate)
|
||||
{
|
||||
var user = GameContextResolver.ResolveUserLocked(stateStore, sessionToken);
|
||||
if (user is null)
|
||||
return ServiceResult<bool>.Failure("unauthorized", "You must be logged in.");
|
||||
|
||||
if (!stateStore.CharactersById.TryGetValue(characterId, out var character))
|
||||
return ServiceResult<bool>.Failure("character_not_found", "Character was not found.");
|
||||
|
||||
var isOwner = character.OwnerUserId == user.Id;
|
||||
var isAdmin = GameAuthorization.HasRole(user, UserRoles.Admin);
|
||||
if (!isOwner && !isAdmin)
|
||||
return ServiceResult<bool>.Failure("forbidden", "Only the owner or admin can delete this character.");
|
||||
|
||||
DeleteCharacterLocked(characterId);
|
||||
persistenceService.PersistStateLocked();
|
||||
return ServiceResult<bool>.Success(true);
|
||||
}
|
||||
}
|
||||
|
||||
public ServiceResult<bool> ActivateCharacter(string sessionToken, Guid characterId)
|
||||
{
|
||||
lock (stateStore.Gate)
|
||||
{
|
||||
var user = GameContextResolver.ResolveUserLocked(stateStore, sessionToken);
|
||||
if (user is null)
|
||||
return ServiceResult<bool>.Failure("unauthorized", "You must be logged in.");
|
||||
|
||||
if (!stateStore.CharactersById.TryGetValue(characterId, out var character))
|
||||
return ServiceResult<bool>.Failure("character_not_found", "Character was not found.");
|
||||
|
||||
if (character.OwnerUserId != user.Id)
|
||||
return ServiceResult<bool>.Failure("forbidden", "You can activate only your own character.");
|
||||
|
||||
user.ActiveCharacterId = character.Id;
|
||||
persistenceService.PersistStateLocked();
|
||||
return ServiceResult<bool>.Success(true);
|
||||
}
|
||||
}
|
||||
|
||||
public ServiceResult<IReadOnlyList<CharacterSummary>> GetOwnCharacters(string sessionToken)
|
||||
{
|
||||
lock (stateStore.Gate)
|
||||
{
|
||||
var user = GameContextResolver.ResolveUserLocked(stateStore, sessionToken);
|
||||
if (user is null)
|
||||
return ServiceResult<IReadOnlyList<CharacterSummary>>.Failure("unauthorized", "You must be logged in.");
|
||||
|
||||
var characters = stateStore.CharactersById.Values.Where(character => character.OwnerUserId == user.Id).OrderBy(character => character.Name, StringComparer.OrdinalIgnoreCase).Select(character => GameDtoMapper.ToCharacterSummary(stateStore, character)).ToArray();
|
||||
|
||||
return ServiceResult<IReadOnlyList<CharacterSummary>>.Success(characters);
|
||||
}
|
||||
}
|
||||
|
||||
private void DeleteCharacterLocked(Guid characterId)
|
||||
{
|
||||
if (!stateStore.CharactersById.TryGetValue(characterId, out var character))
|
||||
return;
|
||||
|
||||
var campaignId = character.CampaignId;
|
||||
stateStore.CharactersById.Remove(characterId);
|
||||
|
||||
var skillGroupIds = stateStore.SkillGroupsById.Values.Where(group => group.CharacterId == characterId).Select(group => group.Id).ToHashSet();
|
||||
foreach (var skillGroupId in skillGroupIds)
|
||||
stateStore.SkillGroupsById.Remove(skillGroupId);
|
||||
|
||||
var skillIds = stateStore.SkillsById.Values.Where(skill => skill.CharacterId == characterId).Select(skill => skill.Id).ToHashSet();
|
||||
foreach (var skillId in skillIds)
|
||||
stateStore.SkillsById.Remove(skillId);
|
||||
|
||||
stateStore.RollLog.RemoveAll(entry => entry.CharacterId == characterId || skillIds.Contains(entry.SkillId));
|
||||
|
||||
foreach (var user in stateStore.UsersById.Values.Where(account => account.ActiveCharacterId == characterId))
|
||||
user.ActiveCharacterId = null;
|
||||
|
||||
stateStore.RemoveCharacterStateLocked(campaignId, characterId);
|
||||
stateStore.TouchRosterLocked(campaignId);
|
||||
}
|
||||
|
||||
private static string NormalizeUsername(string username)
|
||||
{
|
||||
return username.ToUpperInvariant();
|
||||
}
|
||||
}
|
||||
46
RpgRoller/Services/GameContextResolver.cs
Normal file
46
RpgRoller/Services/GameContextResolver.cs
Normal file
@@ -0,0 +1,46 @@
|
||||
using RpgRoller.Domain;
|
||||
|
||||
namespace RpgRoller.Services;
|
||||
|
||||
public static class GameContextResolver
|
||||
{
|
||||
public static UserAccount? ResolveUserLocked(GameStateStore stateStore, string sessionToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sessionToken))
|
||||
return null;
|
||||
|
||||
if (!stateStore.SessionsByToken.TryGetValue(sessionToken, out var session))
|
||||
return null;
|
||||
|
||||
return stateStore.UsersById.GetValueOrDefault(session.UserId);
|
||||
}
|
||||
|
||||
public static ServiceResult<(UserAccount User, Campaign Campaign)> ResolveCampaignContextLocked(GameStateStore stateStore, string sessionToken, Guid campaignId)
|
||||
{
|
||||
var user = ResolveUserLocked(stateStore, sessionToken);
|
||||
if (user is null)
|
||||
return ServiceResult<(UserAccount User, Campaign Campaign)>.Failure("unauthorized", "You must be logged in.");
|
||||
|
||||
if (!stateStore.CampaignsById.TryGetValue(campaignId, out var campaign))
|
||||
return ServiceResult<(UserAccount User, Campaign Campaign)>.Failure("campaign_not_found", "Campaign was not found.");
|
||||
|
||||
if (!GameAuthorization.CanViewCampaign(stateStore, user.Id, campaign.Id))
|
||||
return ServiceResult<(UserAccount User, Campaign Campaign)>.Failure("forbidden", "You are not a participant in this campaign.");
|
||||
|
||||
return ServiceResult<(UserAccount User, Campaign Campaign)>.Success((user, campaign));
|
||||
}
|
||||
|
||||
public static bool TryResolveCharacterCampaignLocked(GameStateStore stateStore, Character character, out Campaign campaign, out ServiceError? error)
|
||||
{
|
||||
campaign = null!;
|
||||
if (!character.CampaignId.HasValue || !stateStore.CampaignsById.TryGetValue(character.CampaignId.Value, out var resolvedCampaign))
|
||||
{
|
||||
error = new("character_not_in_campaign", "Character is not linked to a campaign.");
|
||||
return false;
|
||||
}
|
||||
|
||||
campaign = resolvedCampaign;
|
||||
error = null;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
103
RpgRoller/Services/GameDtoMapper.cs
Normal file
103
RpgRoller/Services/GameDtoMapper.cs
Normal file
@@ -0,0 +1,103 @@
|
||||
using RpgRoller.Contracts;
|
||||
using RpgRoller.Domain;
|
||||
|
||||
namespace RpgRoller.Services;
|
||||
|
||||
public static class GameDtoMapper
|
||||
{
|
||||
public static UserSummary ToUserSummary(UserAccount user)
|
||||
{
|
||||
return new(user.Id, user.Username, user.DisplayName, RoleSerializer.Parse(user.Roles));
|
||||
}
|
||||
|
||||
public static AdminUserSummary ToAdminUserSummary(UserAccount user)
|
||||
{
|
||||
return new(user.Id, user.Username, user.DisplayName, RoleSerializer.Parse(user.Roles));
|
||||
}
|
||||
|
||||
public static CampaignOption ToCampaignOption(Campaign campaign)
|
||||
{
|
||||
return new(campaign.Id, campaign.Name);
|
||||
}
|
||||
|
||||
public static CampaignSummary ToCampaignSummary(GameStateStore stateStore, Campaign campaign)
|
||||
{
|
||||
var gm = stateStore.UsersById[campaign.GmUserId];
|
||||
var characterCount = stateStore.CharactersById.Values.Count(character => character.CampaignId == campaign.Id);
|
||||
return new(campaign.Id, campaign.Name, DiceRules.ToRulesetId(campaign.Ruleset), new(gm.Id, gm.DisplayName), characterCount);
|
||||
}
|
||||
|
||||
public static CampaignRoster ToCampaignRoster(GameStateStore stateStore, Campaign campaign)
|
||||
{
|
||||
var gm = stateStore.UsersById[campaign.GmUserId];
|
||||
var characters = stateStore.CharactersById.Values.Where(character => character.CampaignId == campaign.Id).OrderBy(character => character.Name, StringComparer.OrdinalIgnoreCase).Select(character => ToCharacterSummary(stateStore, character)).ToArray();
|
||||
|
||||
return new(campaign.Id, campaign.Name, DiceRules.ToRulesetId(campaign.Ruleset), new(gm.Id, gm.DisplayName), characters);
|
||||
}
|
||||
|
||||
public static CharacterSummary ToCharacterSummary(GameStateStore stateStore, Character character)
|
||||
{
|
||||
return new(character.Id, character.Name, character.OwnerUserId, character.CampaignId, ResolveOwnerDisplayName(stateStore, character.OwnerUserId, "Unknown user"));
|
||||
}
|
||||
|
||||
public static CharacterSheet ToCharacterSheet(GameStateStore stateStore, Guid characterId)
|
||||
{
|
||||
var skillGroups = stateStore.SkillGroupsById.Values.Where(group => group.CharacterId == characterId).OrderBy(group => group.Name, StringComparer.OrdinalIgnoreCase).Select(ToCharacterSheetSkillGroup).ToArray();
|
||||
var skills = stateStore.SkillsById.Values.Where(skill => skill.CharacterId == characterId).OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase).Select(ToCharacterSheetSkill).ToArray();
|
||||
|
||||
return new(characterId, skillGroups, skills);
|
||||
}
|
||||
|
||||
public static SkillGroupSummary ToSkillGroupSummary(SkillGroup skillGroup)
|
||||
{
|
||||
return new(skillGroup.Id, skillGroup.CharacterId, skillGroup.Name, skillGroup.DiceRollDefinition, skillGroup.WildDice, skillGroup.AllowFumble, skillGroup.FumbleRange);
|
||||
}
|
||||
|
||||
public static SkillSummary ToSkillSummary(Skill skill)
|
||||
{
|
||||
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)
|
||||
{
|
||||
return new(entry.Id, entry.CampaignId, entry.CharacterId, entry.SkillId, entry.RollerUserId, entry.Visibility == RollVisibility.Public ? "public" : "private", entry.Result, entry.Breakdown, dice, entry.TimestampUtc);
|
||||
}
|
||||
|
||||
public static CampaignLogEntry ToCampaignLogEntry(RollLogEntry entry, string characterName, string skillName, string rollerDisplayName, IReadOnlyList<RollDieResult> dice)
|
||||
{
|
||||
return new(entry.Id, entry.CampaignId, entry.CharacterId, characterName, entry.SkillId, skillName, entry.RollerUserId, rollerDisplayName, entry.Visibility == RollVisibility.Public ? "public" : "private", entry.Result, entry.Breakdown, dice, entry.TimestampUtc);
|
||||
}
|
||||
|
||||
public static CampaignLogListEntry ToCampaignLogListEntry(RollLogEntry entry, string characterName, string skillName, string rollerLabel, string visibilityLabel, string visibilityStyle, string summaryText, string[]? eventBadges)
|
||||
{
|
||||
return new(entry.Id, characterName, skillName, rollerLabel, visibilityLabel, visibilityStyle, entry.Result, summaryText, eventBadges, entry.TimestampUtc);
|
||||
}
|
||||
|
||||
public static CampaignRollDetail ToCampaignRollDetail(RollLogEntry entry, RollDieResult[] dice)
|
||||
{
|
||||
return new(entry.Id, entry.Breakdown, dice);
|
||||
}
|
||||
|
||||
public static CampaignStateSnapshot ToCampaignStateSnapshot(GameStateStore stateStore, Guid campaignId)
|
||||
{
|
||||
var state = stateStore.GetOrCreateCampaignStateLocked(campaignId);
|
||||
var characterVersions = state.CharacterVersions.OrderBy(version => version.Key).Select(version => new CharacterStateVersion(version.Key, version.Value)).ToArray();
|
||||
|
||||
return new(campaignId, state.TotalVersion, state.RosterVersion, state.LogVersion, characterVersions);
|
||||
}
|
||||
|
||||
public static string ResolveOwnerDisplayName(GameStateStore stateStore, Guid ownerUserId, string fallback)
|
||||
{
|
||||
return stateStore.UsersById.TryGetValue(ownerUserId, out var user) && !string.IsNullOrWhiteSpace(user.DisplayName) ? user.DisplayName : fallback;
|
||||
}
|
||||
|
||||
private static CharacterSheetSkillGroup ToCharacterSheetSkillGroup(SkillGroup skillGroup)
|
||||
{
|
||||
return new(skillGroup.Id, skillGroup.Name, skillGroup.DiceRollDefinition, skillGroup.WildDice, skillGroup.AllowFumble, skillGroup.FumbleRange);
|
||||
}
|
||||
|
||||
private static CharacterSheetSkill ToCharacterSheetSkill(Skill skill)
|
||||
{
|
||||
return new(skill.Id, skill.SkillGroupId, skill.Name, skill.DiceRollDefinition, skill.WildDice, skill.AllowFumble, skill.FumbleRange, skill.RolemasterAutoRetry);
|
||||
}
|
||||
}
|
||||
103
RpgRoller/Services/GamePersistenceService.cs
Normal file
103
RpgRoller/Services/GamePersistenceService.cs
Normal file
@@ -0,0 +1,103 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using RpgRoller.Data;
|
||||
using RpgRoller.Domain;
|
||||
|
||||
namespace RpgRoller.Services;
|
||||
|
||||
public sealed class GamePersistenceService(IDbContextFactory<RpgRollerDbContext> dbContextFactory, GameStateStore stateStore)
|
||||
{
|
||||
public void LoadStateFromDatabase()
|
||||
{
|
||||
using var db = dbContextFactory.CreateDbContext();
|
||||
var users = db.Users.AsNoTracking().ToList();
|
||||
var sessions = db.Sessions.AsNoTracking().ToList();
|
||||
var campaigns = db.Campaigns.AsNoTracking().ToList();
|
||||
var characters = db.Characters.AsNoTracking().ToList();
|
||||
var skillGroups = db.SkillGroups.AsNoTracking().ToList();
|
||||
var skills = db.Skills.AsNoTracking().ToList();
|
||||
var logEntries = db.RollLogEntries.AsNoTracking().ToList().OrderBy(x => x.TimestampUtc).ThenBy(x => x.Id).ToList();
|
||||
|
||||
lock (stateStore.Gate)
|
||||
{
|
||||
stateStore.UsersById.Clear();
|
||||
stateStore.UserIdsByUsername.Clear();
|
||||
stateStore.SessionsByToken.Clear();
|
||||
stateStore.CampaignsById.Clear();
|
||||
stateStore.CharactersById.Clear();
|
||||
stateStore.SkillGroupsById.Clear();
|
||||
stateStore.SkillsById.Clear();
|
||||
stateStore.RollLog.Clear();
|
||||
|
||||
foreach (var user in users)
|
||||
{
|
||||
var normalizedUsername = string.IsNullOrWhiteSpace(user.UsernameNormalized) ? NormalizeUsername(user.Username) : user.UsernameNormalized;
|
||||
|
||||
var storedUser = new UserAccount
|
||||
{
|
||||
Id = user.Id,
|
||||
Username = user.Username,
|
||||
UsernameNormalized = normalizedUsername,
|
||||
PasswordHash = user.PasswordHash,
|
||||
DisplayName = user.DisplayName,
|
||||
Roles = string.IsNullOrWhiteSpace(user.Roles) ? string.Empty : RoleSerializer.Serialize(RoleSerializer.Parse(user.Roles)),
|
||||
ActiveCharacterId = user.ActiveCharacterId
|
||||
};
|
||||
stateStore.UsersById[storedUser.Id] = storedUser;
|
||||
stateStore.UserIdsByUsername[normalizedUsername] = storedUser.Id;
|
||||
}
|
||||
|
||||
foreach (var session in sessions)
|
||||
{
|
||||
if (stateStore.UsersById.ContainsKey(session.UserId))
|
||||
stateStore.SessionsByToken[session.Token] = GameStateCloneFactory.CloneSession(session);
|
||||
}
|
||||
|
||||
foreach (var campaign in campaigns)
|
||||
stateStore.CampaignsById[campaign.Id] = GameStateCloneFactory.CloneCampaign(campaign);
|
||||
|
||||
foreach (var character in characters)
|
||||
stateStore.CharactersById[character.Id] = GameStateCloneFactory.CloneCharacter(character);
|
||||
|
||||
foreach (var skillGroup in skillGroups)
|
||||
stateStore.SkillGroupsById[skillGroup.Id] = GameStateCloneFactory.CloneSkillGroup(skillGroup);
|
||||
|
||||
foreach (var skill in skills)
|
||||
stateStore.SkillsById[skill.Id] = GameStateCloneFactory.CloneSkill(skill);
|
||||
|
||||
stateStore.RollLog.AddRange(logEntries.Select(GameStateCloneFactory.CloneRollLogEntry));
|
||||
}
|
||||
}
|
||||
|
||||
public void PersistStateLocked()
|
||||
{
|
||||
using var db = dbContextFactory.CreateDbContext();
|
||||
using var transaction = db.Database.BeginTransaction();
|
||||
|
||||
db.RollLogEntries.ExecuteDelete();
|
||||
db.Skills.ExecuteDelete();
|
||||
db.SkillGroups.ExecuteDelete();
|
||||
db.Characters.ExecuteDelete();
|
||||
db.Campaigns.ExecuteDelete();
|
||||
db.Sessions.ExecuteDelete();
|
||||
db.Users.ExecuteDelete();
|
||||
|
||||
lock (stateStore.Gate)
|
||||
{
|
||||
db.Users.AddRange(stateStore.UsersById.Values.Select(GameStateCloneFactory.CloneUser));
|
||||
db.Sessions.AddRange(stateStore.SessionsByToken.Values.Select(GameStateCloneFactory.CloneSession));
|
||||
db.Campaigns.AddRange(stateStore.CampaignsById.Values.Select(GameStateCloneFactory.CloneCampaign));
|
||||
db.Characters.AddRange(stateStore.CharactersById.Values.Select(GameStateCloneFactory.CloneCharacter));
|
||||
db.SkillGroups.AddRange(stateStore.SkillGroupsById.Values.Select(GameStateCloneFactory.CloneSkillGroup));
|
||||
db.Skills.AddRange(stateStore.SkillsById.Values.Select(GameStateCloneFactory.CloneSkill));
|
||||
db.RollLogEntries.AddRange(stateStore.RollLog.Select(GameStateCloneFactory.CloneRollLogEntry));
|
||||
}
|
||||
|
||||
db.SaveChanges();
|
||||
transaction.Commit();
|
||||
}
|
||||
|
||||
private static string NormalizeUsername(string username)
|
||||
{
|
||||
return username.ToUpperInvariant();
|
||||
}
|
||||
}
|
||||
303
RpgRoller/Services/GameRollService.cs
Normal file
303
RpgRoller/Services/GameRollService.cs
Normal file
@@ -0,0 +1,303 @@
|
||||
using System.Text.Json;
|
||||
using RpgRoller.Contracts;
|
||||
using RpgRoller.Domain;
|
||||
|
||||
namespace RpgRoller.Services;
|
||||
|
||||
public sealed class GameRollService(GameStateStore stateStore, GamePersistenceService persistenceService, IDiceRoller diceRoller)
|
||||
{
|
||||
public ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility, int situationalModifier = 0)
|
||||
{
|
||||
lock (stateStore.Gate)
|
||||
{
|
||||
var user = GameContextResolver.ResolveUserLocked(stateStore, sessionToken);
|
||||
if (user is null)
|
||||
return ServiceResult<RollResult>.Failure("unauthorized", "You must be logged in.");
|
||||
|
||||
if (!stateStore.SkillsById.TryGetValue(skillId, out var skill))
|
||||
return ServiceResult<RollResult>.Failure("skill_not_found", "Skill was not found.");
|
||||
|
||||
var character = stateStore.CharactersById[skill.CharacterId];
|
||||
if (!GameContextResolver.TryResolveCharacterCampaignLocked(stateStore, character, out var campaign, out var campaignError))
|
||||
return ServiceResult<RollResult>.Failure(campaignError!.Code, campaignError.Message);
|
||||
|
||||
if (!GameAuthorization.CanEditCharacter(user.Id, character, campaign))
|
||||
return ServiceResult<RollResult>.Failure("forbidden", "Only the owner or GM can roll this skill.");
|
||||
|
||||
var parsedExpression = DiceRules.ParseExpression(campaign.Ruleset, skill.DiceRollDefinition);
|
||||
if (!parsedExpression.Succeeded)
|
||||
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);
|
||||
if (!parsedVisibility.Succeeded)
|
||||
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, skill.RolemasterAutoRetry, situationalModifier);
|
||||
return RecordRollLocked(user, campaign, character, skill.Id, parsedVisibility.Value, roll, parsedExpression.Value!.Canonical);
|
||||
}
|
||||
}
|
||||
|
||||
public ServiceResult<RollResult> RollCustom(string sessionToken, Guid characterId, string expression, string visibility)
|
||||
{
|
||||
lock (stateStore.Gate)
|
||||
{
|
||||
var user = GameContextResolver.ResolveUserLocked(stateStore, sessionToken);
|
||||
if (user is null)
|
||||
return ServiceResult<RollResult>.Failure("unauthorized", "You must be logged in.");
|
||||
|
||||
if (!stateStore.CharactersById.TryGetValue(characterId, out var character))
|
||||
return ServiceResult<RollResult>.Failure("character_not_found", "Character was not found.");
|
||||
|
||||
if (!GameContextResolver.TryResolveCharacterCampaignLocked(stateStore, character, out var campaign, out var campaignError))
|
||||
return ServiceResult<RollResult>.Failure(campaignError!.Code, campaignError.Message);
|
||||
|
||||
if (!GameAuthorization.CanEditCharacter(user.Id, character, campaign))
|
||||
return ServiceResult<RollResult>.Failure("forbidden", "Only the owner or GM can make a custom roll for this character.");
|
||||
|
||||
var parsedExpression = DiceRules.ParseExpression(campaign.Ruleset, expression);
|
||||
if (!parsedExpression.Succeeded)
|
||||
return ServiceResult<RollResult>.Failure(parsedExpression.Error!.Code, parsedExpression.Error.Message);
|
||||
|
||||
var parsedVisibility = RollVisibilityParser.Parse(visibility);
|
||||
if (!parsedVisibility.Succeeded)
|
||||
return ServiceResult<RollResult>.Failure(parsedVisibility.Error!.Code, parsedVisibility.Error.Message);
|
||||
|
||||
var (wildDice, allowFumble, fumbleRange) = CustomRollOptionsResolver.Resolve(campaign.Ruleset);
|
||||
var roll = m_RollEngine.Roll(campaign.Ruleset, parsedExpression.Value!, wildDice, allowFumble, fumbleRange);
|
||||
return RecordRollLocked(user, campaign, character, CustomRollSkillId, parsedVisibility.Value, roll, parsedExpression.Value!.Canonical);
|
||||
}
|
||||
}
|
||||
|
||||
public ServiceResult<IReadOnlyList<CampaignLogEntry>> GetCampaignLog(string sessionToken, Guid campaignId)
|
||||
{
|
||||
lock (stateStore.Gate)
|
||||
{
|
||||
var context = GameContextResolver.ResolveCampaignContextLocked(stateStore, sessionToken, campaignId);
|
||||
if (!context.Succeeded)
|
||||
return ServiceResult<IReadOnlyList<CampaignLogEntry>>.Failure(context.Error!.Code, context.Error.Message);
|
||||
|
||||
var (user, campaign) = context.Value;
|
||||
var entries = GetVisibleCampaignLogEntriesLocked(user, campaign).TakeLast(CampaignLogHistoryWindowSize).Select(ToLogEntry).ToArray();
|
||||
|
||||
return ServiceResult<IReadOnlyList<CampaignLogEntry>>.Success(entries);
|
||||
}
|
||||
}
|
||||
|
||||
public ServiceResult<CampaignLogPage> GetCampaignLogPage(string sessionToken, Guid campaignId, Guid? afterRollId = null, int? limit = null)
|
||||
{
|
||||
lock (stateStore.Gate)
|
||||
{
|
||||
var context = GameContextResolver.ResolveCampaignContextLocked(stateStore, sessionToken, campaignId);
|
||||
if (!context.Succeeded)
|
||||
return ServiceResult<CampaignLogPage>.Failure(context.Error!.Code, context.Error.Message);
|
||||
|
||||
var (user, campaign) = context.Value;
|
||||
var pageSize = NormalizeCampaignLogPageSize(limit);
|
||||
var visibleEntries = GetVisibleCampaignLogEntriesLocked(user, campaign).ToArray();
|
||||
|
||||
if (!afterRollId.HasValue)
|
||||
{
|
||||
var initialEntries = visibleEntries.TakeLast(pageSize).Select(entry => ToLogListEntry(user, campaign, entry)).ToArray();
|
||||
return ServiceResult<CampaignLogPage>.Success(new(initialEntries, initialEntries.LastOrDefault()?.RollId, visibleEntries.Length > pageSize, false));
|
||||
}
|
||||
|
||||
var afterIndex = Array.FindIndex(visibleEntries, entry => entry.Id == afterRollId.Value);
|
||||
if (afterIndex < 0)
|
||||
{
|
||||
var replacementEntries = visibleEntries.TakeLast(pageSize).Select(entry => ToLogListEntry(user, campaign, entry)).ToArray();
|
||||
return ServiceResult<CampaignLogPage>.Success(new(replacementEntries, replacementEntries.LastOrDefault()?.RollId, visibleEntries.Length > pageSize, true));
|
||||
}
|
||||
|
||||
var newEntries = visibleEntries.Skip(afterIndex + 1).ToArray();
|
||||
if (newEntries.Length == 0)
|
||||
return ServiceResult<CampaignLogPage>.Success(new([], afterRollId, false, false));
|
||||
|
||||
if (newEntries.Length > pageSize)
|
||||
{
|
||||
var replacementEntries = newEntries.TakeLast(pageSize).Select(entry => ToLogListEntry(user, campaign, entry)).ToArray();
|
||||
return ServiceResult<CampaignLogPage>.Success(new(replacementEntries, replacementEntries[^1].RollId, true, true));
|
||||
}
|
||||
|
||||
var appendedEntries = newEntries.Select(entry => ToLogListEntry(user, campaign, entry)).ToArray();
|
||||
return ServiceResult<CampaignLogPage>.Success(new(appendedEntries, appendedEntries[^1].RollId, false, false));
|
||||
}
|
||||
}
|
||||
|
||||
public ServiceResult<CampaignRollDetail> GetRollDetail(string sessionToken, Guid rollId)
|
||||
{
|
||||
lock (stateStore.Gate)
|
||||
{
|
||||
var user = GameContextResolver.ResolveUserLocked(stateStore, sessionToken);
|
||||
if (user is null)
|
||||
return ServiceResult<CampaignRollDetail>.Failure("unauthorized", "You must be logged in.");
|
||||
|
||||
var entry = stateStore.RollLog.FirstOrDefault(candidate => candidate.Id == rollId);
|
||||
if (entry is null)
|
||||
return ServiceResult<CampaignRollDetail>.Failure("roll_not_found", "Roll was not found.");
|
||||
|
||||
if (!stateStore.CampaignsById.TryGetValue(entry.CampaignId, out var campaign) || !GameAuthorization.CanViewRoll(stateStore, user.Id, campaign, entry))
|
||||
return ServiceResult<CampaignRollDetail>.Failure("roll_not_found", "Roll was not found.");
|
||||
|
||||
return ServiceResult<CampaignRollDetail>.Success(GameDtoMapper.ToCampaignRollDetail(entry, DeserializeDice(entry.Dice).ToArray()));
|
||||
}
|
||||
}
|
||||
|
||||
public ServiceResult<CampaignStateSnapshot> GetCampaignStateSnapshot(string sessionToken, Guid campaignId)
|
||||
{
|
||||
lock (stateStore.Gate)
|
||||
{
|
||||
var context = GameContextResolver.ResolveCampaignContextLocked(stateStore, sessionToken, campaignId);
|
||||
if (!context.Succeeded)
|
||||
return ServiceResult<CampaignStateSnapshot>.Failure(context.Error!.Code, context.Error.Message);
|
||||
|
||||
return ServiceResult<CampaignStateSnapshot>.Success(GameDtoMapper.ToCampaignStateSnapshot(stateStore, context.Value.Campaign.Id));
|
||||
}
|
||||
}
|
||||
|
||||
private ServiceResult<RollResult> RecordRollLocked(UserAccount user, Campaign campaign, Character character, Guid skillId, RollVisibility visibility, (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) roll, string canonicalExpression)
|
||||
{
|
||||
var entry = new RollLogEntry
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CampaignId = campaign.Id,
|
||||
CharacterId = character.Id,
|
||||
SkillId = skillId,
|
||||
RollerUserId = user.Id,
|
||||
Visibility = visibility,
|
||||
Result = roll.Total,
|
||||
Breakdown = FormatLoggedBreakdown(skillId, canonicalExpression, roll.Breakdown),
|
||||
Dice = SerializeDice(roll.Dice),
|
||||
TimestampUtc = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
stateStore.RollLog.Add(entry);
|
||||
stateStore.TouchLogLocked(campaign.Id);
|
||||
|
||||
persistenceService.PersistStateLocked();
|
||||
return ServiceResult<RollResult>.Success(GameDtoMapper.ToRollResult(entry, roll.Dice));
|
||||
}
|
||||
|
||||
private static string FormatLoggedBreakdown(Guid skillId, string canonicalExpression, string breakdown)
|
||||
{
|
||||
return skillId == CustomRollSkillId ? $"{canonicalExpression}{CustomRollBreakdownSeparator}{breakdown}" : breakdown;
|
||||
}
|
||||
|
||||
private IEnumerable<RollLogEntry> GetVisibleCampaignLogEntriesLocked(UserAccount user, Campaign campaign)
|
||||
{
|
||||
return stateStore.RollLog.Where(r => r.CampaignId == campaign.Id).Where(r => r.Visibility == RollVisibility.Public || r.RollerUserId == user.Id || campaign.GmUserId == user.Id).OrderBy(r => r.TimestampUtc).ThenBy(r => r.Id);
|
||||
}
|
||||
|
||||
private CampaignLogEntry ToLogEntry(RollLogEntry entry)
|
||||
{
|
||||
var dice = DeserializeDice(entry.Dice);
|
||||
var characterName = stateStore.CharactersById.TryGetValue(entry.CharacterId, out var character) ? character.Name : "Unknown character";
|
||||
var skillName = ResolveLoggedSkillName(entry);
|
||||
var rollerDisplayName = GameDtoMapper.ResolveOwnerDisplayName(stateStore, entry.RollerUserId, "Unknown owner");
|
||||
|
||||
return GameDtoMapper.ToCampaignLogEntry(entry, characterName, skillName, rollerDisplayName, dice);
|
||||
}
|
||||
|
||||
private CampaignLogListEntry ToLogListEntry(UserAccount user, Campaign campaign, RollLogEntry entry)
|
||||
{
|
||||
var dice = DeserializeDice(entry.Dice);
|
||||
var characterName = stateStore.CharactersById.TryGetValue(entry.CharacterId, out var character) ? character.Name : "Unknown character";
|
||||
var skillName = ResolveLoggedSkillName(entry);
|
||||
var loggedExpression = ResolveLoggedExpression(entry);
|
||||
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, entry.Breakdown), eventBadges);
|
||||
}
|
||||
|
||||
private static string SerializeDice(IReadOnlyList<RollDieResult> dice)
|
||||
{
|
||||
return JsonSerializer.Serialize(dice, DiceJsonOptions);
|
||||
}
|
||||
|
||||
private string ResolveLoggedSkillName(RollLogEntry entry)
|
||||
{
|
||||
if (entry.SkillId == CustomRollSkillId)
|
||||
return CustomRollLabel;
|
||||
|
||||
return stateStore.SkillsById.TryGetValue(entry.SkillId, out var skill) ? skill.Name : "Unknown skill";
|
||||
}
|
||||
|
||||
private string? ResolveLoggedExpression(RollLogEntry entry)
|
||||
{
|
||||
if (entry.SkillId == CustomRollSkillId)
|
||||
return CampaignLogSummaryBuilder.ExtractCustomRollExpression(entry.Breakdown, CustomRollBreakdownSeparator);
|
||||
|
||||
return stateStore.SkillsById.TryGetValue(entry.SkillId, out var skill) ? skill.DiceRollDefinition : null;
|
||||
}
|
||||
|
||||
private string ResolveLogRollerLabel(UserAccount user, Campaign campaign, RollLogEntry entry)
|
||||
{
|
||||
if (entry.RollerUserId == user.Id)
|
||||
return "You";
|
||||
|
||||
if (entry.RollerUserId == campaign.GmUserId)
|
||||
return "GM";
|
||||
|
||||
return GameDtoMapper.ResolveOwnerDisplayName(stateStore, entry.RollerUserId, "Unknown owner");
|
||||
}
|
||||
|
||||
private static string ResolveLogVisibilityLabel(UserAccount user, Campaign campaign, RollLogEntry entry)
|
||||
{
|
||||
if (entry.Visibility != RollVisibility.Private)
|
||||
return "Public";
|
||||
|
||||
if (entry.RollerUserId == user.Id)
|
||||
return "Private (you)";
|
||||
|
||||
return campaign.GmUserId == user.Id ? "Private (GM view)" : "Private";
|
||||
}
|
||||
|
||||
private static string ResolveLogVisibilityStyle(UserAccount user, Campaign campaign, RollLogEntry entry)
|
||||
{
|
||||
if (entry.Visibility != RollVisibility.Private)
|
||||
return "public";
|
||||
|
||||
if (entry.RollerUserId == user.Id)
|
||||
return "private-self";
|
||||
|
||||
return campaign.GmUserId == user.Id ? "private-gm" : "private-generic";
|
||||
}
|
||||
|
||||
private static IReadOnlyList<RollDieResult> DeserializeDice(string serializedDice)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(serializedDice))
|
||||
return [];
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<IReadOnlyList<RollDieResult>>(serializedDice, DiceJsonOptions) ?? [];
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private static int NormalizeCampaignLogPageSize(int? limit)
|
||||
{
|
||||
if (!limit.HasValue)
|
||||
return CampaignLogLivePageSize;
|
||||
|
||||
return Math.Clamp(limit.Value, 1, CampaignLogLivePageSize);
|
||||
}
|
||||
|
||||
private const int CampaignLogHistoryWindowSize = 100;
|
||||
private const int CampaignLogLivePageSize = 25;
|
||||
private const int MaxSituationalModifier = 1000;
|
||||
private const string CustomRollBreakdownSeparator = " => ";
|
||||
private const string CustomRollLabel = "Custom roll";
|
||||
private static readonly Guid CustomRollSkillId = Guid.Empty;
|
||||
private static readonly JsonSerializerOptions DiceJsonOptions = RpgRollerJson.CreateSerializerOptions();
|
||||
private readonly IDiceRoller m_DiceRoller = diceRoller;
|
||||
private readonly RollEngine m_RollEngine = new(new(diceRoller), new(diceRoller), new(diceRoller));
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
269
RpgRoller/Services/GameSkillService.cs
Normal file
269
RpgRoller/Services/GameSkillService.cs
Normal file
@@ -0,0 +1,269 @@
|
||||
using RpgRoller.Contracts;
|
||||
using RpgRoller.Domain;
|
||||
|
||||
namespace RpgRoller.Services;
|
||||
|
||||
public sealed class GameSkillService(GameStateStore stateStore, GamePersistenceService persistenceService)
|
||||
{
|
||||
public ServiceResult<SkillGroupSummary> CreateSkillGroup(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, int? fumbleRange = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return ServiceResult<SkillGroupSummary>.Failure("invalid_skill_group_name", "Skill group name is required.");
|
||||
|
||||
lock (stateStore.Gate)
|
||||
{
|
||||
var user = GameContextResolver.ResolveUserLocked(stateStore, sessionToken);
|
||||
if (user is null)
|
||||
return ServiceResult<SkillGroupSummary>.Failure("unauthorized", "You must be logged in.");
|
||||
|
||||
if (!stateStore.CharactersById.TryGetValue(characterId, out var character))
|
||||
return ServiceResult<SkillGroupSummary>.Failure("character_not_found", "Character was not found.");
|
||||
|
||||
if (!GameContextResolver.TryResolveCharacterCampaignLocked(stateStore, character, out var campaign, out var campaignError))
|
||||
return ServiceResult<SkillGroupSummary>.Failure(campaignError!.Code, campaignError.Message);
|
||||
|
||||
if (!GameAuthorization.CanEditCharacter(user.Id, character, campaign))
|
||||
return ServiceResult<SkillGroupSummary>.Failure("forbidden", "Only the owner or GM can manage skill groups.");
|
||||
|
||||
var prototypeValidation = SkillDefinitionValidator.Validate(campaign.Ruleset, diceRollDefinition, wildDice, allowFumble, fumbleRange);
|
||||
if (!prototypeValidation.Succeeded)
|
||||
return ServiceResult<SkillGroupSummary>.Failure(prototypeValidation.Error!.Code, prototypeValidation.Error.Message);
|
||||
|
||||
var group = new SkillGroup
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CharacterId = character.Id,
|
||||
Name = name.Trim(),
|
||||
DiceRollDefinition = prototypeValidation.Value.CanonicalExpression,
|
||||
WildDice = prototypeValidation.Value.WildDice,
|
||||
AllowFumble = prototypeValidation.Value.AllowFumble,
|
||||
FumbleRange = prototypeValidation.Value.FumbleRange
|
||||
};
|
||||
|
||||
stateStore.SkillGroupsById[group.Id] = group;
|
||||
stateStore.TouchCharacterLocked(campaign.Id, character.Id);
|
||||
|
||||
persistenceService.PersistStateLocked();
|
||||
return ServiceResult<SkillGroupSummary>.Success(GameDtoMapper.ToSkillGroupSummary(group));
|
||||
}
|
||||
}
|
||||
|
||||
public ServiceResult<SkillGroupSummary> UpdateSkillGroup(string sessionToken, Guid skillGroupId, string name, string diceRollDefinition, int wildDice, bool allowFumble, int? fumbleRange = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return ServiceResult<SkillGroupSummary>.Failure("invalid_skill_group_name", "Skill group name is required.");
|
||||
|
||||
lock (stateStore.Gate)
|
||||
{
|
||||
var user = GameContextResolver.ResolveUserLocked(stateStore, sessionToken);
|
||||
if (user is null)
|
||||
return ServiceResult<SkillGroupSummary>.Failure("unauthorized", "You must be logged in.");
|
||||
|
||||
if (!stateStore.SkillGroupsById.TryGetValue(skillGroupId, out var group))
|
||||
return ServiceResult<SkillGroupSummary>.Failure("skill_group_not_found", "Skill group was not found.");
|
||||
|
||||
var character = stateStore.CharactersById[group.CharacterId];
|
||||
if (!GameContextResolver.TryResolveCharacterCampaignLocked(stateStore, character, out var campaign, out var campaignError))
|
||||
return ServiceResult<SkillGroupSummary>.Failure(campaignError!.Code, campaignError.Message);
|
||||
|
||||
if (!GameAuthorization.CanEditCharacter(user.Id, character, campaign))
|
||||
return ServiceResult<SkillGroupSummary>.Failure("forbidden", "Only the owner or GM can manage skill groups.");
|
||||
|
||||
var prototypeValidation = SkillDefinitionValidator.Validate(campaign.Ruleset, diceRollDefinition, wildDice, allowFumble, fumbleRange);
|
||||
if (!prototypeValidation.Succeeded)
|
||||
return ServiceResult<SkillGroupSummary>.Failure(prototypeValidation.Error!.Code, prototypeValidation.Error.Message);
|
||||
|
||||
group.Name = name.Trim();
|
||||
group.DiceRollDefinition = prototypeValidation.Value.CanonicalExpression;
|
||||
group.WildDice = prototypeValidation.Value.WildDice;
|
||||
group.AllowFumble = prototypeValidation.Value.AllowFumble;
|
||||
group.FumbleRange = prototypeValidation.Value.FumbleRange;
|
||||
stateStore.TouchCharacterLocked(campaign.Id, character.Id);
|
||||
|
||||
persistenceService.PersistStateLocked();
|
||||
return ServiceResult<SkillGroupSummary>.Success(GameDtoMapper.ToSkillGroupSummary(group));
|
||||
}
|
||||
}
|
||||
|
||||
public ServiceResult<bool> DeleteSkillGroup(string sessionToken, Guid skillGroupId)
|
||||
{
|
||||
lock (stateStore.Gate)
|
||||
{
|
||||
var user = GameContextResolver.ResolveUserLocked(stateStore, sessionToken);
|
||||
if (user is null)
|
||||
return ServiceResult<bool>.Failure("unauthorized", "You must be logged in.");
|
||||
|
||||
if (!stateStore.SkillGroupsById.TryGetValue(skillGroupId, out var group))
|
||||
return ServiceResult<bool>.Failure("skill_group_not_found", "Skill group was not found.");
|
||||
|
||||
var character = stateStore.CharactersById[group.CharacterId];
|
||||
if (!GameContextResolver.TryResolveCharacterCampaignLocked(stateStore, character, out var campaign, out var campaignError))
|
||||
return ServiceResult<bool>.Failure(campaignError!.Code, campaignError.Message);
|
||||
|
||||
if (!GameAuthorization.CanEditCharacter(user.Id, character, campaign))
|
||||
return ServiceResult<bool>.Failure("forbidden", "Only the owner or GM can manage skill groups.");
|
||||
|
||||
foreach (var skill in stateStore.SkillsById.Values.Where(skill => skill.SkillGroupId == group.Id))
|
||||
skill.SkillGroupId = null;
|
||||
|
||||
stateStore.SkillGroupsById.Remove(group.Id);
|
||||
stateStore.TouchCharacterLocked(campaign.Id, character.Id);
|
||||
|
||||
persistenceService.PersistStateLocked();
|
||||
return ServiceResult<bool>.Success(true);
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
return ServiceResult<SkillSummary>.Failure("invalid_skill_name", "Skill name is required.");
|
||||
|
||||
lock (stateStore.Gate)
|
||||
{
|
||||
var user = GameContextResolver.ResolveUserLocked(stateStore, sessionToken);
|
||||
if (user is null)
|
||||
return ServiceResult<SkillSummary>.Failure("unauthorized", "You must be logged in.");
|
||||
|
||||
if (!stateStore.CharactersById.TryGetValue(characterId, out var character))
|
||||
return ServiceResult<SkillSummary>.Failure("character_not_found", "Character was not found.");
|
||||
|
||||
if (!GameContextResolver.TryResolveCharacterCampaignLocked(stateStore, character, out var campaign, out var campaignError))
|
||||
return ServiceResult<SkillSummary>.Failure(campaignError!.Code, campaignError.Message);
|
||||
|
||||
if (!GameAuthorization.CanEditCharacter(user.Id, character, campaign))
|
||||
return ServiceResult<SkillSummary>.Failure("forbidden", "Only the owner or GM can edit skills.");
|
||||
|
||||
var skillValidation = SkillDefinitionValidator.Validate(campaign.Ruleset, diceRollDefinition, wildDice, allowFumble, fumbleRange, rolemasterAutoRetry);
|
||||
if (!skillValidation.Succeeded)
|
||||
return ServiceResult<SkillSummary>.Failure(skillValidation.Error!.Code, skillValidation.Error.Message);
|
||||
|
||||
var resolvedSkillGroupId = ResolveSkillGroupForSkillChangeLocked(skillGroupId, character.Id);
|
||||
if (!resolvedSkillGroupId.Succeeded)
|
||||
return ServiceResult<SkillSummary>.Failure(resolvedSkillGroupId.Error!.Code, resolvedSkillGroupId.Error.Message);
|
||||
|
||||
var skill = new Skill
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CharacterId = character.Id,
|
||||
SkillGroupId = resolvedSkillGroupId.Value,
|
||||
Name = name.Trim(),
|
||||
DiceRollDefinition = skillValidation.Value.CanonicalExpression,
|
||||
WildDice = skillValidation.Value.WildDice,
|
||||
AllowFumble = skillValidation.Value.AllowFumble,
|
||||
FumbleRange = skillValidation.Value.FumbleRange,
|
||||
RolemasterAutoRetry = skillValidation.Value.RolemasterAutoRetry
|
||||
};
|
||||
|
||||
stateStore.SkillsById[skill.Id] = skill;
|
||||
stateStore.TouchCharacterLocked(campaign.Id, character.Id);
|
||||
|
||||
persistenceService.PersistStateLocked();
|
||||
return ServiceResult<SkillSummary>.Success(GameDtoMapper.ToSkillSummary(skill));
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
return ServiceResult<SkillSummary>.Failure("invalid_skill_name", "Skill name is required.");
|
||||
|
||||
lock (stateStore.Gate)
|
||||
{
|
||||
var user = GameContextResolver.ResolveUserLocked(stateStore, sessionToken);
|
||||
if (user is null)
|
||||
return ServiceResult<SkillSummary>.Failure("unauthorized", "You must be logged in.");
|
||||
|
||||
if (!stateStore.SkillsById.TryGetValue(skillId, out var skill))
|
||||
return ServiceResult<SkillSummary>.Failure("skill_not_found", "Skill was not found.");
|
||||
|
||||
var character = stateStore.CharactersById[skill.CharacterId];
|
||||
if (!GameContextResolver.TryResolveCharacterCampaignLocked(stateStore, character, out var campaign, out var campaignError))
|
||||
return ServiceResult<SkillSummary>.Failure(campaignError!.Code, campaignError.Message);
|
||||
|
||||
if (!GameAuthorization.CanEditCharacter(user.Id, character, campaign))
|
||||
return ServiceResult<SkillSummary>.Failure("forbidden", "Only the owner or GM can edit skills.");
|
||||
|
||||
var skillValidation = SkillDefinitionValidator.Validate(campaign.Ruleset, diceRollDefinition, wildDice, allowFumble, fumbleRange, rolemasterAutoRetry);
|
||||
if (!skillValidation.Succeeded)
|
||||
return ServiceResult<SkillSummary>.Failure(skillValidation.Error!.Code, skillValidation.Error.Message);
|
||||
|
||||
var resolvedSkillGroupId = ResolveSkillGroupForSkillChangeLocked(skillGroupId, character.Id);
|
||||
if (!resolvedSkillGroupId.Succeeded)
|
||||
return ServiceResult<SkillSummary>.Failure(resolvedSkillGroupId.Error!.Code, resolvedSkillGroupId.Error.Message);
|
||||
|
||||
skill.Name = name.Trim();
|
||||
skill.DiceRollDefinition = skillValidation.Value.CanonicalExpression;
|
||||
skill.WildDice = skillValidation.Value.WildDice;
|
||||
skill.AllowFumble = skillValidation.Value.AllowFumble;
|
||||
skill.FumbleRange = skillValidation.Value.FumbleRange;
|
||||
skill.RolemasterAutoRetry = skillValidation.Value.RolemasterAutoRetry;
|
||||
skill.SkillGroupId = resolvedSkillGroupId.Value;
|
||||
stateStore.TouchCharacterLocked(campaign.Id, character.Id);
|
||||
|
||||
persistenceService.PersistStateLocked();
|
||||
return ServiceResult<SkillSummary>.Success(GameDtoMapper.ToSkillSummary(skill));
|
||||
}
|
||||
}
|
||||
|
||||
public ServiceResult<bool> DeleteSkill(string sessionToken, Guid skillId)
|
||||
{
|
||||
lock (stateStore.Gate)
|
||||
{
|
||||
var user = GameContextResolver.ResolveUserLocked(stateStore, sessionToken);
|
||||
if (user is null)
|
||||
return ServiceResult<bool>.Failure("unauthorized", "You must be logged in.");
|
||||
|
||||
if (!stateStore.SkillsById.TryGetValue(skillId, out var skill))
|
||||
return ServiceResult<bool>.Failure("skill_not_found", "Skill was not found.");
|
||||
|
||||
var character = stateStore.CharactersById[skill.CharacterId];
|
||||
if (!GameContextResolver.TryResolveCharacterCampaignLocked(stateStore, character, out var campaign, out var campaignError))
|
||||
return ServiceResult<bool>.Failure(campaignError!.Code, campaignError.Message);
|
||||
|
||||
if (!GameAuthorization.CanEditCharacter(user.Id, character, campaign))
|
||||
return ServiceResult<bool>.Failure("forbidden", "Only the owner or GM can edit skills.");
|
||||
|
||||
stateStore.SkillsById.Remove(skill.Id);
|
||||
stateStore.TouchCharacterLocked(campaign.Id, character.Id);
|
||||
|
||||
persistenceService.PersistStateLocked();
|
||||
return ServiceResult<bool>.Success(true);
|
||||
}
|
||||
}
|
||||
|
||||
public ServiceResult<CharacterSheet> GetCharacterSheet(string sessionToken, Guid characterId)
|
||||
{
|
||||
lock (stateStore.Gate)
|
||||
{
|
||||
var user = GameContextResolver.ResolveUserLocked(stateStore, sessionToken);
|
||||
if (user is null)
|
||||
return ServiceResult<CharacterSheet>.Failure("unauthorized", "You must be logged in.");
|
||||
|
||||
if (!stateStore.CharactersById.TryGetValue(characterId, out var character))
|
||||
return ServiceResult<CharacterSheet>.Failure("character_not_found", "Character was not found.");
|
||||
|
||||
if (!GameContextResolver.TryResolveCharacterCampaignLocked(stateStore, character, out var campaign, out var campaignError))
|
||||
return ServiceResult<CharacterSheet>.Failure(campaignError!.Code, campaignError.Message);
|
||||
|
||||
if (!GameAuthorization.CanViewCampaign(stateStore, user.Id, campaign.Id))
|
||||
return ServiceResult<CharacterSheet>.Failure("forbidden", "You are not a participant in this campaign.");
|
||||
|
||||
return ServiceResult<CharacterSheet>.Success(GameDtoMapper.ToCharacterSheet(stateStore, character.Id));
|
||||
}
|
||||
}
|
||||
|
||||
private ServiceResult<Guid?> ResolveSkillGroupForSkillChangeLocked(Guid? requestedSkillGroupId, Guid characterId)
|
||||
{
|
||||
if (!requestedSkillGroupId.HasValue)
|
||||
return ServiceResult<Guid?>.Success(null);
|
||||
|
||||
if (!stateStore.SkillGroupsById.TryGetValue(requestedSkillGroupId.Value, out var skillGroup))
|
||||
return ServiceResult<Guid?>.Failure("skill_group_not_found", "Skill group was not found.");
|
||||
|
||||
if (skillGroup.CharacterId != characterId)
|
||||
return ServiceResult<Guid?>.Failure("invalid_skill_group", "Skill group must belong to the same character.");
|
||||
|
||||
return ServiceResult<Guid?>.Success(skillGroup.Id);
|
||||
}
|
||||
}
|
||||
100
RpgRoller/Services/GameStateCloneFactory.cs
Normal file
100
RpgRoller/Services/GameStateCloneFactory.cs
Normal file
@@ -0,0 +1,100 @@
|
||||
using RpgRoller.Domain;
|
||||
|
||||
namespace RpgRoller.Services;
|
||||
|
||||
public static class GameStateCloneFactory
|
||||
{
|
||||
public static UserAccount CloneUser(UserAccount user)
|
||||
{
|
||||
return new()
|
||||
{
|
||||
Id = user.Id,
|
||||
Username = user.Username,
|
||||
UsernameNormalized = user.UsernameNormalized,
|
||||
PasswordHash = user.PasswordHash,
|
||||
DisplayName = user.DisplayName,
|
||||
Roles = user.Roles,
|
||||
ActiveCharacterId = user.ActiveCharacterId
|
||||
};
|
||||
}
|
||||
|
||||
public static UserSession CloneSession(UserSession session)
|
||||
{
|
||||
return new()
|
||||
{
|
||||
Token = session.Token,
|
||||
UserId = session.UserId,
|
||||
CreatedAtUtc = session.CreatedAtUtc
|
||||
};
|
||||
}
|
||||
|
||||
public static Campaign CloneCampaign(Campaign campaign)
|
||||
{
|
||||
return new()
|
||||
{
|
||||
Id = campaign.Id,
|
||||
GmUserId = campaign.GmUserId,
|
||||
Name = campaign.Name,
|
||||
Ruleset = campaign.Ruleset,
|
||||
Version = campaign.Version
|
||||
};
|
||||
}
|
||||
|
||||
public static Character CloneCharacter(Character character)
|
||||
{
|
||||
return new()
|
||||
{
|
||||
Id = character.Id,
|
||||
OwnerUserId = character.OwnerUserId,
|
||||
CampaignId = character.CampaignId,
|
||||
Name = character.Name
|
||||
};
|
||||
}
|
||||
|
||||
public static Skill CloneSkill(Skill skill)
|
||||
{
|
||||
return new()
|
||||
{
|
||||
Id = skill.Id,
|
||||
CharacterId = skill.CharacterId,
|
||||
SkillGroupId = skill.SkillGroupId,
|
||||
Name = skill.Name,
|
||||
DiceRollDefinition = skill.DiceRollDefinition,
|
||||
WildDice = skill.WildDice,
|
||||
AllowFumble = skill.AllowFumble,
|
||||
FumbleRange = skill.FumbleRange,
|
||||
RolemasterAutoRetry = skill.RolemasterAutoRetry
|
||||
};
|
||||
}
|
||||
|
||||
public static SkillGroup CloneSkillGroup(SkillGroup skillGroup)
|
||||
{
|
||||
return new()
|
||||
{
|
||||
Id = skillGroup.Id,
|
||||
CharacterId = skillGroup.CharacterId,
|
||||
Name = skillGroup.Name,
|
||||
DiceRollDefinition = skillGroup.DiceRollDefinition,
|
||||
WildDice = skillGroup.WildDice,
|
||||
AllowFumble = skillGroup.AllowFumble,
|
||||
FumbleRange = skillGroup.FumbleRange
|
||||
};
|
||||
}
|
||||
|
||||
public static RollLogEntry CloneRollLogEntry(RollLogEntry entry)
|
||||
{
|
||||
return new()
|
||||
{
|
||||
Id = entry.Id,
|
||||
CampaignId = entry.CampaignId,
|
||||
CharacterId = entry.CharacterId,
|
||||
SkillId = entry.SkillId,
|
||||
RollerUserId = entry.RollerUserId,
|
||||
Visibility = entry.Visibility,
|
||||
Result = entry.Result,
|
||||
Breakdown = entry.Breakdown,
|
||||
Dice = entry.Dice,
|
||||
TimestampUtc = entry.TimestampUtc
|
||||
};
|
||||
}
|
||||
}
|
||||
94
RpgRoller/Services/GameStateStore.cs
Normal file
94
RpgRoller/Services/GameStateStore.cs
Normal file
@@ -0,0 +1,94 @@
|
||||
using RpgRoller.Domain;
|
||||
|
||||
namespace RpgRoller.Services;
|
||||
|
||||
public sealed class GameStateStore
|
||||
{
|
||||
public GameCampaignStateTracker GetOrCreateCampaignStateLocked(Guid campaignId)
|
||||
{
|
||||
if (!CampaignStateById.TryGetValue(campaignId, out var state))
|
||||
{
|
||||
state = new();
|
||||
CampaignStateById[campaignId] = state;
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
public void RebuildCampaignStateLocked()
|
||||
{
|
||||
CampaignStateById.Clear();
|
||||
|
||||
foreach (var campaignId in CampaignsById.Keys)
|
||||
CampaignStateById[campaignId] = new();
|
||||
|
||||
foreach (var character in CharactersById.Values.Where(character => character.CampaignId.HasValue))
|
||||
AddCharacterStateLocked(character.CampaignId, character.Id);
|
||||
}
|
||||
|
||||
public void AddCharacterStateLocked(Guid? campaignId, Guid characterId)
|
||||
{
|
||||
if (!campaignId.HasValue || !CampaignsById.ContainsKey(campaignId.Value))
|
||||
return;
|
||||
|
||||
var state = GetOrCreateCampaignStateLocked(campaignId.Value);
|
||||
state.CharacterVersions[characterId] = 1;
|
||||
}
|
||||
|
||||
public void RemoveCharacterStateLocked(Guid? campaignId, Guid characterId)
|
||||
{
|
||||
if (!campaignId.HasValue || !CampaignStateById.TryGetValue(campaignId.Value, out var state))
|
||||
return;
|
||||
|
||||
state.CharacterVersions.Remove(characterId);
|
||||
}
|
||||
|
||||
public void TouchRosterLocked(Guid? campaignId)
|
||||
{
|
||||
if (!campaignId.HasValue || !CampaignsById.ContainsKey(campaignId.Value))
|
||||
return;
|
||||
|
||||
var state = GetOrCreateCampaignStateLocked(campaignId.Value);
|
||||
state.TotalVersion += 1;
|
||||
state.RosterVersion += 1;
|
||||
}
|
||||
|
||||
public void TouchCharacterLocked(Guid? campaignId, Guid characterId)
|
||||
{
|
||||
if (!campaignId.HasValue || !CampaignsById.ContainsKey(campaignId.Value))
|
||||
return;
|
||||
|
||||
var state = GetOrCreateCampaignStateLocked(campaignId.Value);
|
||||
state.TotalVersion += 1;
|
||||
state.CharacterVersions[characterId] = state.CharacterVersions.GetValueOrDefault(characterId, 1) + 1;
|
||||
}
|
||||
|
||||
public void TouchLogLocked(Guid? campaignId)
|
||||
{
|
||||
if (!campaignId.HasValue || !CampaignsById.ContainsKey(campaignId.Value))
|
||||
return;
|
||||
|
||||
var state = GetOrCreateCampaignStateLocked(campaignId.Value);
|
||||
state.TotalVersion += 1;
|
||||
state.LogVersion += 1;
|
||||
}
|
||||
|
||||
public object Gate { get; } = new();
|
||||
public Dictionary<Guid, Campaign> CampaignsById { get; } = [];
|
||||
public Dictionary<Guid, GameCampaignStateTracker> CampaignStateById { get; } = [];
|
||||
public Dictionary<Guid, Character> CharactersById { get; } = [];
|
||||
public List<RollLogEntry> RollLog { get; } = [];
|
||||
public Dictionary<string, UserSession> SessionsByToken { get; } = new(StringComparer.Ordinal);
|
||||
public Dictionary<Guid, SkillGroup> SkillGroupsById { get; } = [];
|
||||
public Dictionary<Guid, Skill> SkillsById { get; } = [];
|
||||
public Dictionary<string, Guid> UserIdsByUsername { get; } = new(StringComparer.Ordinal);
|
||||
public Dictionary<Guid, UserAccount> UsersById { get; } = [];
|
||||
}
|
||||
|
||||
public sealed class GameCampaignStateTracker
|
||||
{
|
||||
public long TotalVersion { get; set; } = 1;
|
||||
public long RosterVersion { get; set; } = 1;
|
||||
public long LogVersion { get; set; } = 1;
|
||||
public Dictionary<Guid, long> CharacterVersions { get; } = [];
|
||||
}
|
||||
145
RpgRoller/Services/GameUserAdministrationService.cs
Normal file
145
RpgRoller/Services/GameUserAdministrationService.cs
Normal file
@@ -0,0 +1,145 @@
|
||||
using RpgRoller.Contracts;
|
||||
using RpgRoller.Domain;
|
||||
|
||||
namespace RpgRoller.Services;
|
||||
|
||||
public sealed class GameUserAdministrationService(GameStateStore stateStore, GamePersistenceService persistenceService)
|
||||
{
|
||||
public ServiceResult<IReadOnlyList<string>> GetUsernames(string sessionToken)
|
||||
{
|
||||
lock (stateStore.Gate)
|
||||
{
|
||||
var user = GameContextResolver.ResolveUserLocked(stateStore, sessionToken);
|
||||
if (user is null)
|
||||
return ServiceResult<IReadOnlyList<string>>.Failure("unauthorized", "You must be logged in.");
|
||||
|
||||
var usernames = stateStore.UsersById.Values.Select(account => account.Username).OrderBy(username => username, StringComparer.OrdinalIgnoreCase).ToArray();
|
||||
|
||||
return ServiceResult<IReadOnlyList<string>>.Success(usernames);
|
||||
}
|
||||
}
|
||||
|
||||
public ServiceResult<IReadOnlyList<AdminUserSummary>> GetUsers(string sessionToken)
|
||||
{
|
||||
lock (stateStore.Gate)
|
||||
{
|
||||
var user = GameContextResolver.ResolveUserLocked(stateStore, sessionToken);
|
||||
if (user is null)
|
||||
return ServiceResult<IReadOnlyList<AdminUserSummary>>.Failure("unauthorized", "You must be logged in.");
|
||||
|
||||
if (!GameAuthorization.HasRole(user, UserRoles.Admin))
|
||||
return ServiceResult<IReadOnlyList<AdminUserSummary>>.Failure("forbidden", "Admin role is required.");
|
||||
|
||||
var users = stateStore.UsersById.Values.OrderBy(account => account.Username, StringComparer.OrdinalIgnoreCase).Select(GameDtoMapper.ToAdminUserSummary).ToArray();
|
||||
|
||||
return ServiceResult<IReadOnlyList<AdminUserSummary>>.Success(users);
|
||||
}
|
||||
}
|
||||
|
||||
public ServiceResult<AdminUserSummary> UpdateUserRoles(string sessionToken, Guid userId, IReadOnlyList<string> roles)
|
||||
{
|
||||
lock (stateStore.Gate)
|
||||
{
|
||||
var user = GameContextResolver.ResolveUserLocked(stateStore, sessionToken);
|
||||
if (user is null)
|
||||
return ServiceResult<AdminUserSummary>.Failure("unauthorized", "You must be logged in.");
|
||||
|
||||
if (!GameAuthorization.HasRole(user, UserRoles.Admin))
|
||||
return ServiceResult<AdminUserSummary>.Failure("forbidden", "Admin role is required.");
|
||||
|
||||
if (!stateStore.UsersById.TryGetValue(userId, out var targetUser))
|
||||
return ServiceResult<AdminUserSummary>.Failure("user_not_found", "User was not found.");
|
||||
|
||||
var normalizedRoles = RoleSerializer.Normalize(roles);
|
||||
if (normalizedRoles.Any(role => !string.Equals(role, UserRoles.Admin, StringComparison.Ordinal)))
|
||||
return ServiceResult<AdminUserSummary>.Failure("invalid_role", "Unsupported role.");
|
||||
|
||||
if (user.Id == targetUser.Id && !normalizedRoles.Contains(UserRoles.Admin, StringComparer.Ordinal))
|
||||
return ServiceResult<AdminUserSummary>.Failure("forbidden", "You cannot remove your own admin role.");
|
||||
|
||||
targetUser.Roles = RoleSerializer.Serialize(normalizedRoles);
|
||||
persistenceService.PersistStateLocked();
|
||||
return ServiceResult<AdminUserSummary>.Success(GameDtoMapper.ToAdminUserSummary(targetUser));
|
||||
}
|
||||
}
|
||||
|
||||
public ServiceResult<bool> DeleteUser(string sessionToken, Guid userId)
|
||||
{
|
||||
lock (stateStore.Gate)
|
||||
{
|
||||
var user = GameContextResolver.ResolveUserLocked(stateStore, sessionToken);
|
||||
if (user is null)
|
||||
return ServiceResult<bool>.Failure("unauthorized", "You must be logged in.");
|
||||
|
||||
if (!GameAuthorization.HasRole(user, UserRoles.Admin))
|
||||
return ServiceResult<bool>.Failure("forbidden", "Admin role is required.");
|
||||
|
||||
if (user.Id == userId)
|
||||
return ServiceResult<bool>.Failure("forbidden", "You cannot delete your own account.");
|
||||
|
||||
if (!stateStore.UsersById.TryGetValue(userId, out var targetUser))
|
||||
return ServiceResult<bool>.Failure("user_not_found", "User was not found.");
|
||||
|
||||
var gmCampaignIds = stateStore.CampaignsById.Values.Where(campaign => campaign.GmUserId == targetUser.Id).Select(campaign => campaign.Id).ToArray();
|
||||
var gmCampaignIdSet = gmCampaignIds.ToHashSet();
|
||||
var preservedCharacterIds = stateStore.CharactersById.Values.Where(character => character.CampaignId.HasValue && gmCampaignIdSet.Contains(character.CampaignId.Value)).Select(character => character.Id).ToHashSet();
|
||||
|
||||
foreach (var campaignId in gmCampaignIds)
|
||||
DeleteCampaignLocked(campaignId);
|
||||
|
||||
var ownedCharacterIds = stateStore.CharactersById.Values.Where(character => character.OwnerUserId == targetUser.Id && !preservedCharacterIds.Contains(character.Id)).Select(character => character.Id).ToArray();
|
||||
foreach (var characterId in ownedCharacterIds)
|
||||
DeleteCharacterLocked(characterId);
|
||||
|
||||
stateStore.RollLog.RemoveAll(entry => entry.RollerUserId == targetUser.Id);
|
||||
|
||||
var staleSessions = stateStore.SessionsByToken.Values.Where(session => session.UserId == targetUser.Id).Select(session => session.Token).ToArray();
|
||||
foreach (var token in staleSessions)
|
||||
stateStore.SessionsByToken.Remove(token);
|
||||
|
||||
stateStore.UsersById.Remove(targetUser.Id);
|
||||
stateStore.UserIdsByUsername.Remove(targetUser.UsernameNormalized);
|
||||
|
||||
persistenceService.PersistStateLocked();
|
||||
return ServiceResult<bool>.Success(true);
|
||||
}
|
||||
}
|
||||
|
||||
private void DeleteCampaignLocked(Guid campaignId)
|
||||
{
|
||||
if (!stateStore.CampaignsById.Remove(campaignId))
|
||||
return;
|
||||
|
||||
var affectedCharacterIds = stateStore.CharactersById.Values.Where(character => character.CampaignId == campaignId).Select(character => character.Id).ToArray();
|
||||
foreach (var characterId in affectedCharacterIds)
|
||||
stateStore.CharactersById[characterId].CampaignId = null;
|
||||
|
||||
stateStore.RollLog.RemoveAll(entry => entry.CampaignId == campaignId);
|
||||
stateStore.CampaignStateById.Remove(campaignId);
|
||||
}
|
||||
|
||||
private void DeleteCharacterLocked(Guid characterId)
|
||||
{
|
||||
if (!stateStore.CharactersById.TryGetValue(characterId, out var character))
|
||||
return;
|
||||
|
||||
var campaignId = character.CampaignId;
|
||||
stateStore.CharactersById.Remove(characterId);
|
||||
|
||||
var skillGroupIds = stateStore.SkillGroupsById.Values.Where(group => group.CharacterId == characterId).Select(group => group.Id).ToHashSet();
|
||||
foreach (var skillGroupId in skillGroupIds)
|
||||
stateStore.SkillGroupsById.Remove(skillGroupId);
|
||||
|
||||
var skillIds = stateStore.SkillsById.Values.Where(skill => skill.CharacterId == characterId).Select(skill => skill.Id).ToHashSet();
|
||||
foreach (var skillId in skillIds)
|
||||
stateStore.SkillsById.Remove(skillId);
|
||||
|
||||
stateStore.RollLog.RemoveAll(entry => entry.CharacterId == characterId || skillIds.Contains(entry.SkillId));
|
||||
|
||||
foreach (var account in stateStore.UsersById.Values.Where(account => account.ActiveCharacterId == characterId))
|
||||
account.ActiveCharacterId = null;
|
||||
|
||||
stateStore.RemoveCharacterStateLocked(campaignId, characterId);
|
||||
stateStore.TouchRosterLocked(campaignId);
|
||||
}
|
||||
}
|
||||
@@ -28,15 +28,16 @@ public interface IGameService
|
||||
ServiceResult<bool> ActivateCharacter(string sessionToken, Guid characterId);
|
||||
ServiceResult<IReadOnlyList<CharacterSummary>> GetOwnCharacters(string sessionToken);
|
||||
|
||||
ServiceResult<SkillGroupSummary> CreateSkillGroup(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble);
|
||||
ServiceResult<SkillGroupSummary> UpdateSkillGroup(string sessionToken, Guid skillGroupId, string name, string diceRollDefinition, int wildDice, bool allowFumble);
|
||||
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<bool> DeleteSkillGroup(string sessionToken, Guid skillGroupId);
|
||||
ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null);
|
||||
ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null);
|
||||
ServiceResult<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, bool rolemasterAutoRetry = false);
|
||||
ServiceResult<bool> DeleteSkill(string sessionToken, Guid skillId);
|
||||
ServiceResult<CharacterSheet> GetCharacterSheet(string sessionToken, Guid characterId);
|
||||
|
||||
ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility);
|
||||
ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility, int situationalModifier = 0);
|
||||
ServiceResult<RollResult> RollCustom(string sessionToken, Guid characterId, string expression, string visibility);
|
||||
ServiceResult<IReadOnlyList<CampaignLogEntry>> GetCampaignLog(string sessionToken, Guid campaignId);
|
||||
ServiceResult<CampaignLogPage> GetCampaignLogPage(string sessionToken, Guid campaignId, Guid? afterRollId = null, int? limit = null);
|
||||
ServiceResult<CampaignRollDetail> GetRollDetail(string sessionToken, Guid rollId);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user