Compare commits
12 Commits
b8bd92e3dc
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c99544baf | |||
| 8e730af85d | |||
| 66607e51eb | |||
| ecc799ae7f | |||
| ff28f70b51 | |||
| 20c8868744 | |||
| b80e9f1aec | |||
| d74f8a65a9 | |||
| c79bea86b6 | |||
| e7ae0e00c1 | |||
| 43bd68e707 | |||
| e574b4a37b |
@@ -8,25 +8,8 @@ These tools are installed and available: Python3, geckodriver, Selenium
|
|||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
- Prefer extracting code to a shared helper to be reused instead of duplicating code. Always keep high maintainability standards.
|
- After every iteration, run `jb cleanupcode --build=False '$file1' '$file2' ...` for every C# file you touched.
|
||||||
- Always place each newly created class into its own file. The file name must match the class name.
|
|
||||||
- When asked to begin working on a task, create a detailed implementation plan first, present the plan to the user, and ask for approval before beginning with the actual implementation.
|
|
||||||
- Don't make assumptions in the plan. If necessary, ask all clarifying questions before presenting the final plan.
|
|
||||||
- When an task is finished, perform a code review to evaluate if the change is clean and maintainable with high software engineering standards. Iterate on the code and repeat the review process until satisfied.
|
|
||||||
- If there's documnentation present, always keep it updated.
|
|
||||||
- After every iteration, evaluate if the test coverage would fall below 100%, and write tests if necessary.
|
|
||||||
- After every iteration, run `dotnet jb cleanupcode --build=False '$file1' '$file2' ...` for every C# file you touched.
|
|
||||||
- After every frontend change, verify the results using a geckodriver+Selenium run.
|
- After every frontend change, verify the results using a geckodriver+Selenium run.
|
||||||
- When browser verification needs the app running, launch the app against a temporary copy of `src\RolemasterDb.App\rolemaster.db` so verification does not mutate the canonical DB.
|
|
||||||
|
|
||||||
### Git
|
|
||||||
|
|
||||||
- Never change the .gitignore file without consent.
|
|
||||||
- Keep changes small with minimal churn and commit often. If one iteration encompasses many smaller tasks with more than one commit, create a git branch and do the commits there. Let me review the branch before merging it back to master.
|
|
||||||
- When multiple commits are necessary, pause after every commit and ask the user to give a command to proceed.
|
|
||||||
- After every iteration, do a git commit with a brief summary of the changes as a commit message.
|
|
||||||
- If you find unexpected changes in the code (deletions, changes, diff results that were not communicated), never revert them and never restore the old state. Assume that those changes happened with intent.
|
|
||||||
- Never use `git restore`, `git checkout --`, reset commands, or equivalent rollback actions to discard local changes unless the user explicitly asks for that exact rollback.
|
|
||||||
|
|
||||||
### Dotnet CLI
|
### Dotnet CLI
|
||||||
|
|
||||||
|
|||||||
27
AGENTS.md
27
AGENTS.md
@@ -1 +1,26 @@
|
|||||||
This is a linux environment, read `AGENTS.linux.md`.
|
# Agent Guide
|
||||||
|
|
||||||
|
Detect which operating system you're currently running on.
|
||||||
|
If this is a linux environment, read `AGENTS.linux.md`.
|
||||||
|
If this is a windows environment, read `AGENTS.windows.md`.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- Prefer extracting code to a shared helper to be reused instead of duplicating code. Always keep high maintainability standards.
|
||||||
|
- Keep changes as small as possible, design solutions that achieve the goals with minimal churn.
|
||||||
|
- Always place each newly created class into its own file. The file name must match the class name.
|
||||||
|
- When asked to begin wor~~~~king on a task, create a detailed implementation plan first, present the plan to the user, and ask for approval before beginning with the actual implementation.
|
||||||
|
- Don't make assumptions in the plan. If necessary, ask all clarifying questions before presenting the final plan.
|
||||||
|
- When an task is finished, perform a code review to evaluate if the change is clean and maintainable with high software engineering standards. Iterate on the code and repeat the review process until satisfied.
|
||||||
|
- If there's documnentation present, always keep it updated.
|
||||||
|
- After every iteration, evaluate if the test coverage would fall below 100%, and write tests if necessary.
|
||||||
|
- When browser verification needs the app running, launch the app against a temporary copy of `src\RolemasterDb.App\rolemaster.db` so verification does not mutate the canonical DB.
|
||||||
|
|
||||||
|
### Git
|
||||||
|
|
||||||
|
- Never change the .gitignore file without consent.
|
||||||
|
- Keep changes small and commit often. If one iteration encompasses many smaller tasks with more than one commit, create a git branch and do the commits there. Let me review the branch before merging it back to master.
|
||||||
|
- When multiple commits are necessary, pause after every commit and ask the user to give a command to proceed.
|
||||||
|
- After every iteration, do a git commit with a brief summary of the changes as a commit message.
|
||||||
|
- If you find unexpected changes in the code (deletions, changes, diff results that were not communicated), never revert them and never restore the old state. Assume that those changes happened with intent.
|
||||||
|
- Never use `git restore`, `git checkout --`, reset commands, or equivalent rollback actions to discard local changes unless the user explicitly asks for that exact rollback.
|
||||||
|
|||||||
@@ -12,30 +12,14 @@ These tool paths should be used instead of any entry in the PATH environment var
|
|||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
- Prefer extracting code to a shared helper to be reused instead of duplicating code. Always keep high maintainability standards.
|
- After every iteration, run `dotnet jb cleanupcode --build=False '$file1' '$file2' ...` for every C# file you touched.
|
||||||
- Keep changes as small as possible, design solutions that achieve the goals with minimal churn.
|
|
||||||
- Always place each newly created class into its own file. The file name must match the class name.
|
|
||||||
- When asked to begin working on a task, create a detailed implementation plan first, present the plan to the user, and ask for approval before beginning with the actual implementation.
|
|
||||||
- Don't make assumptions in the plan. If necessary, ask all clarifying questions before presenting the final plan.
|
|
||||||
- When an task is finished, perform a code review to evaluate if the change is clean and maintainable with high software engineering standards. Iterate on the code and repeat the review process until satisfied.
|
|
||||||
- After the implementation is finished, verify all changed files, and run `python D:\Code\crlf.py $file1 $file2 ...` only for files you recognize, in order to normalize all line endings of all touched files to CRLF.
|
- After the implementation is finished, verify all changed files, and run `python D:\Code\crlf.py $file1 $file2 ...` only for files you recognize, in order to normalize all line endings of all touched files to CRLF.
|
||||||
- If there's documnentation present, always keep it updated.
|
|
||||||
- After every iteration, evaluate if the test coverage would fall below 100%, and write tests if necessary.
|
|
||||||
- After every iteration, run `jb cleanupcode --build=False '$file1' '$file2' ...` for every C# file you touched.
|
|
||||||
- After every frontend change, verify the results using an ephemeral Playwright run.
|
- After every frontend change, verify the results using an ephemeral Playwright run.
|
||||||
- For ad hoc verification in this repo, do not default to `npx playwright test` with a temp spec outside the repo.
|
- For ad hoc verification in this repo, do not default to `npx playwright test` with a temp spec outside the repo.
|
||||||
- Prefer a repo-local ephemeral Node script under `artifacts_verify/` that imports `playwright` with `require('playwright')` and drives the browser directly.
|
- Prefer a repo-local ephemeral Node script under `artifacts_verify/` that imports `playwright` with `require('playwright')` and drives the browser directly.
|
||||||
- If using the Playwright test runner, use the repo-local CLI at `node_modules\.bin\playwright.cmd` and keep the spec inside the repo so local `node_modules` resolution works.
|
- If using the Playwright test runner, use the repo-local CLI at `node_modules\.bin\playwright.cmd` and keep the spec inside the repo so local `node_modules` resolution works.
|
||||||
- Do not mix the global Playwright CLI with the repo-local `@playwright/test` package.
|
- Do not mix the global Playwright CLI with the repo-local `@playwright/test` package.
|
||||||
- When browser verification needs the app running, launch the app against a temporary copy of `src\RolemasterDb.App\rolemaster.db` so verification does not mutate the canonical DB.
|
- When browser verification needs the app running, launch the app against a temporary copy of `src\RolemasterDb.App\rolemaster.db` so verification does not mutate the canonical DB.
|
||||||
### Git
|
|
||||||
|
|
||||||
- Never change the .gitignore file without consent.
|
|
||||||
- Keep changes small and commit often. If one iteration encompasses many smaller tasks with more than one commit, create a git branch and do the commits there. Let me review the branch before merging it back to master.
|
|
||||||
- When multiple commits are necessary, pause after every commit and ask the user to give a command to proceed.
|
|
||||||
- After every iteration, do a git commit with a brief summary of the changes as a commit message.
|
|
||||||
- If you find unexpected changes in the code (deletions, changes, diff results that were not communicated), never revert them and never restore the old state. Assume that those changes happened with intent.
|
|
||||||
- Never use `git restore`, `git checkout --`, reset commands, or equivalent rollback actions to discard local changes unless the user explicitly asks for that exact rollback.
|
|
||||||
|
|
||||||
### PowerShell
|
### PowerShell
|
||||||
|
|
||||||
|
|||||||
419
POSTMORTEM.md
419
POSTMORTEM.md
@@ -1,419 +0,0 @@
|
|||||||
# POSTMORTEM
|
|
||||||
|
|
||||||
## Executive Summary
|
|
||||||
|
|
||||||
RpgRoller failed in Firefox with RoboForm enabled because the authenticated workspace was built as a highly reactive Blazor Server surface that performs several structural rerenders immediately after login while assuming stable ownership of the rendered DOM.
|
|
||||||
|
|
||||||
RoboForm was the trigger, not the root cause.
|
|
||||||
|
|
||||||
The root cause was architectural:
|
|
||||||
|
|
||||||
- the app mixed static HTML auth, interactive Blazor Server UI, browser-managed session state, JavaScript fetch calls, and SSE live updates into one startup path
|
|
||||||
- the authenticated workspace bootstrap was driven from `OnAfterRenderAsync` and intentionally caused follow-up renders
|
|
||||||
- the root shell in [App.razor](/home/frank/Code/RpgRoller/RpgRoller/Components/App.razor) still branches on request-time `HttpContext` and session-cookie state
|
|
||||||
- the workspace render tree is large, form-heavy, and sensitive to DOM mutation during early batches
|
|
||||||
|
|
||||||
That combination made the app fragile under browser extensions that legitimately modify login and form-related DOM.
|
|
||||||
|
|
||||||
## Incident Summary
|
|
||||||
|
|
||||||
### User-visible symptoms
|
|
||||||
|
|
||||||
- Firefox in a normal profile crashed the Blazor circuit immediately after or around login
|
|
||||||
- the browser console reported:
|
|
||||||
- `Error: There was an error applying batch ...`
|
|
||||||
- `TypeError: can't access property "insertBefore", n.parentNode is null`
|
|
||||||
- the server logged `RemoteRenderer[100]` and terminated the circuit
|
|
||||||
- the UI often degraded into `Loading user...`, `Offline fallback`, or a partially rendered play screen before failing
|
|
||||||
|
|
||||||
### Scope
|
|
||||||
|
|
||||||
- The failure reproduced only in a normal Firefox profile with RoboForm enabled
|
|
||||||
- The failure did not reproduce in a private Firefox window
|
|
||||||
- The failure did not reproduce after disabling RoboForm
|
|
||||||
- The failure was not tied to a specific username or database row
|
|
||||||
|
|
||||||
### Trigger vs. Root Cause
|
|
||||||
|
|
||||||
Trigger:
|
|
||||||
|
|
||||||
- RoboForm mutated form-related DOM in the page
|
|
||||||
|
|
||||||
Root cause:
|
|
||||||
|
|
||||||
- the app architecture depended on Blazor Server retaining stable ownership of a DOM subtree that was undergoing immediate, multi-batch structural changes during startup
|
|
||||||
|
|
||||||
## Current Architecture
|
|
||||||
|
|
||||||
### Root shell
|
|
||||||
|
|
||||||
The application entry point is [Program.cs](/home/frank/Code/RpgRoller/RpgRoller/Program.cs):
|
|
||||||
|
|
||||||
- `AddRazorComponents().AddInteractiveServerComponents()`
|
|
||||||
- `MapRazorComponents<App>().AddInteractiveServerRenderMode()`
|
|
||||||
- `AddScoped<RpgRollerApiClient>()`
|
|
||||||
- `AddScoped<WorkspaceQueryService>()`
|
|
||||||
|
|
||||||
The root component is [App.razor](/home/frank/Code/RpgRoller/RpgRoller/Components/App.razor). It does two different things at `/`:
|
|
||||||
|
|
||||||
- if the incoming request has no valid session cookie, it renders [StaticAuthPage.razor](/home/frank/Code/RpgRoller/RpgRoller/Components/Pages/HomeControls/StaticAuthPage.razor) as plain HTML
|
|
||||||
- otherwise it boots the Blazor app with `<Routes @rendermode="InteractiveServer(prerender: false)" />`
|
|
||||||
|
|
||||||
This decision is made from request-time state:
|
|
||||||
|
|
||||||
- `HttpContext`
|
|
||||||
- request path
|
|
||||||
- session cookie
|
|
||||||
- `IGameService.GetUserBySession`
|
|
||||||
|
|
||||||
### Auth flow
|
|
||||||
|
|
||||||
The auth page is no longer a Blazor form.
|
|
||||||
|
|
||||||
[StaticAuthPage.razor](/home/frank/Code/RpgRoller/RpgRoller/Components/Pages/HomeControls/StaticAuthPage.razor) renders plain HTML forms, and [rpgroller-api.js](/home/frank/Code/RpgRoller/RpgRoller/wwwroot/js/rpgroller-api.js) binds submit handlers that:
|
|
||||||
|
|
||||||
- validate in JS
|
|
||||||
- call `/api/auth/register` or `/api/auth/login` via `fetch`
|
|
||||||
- on login, force a full `window.location.assign("/")`
|
|
||||||
|
|
||||||
This means the app has one browser experience before login and a different ownership model after login.
|
|
||||||
|
|
||||||
### Authenticated workspace
|
|
||||||
|
|
||||||
The authenticated `/` route is [Home.razor](/home/frank/Code/RpgRoller/RpgRoller/Components/Pages/Home.razor), which only renders `<Workspace LoggedOut="OnLoggedOutAsync" />`.
|
|
||||||
|
|
||||||
The real composition root is [Workspace.razor.cs](/home/frank/Code/RpgRoller/RpgRoller/Components/Pages/Workspace.razor.cs). It wires:
|
|
||||||
|
|
||||||
- `WorkspaceSessionCoordinator`
|
|
||||||
- `WorkspaceCampaignScopeCoordinator`
|
|
||||||
- `WorkspaceCampaignCoordinator`
|
|
||||||
- `WorkspacePlayCoordinator`
|
|
||||||
- `WorkspaceAdminCoordinator`
|
|
||||||
- `WorkspaceLiveStateController`
|
|
||||||
- `WorkspaceFeedbackService`
|
|
||||||
|
|
||||||
The current startup path is driven by `OnAfterRenderAsync`:
|
|
||||||
|
|
||||||
1. first interactive render occurs
|
|
||||||
2. `Session.InitializeAsync()` runs
|
|
||||||
3. `StateHasChanged()` is invoked
|
|
||||||
4. later renders enable more controls such as the character controls and custom roll composer
|
|
||||||
|
|
||||||
### State channels
|
|
||||||
|
|
||||||
The workspace state is not owned by one subsystem. It is spread across four channels:
|
|
||||||
|
|
||||||
1. Blazor component state in [WorkspaceState.cs](/home/frank/Code/RpgRoller/RpgRoller/Components/Pages/WorkspaceState.cs)
|
|
||||||
2. browser `sessionStorage` via `rpgroller-api.js`
|
|
||||||
3. browser `fetch` requests through [RpgRollerApiClient.cs](/home/frank/Code/RpgRoller/RpgRoller/Components/RpgRollerApiClient.cs) and [WorkspaceQueryService.cs](/home/frank/Code/RpgRoller/RpgRoller/Components/WorkspaceQueryService.cs)
|
|
||||||
4. SSE live updates from `/api/events/state` via [StateEventEndpoints.cs](/home/frank/Code/RpgRoller/RpgRoller/Api/StateEventEndpoints.cs)
|
|
||||||
|
|
||||||
### Backend state model
|
|
||||||
|
|
||||||
The backend is not stateless HTTP over a database. [GameService.cs](/home/frank/Code/RpgRoller/RpgRoller/Services/GameService.cs) builds an in-memory runtime state from SQLite at startup using [GameStateStore.cs](/home/frank/Code/RpgRoller/RpgRoller/Services/GameStateStore.cs), then serves reads and writes against that state.
|
|
||||||
|
|
||||||
This matters because the frontend already has multiple live state concepts:
|
|
||||||
|
|
||||||
- the Blazor circuit
|
|
||||||
- the JS app state
|
|
||||||
- the SSE stream
|
|
||||||
- the server-side runtime store
|
|
||||||
|
|
||||||
That complexity is manageable only if the UI ownership boundaries stay clean. They did not.
|
|
||||||
|
|
||||||
## Architectural Timeline
|
|
||||||
|
|
||||||
### February 25, 2026
|
|
||||||
|
|
||||||
Blazor was introduced as the frontend host:
|
|
||||||
|
|
||||||
- `a8ee637` `Scaffold Blazor frontend host and root components`
|
|
||||||
- `35c60c4` `Replace frontend with Blazor UX implementation`
|
|
||||||
|
|
||||||
This established Blazor Server as the UI owner.
|
|
||||||
|
|
||||||
### February 26 to April 5, 2026
|
|
||||||
|
|
||||||
The workspace became denser and more interactive:
|
|
||||||
|
|
||||||
- `c3aa0d4` `Overhaul workspace UX for denser play workflow`
|
|
||||||
- `bf3a6fa` `Persist roll visibility preference across workspace reloads`
|
|
||||||
- `54aabc6` `Unify play management and admin screens in workspace`
|
|
||||||
- `6ea91ee` `Add targeted workspace live refresh`
|
|
||||||
- `e42c0fb` `Load campaign logs incrementally`
|
|
||||||
- `9e6e6fe` `Add custom campaign roll composer`
|
|
||||||
- `4af1c87` through `b291d05` extracted coordinators and simplified the composition root
|
|
||||||
|
|
||||||
This refactor improved code organization, but it also increased the number of reactive moving parts:
|
|
||||||
|
|
||||||
- more persistent UI state in `sessionStorage`
|
|
||||||
- more conditional screen branching inside one workspace root
|
|
||||||
- more input-heavy controls on the default play screen
|
|
||||||
- a live SSE side channel that can trigger refreshes after startup
|
|
||||||
|
|
||||||
### May 2 to May 4, 2026
|
|
||||||
|
|
||||||
This period contains direct evidence of mitigation attempts after the Firefox failure surfaced:
|
|
||||||
|
|
||||||
- `2d2ed56` `Isolate anonymous auth page from Blazor`
|
|
||||||
- `1f19bf7` `Restore workspace prerender and auth errors`
|
|
||||||
- `231b0ac` `Remove workspace session-token coupling`
|
|
||||||
- `da81358` `Delay workspace render until session init completes`
|
|
||||||
- `e0b7d27` `Stage workspace controls after bootstrap`
|
|
||||||
|
|
||||||
These commits are valuable evidence because they show the app was being repaired at the symptom boundary:
|
|
||||||
|
|
||||||
- first by removing auth from Blazor ownership
|
|
||||||
- then by changing prerender behavior
|
|
||||||
- then by removing `HttpContext`-captured session coupling from workspace queries
|
|
||||||
- then by staging workspace startup
|
|
||||||
|
|
||||||
None of those changes removed the underlying architectural fragility: a Blazor Server workspace that still reshapes a large, extension-visible DOM over several early render batches.
|
|
||||||
|
|
||||||
## Root Causes
|
|
||||||
|
|
||||||
### 1. DOM ownership was not treated as a hard architectural boundary
|
|
||||||
|
|
||||||
The app used Blazor Server for a form-heavy authenticated workspace, while also operating in a browser environment where password managers are expected to inject or wrap form controls.
|
|
||||||
|
|
||||||
In principle that can work, but only when the rendered DOM is stable enough that third-party mutation does not race against structural rerenders.
|
|
||||||
|
|
||||||
RpgRoller violated that assumption:
|
|
||||||
|
|
||||||
- immediate post-login renders reshaped the workspace
|
|
||||||
- input-bearing controls were mounted during startup
|
|
||||||
- later state syncs continued changing the same subtree
|
|
||||||
|
|
||||||
That made the DOM ownership contract weak.
|
|
||||||
|
|
||||||
### 2. Startup was centered on `OnAfterRenderAsync` instead of a stable initial model
|
|
||||||
|
|
||||||
[Workspace.razor.cs](/home/frank/Code/RpgRoller/RpgRoller/Components/Pages/Workspace.razor.cs) drives initialization from `OnAfterRenderAsync`, then explicitly schedules more rerenders.
|
|
||||||
|
|
||||||
That has two consequences:
|
|
||||||
|
|
||||||
- the first visible authenticated frame is not the final intended frame
|
|
||||||
- the renderer must apply several batches while the browser is already free to run extensions against the DOM
|
|
||||||
|
|
||||||
This is a poor fit for DOM-mutating extensions.
|
|
||||||
|
|
||||||
### 3. The root shell still depends on request-time `HttpContext`
|
|
||||||
|
|
||||||
[App.razor](/home/frank/Code/RpgRoller/RpgRoller/Components/App.razor) still uses `HttpContext` and the session cookie to decide whether to render:
|
|
||||||
|
|
||||||
- static auth HTML
|
|
||||||
- or the interactive app
|
|
||||||
|
|
||||||
Even after removing the old workspace session-token accessor, the root shell still relies on request-only state to choose the subtree for `/`.
|
|
||||||
|
|
||||||
This is a fragile architectural seam because the app is half request-rendered and half interactive, with the split encoded in the component tree itself.
|
|
||||||
|
|
||||||
### 4. Too many reactive state channels were active during the same startup window
|
|
||||||
|
|
||||||
During or shortly after login, the workspace may react to:
|
|
||||||
|
|
||||||
- `sessionStorage` reads
|
|
||||||
- API reads through `fetch`
|
|
||||||
- Blazor rerenders from `StateHasChanged`
|
|
||||||
- SSE connection state transitions
|
|
||||||
- SSE state events
|
|
||||||
|
|
||||||
That is too much coordination for a large render tree if DOM stability is required.
|
|
||||||
|
|
||||||
### 5. The workspace root remained too large and too structurally dynamic
|
|
||||||
|
|
||||||
[Workspace.razor](/home/frank/Code/RpgRoller/RpgRoller/Components/Pages/Workspace.razor) controls:
|
|
||||||
|
|
||||||
- header
|
|
||||||
- play screen
|
|
||||||
- campaign management
|
|
||||||
- admin screen
|
|
||||||
- toasts
|
|
||||||
- character modals
|
|
||||||
- Rolemaster modal
|
|
||||||
|
|
||||||
The play screen itself contains multiple conditional branches and input surfaces.
|
|
||||||
|
|
||||||
Even though code-behind files and coordinators improved organization, the rendered root still has a large rerender blast radius.
|
|
||||||
|
|
||||||
### 6. Documentation drift hid the architecture change
|
|
||||||
|
|
||||||
The current [README.md](/home/frank/Code/RpgRoller/README.md) still describes:
|
|
||||||
|
|
||||||
- `Home.razor` as a gateway that switches between loading, auth, and authenticated workspace views
|
|
||||||
- `WorkspaceQueryService` as “server-side read model access”
|
|
||||||
|
|
||||||
Neither description matches the current code:
|
|
||||||
|
|
||||||
- `Home.razor` now just renders `Workspace`
|
|
||||||
- `App.razor` became the real gateway
|
|
||||||
- `WorkspaceQueryService` now calls browser `fetch` through `RpgRollerApiClient`
|
|
||||||
|
|
||||||
This kind of drift usually means the architecture has moved faster than the design was reevaluated.
|
|
||||||
|
|
||||||
## Why the Failure Was Hard to Fix Incrementally
|
|
||||||
|
|
||||||
The failure was not caused by one broken line. It emerged from several acceptable local decisions that interacted badly:
|
|
||||||
|
|
||||||
- static auth was added to avoid Blazor auth-page failures
|
|
||||||
- workspace prerender behavior was changed to satisfy session bootstrap
|
|
||||||
- direct server-side workspace reads were removed to avoid `HttpContext` coupling
|
|
||||||
- render staging was added to reduce early DOM churn
|
|
||||||
|
|
||||||
Each change improved one seam while leaving the overall architecture intact.
|
|
||||||
|
|
||||||
That is why the issue kept moving:
|
|
||||||
|
|
||||||
- first the auth page crashed
|
|
||||||
- then login worked but workspace bootstrap stalled
|
|
||||||
- then the play view partially rendered but later crashed
|
|
||||||
|
|
||||||
The architecture allowed the failure to migrate between phases instead of disappearing.
|
|
||||||
|
|
||||||
## Evidence From Recent Fix Attempts
|
|
||||||
|
|
||||||
### `2d2ed56` on May 2, 2026
|
|
||||||
|
|
||||||
`Isolate anonymous auth page from Blazor`
|
|
||||||
|
|
||||||
What it changed:
|
|
||||||
|
|
||||||
- added `StaticAuthPage.razor`
|
|
||||||
- moved login/register handling into `rpgroller-api.js`
|
|
||||||
- changed `App.razor` to serve static auth HTML when unauthenticated
|
|
||||||
|
|
||||||
What it revealed:
|
|
||||||
|
|
||||||
- removing Blazor from the auth page improved the anonymous path
|
|
||||||
- the underlying crash still existed in the authenticated workspace path
|
|
||||||
|
|
||||||
### `1f19bf7` on May 2, 2026
|
|
||||||
|
|
||||||
`Restore workspace prerender and auth errors`
|
|
||||||
|
|
||||||
What it changed:
|
|
||||||
|
|
||||||
- adjusted render mode behavior again
|
|
||||||
- kept better auth error reporting
|
|
||||||
|
|
||||||
What it revealed:
|
|
||||||
|
|
||||||
- workspace startup was still entangled with earlier render-mode decisions
|
|
||||||
- the app was using render-mode changes as a corrective mechanism rather than as a stable architecture choice
|
|
||||||
|
|
||||||
### `231b0ac` on May 3, 2026
|
|
||||||
|
|
||||||
`Remove workspace session-token coupling`
|
|
||||||
|
|
||||||
What it changed:
|
|
||||||
|
|
||||||
- deleted `WorkspaceSessionTokenAccessor`
|
|
||||||
- changed `WorkspaceQueryService` to use API calls instead of direct service access
|
|
||||||
|
|
||||||
What it revealed:
|
|
||||||
|
|
||||||
- the previous architecture had leaked request-time session access into interactive workspace startup
|
|
||||||
- removing that coupling was necessary, but not sufficient, because the DOM ownership problem remained
|
|
||||||
|
|
||||||
### `da81358` on May 4, 2026
|
|
||||||
|
|
||||||
`Delay workspace render until session init completes`
|
|
||||||
|
|
||||||
What it changed:
|
|
||||||
|
|
||||||
- replaced the early workspace UI with a loading shell
|
|
||||||
|
|
||||||
What it revealed:
|
|
||||||
|
|
||||||
- broad render suppression was too blunt
|
|
||||||
- it masked, rather than removed, the actual failing rerender path
|
|
||||||
|
|
||||||
### `e0b7d27` on May 4, 2026
|
|
||||||
|
|
||||||
`Stage workspace controls after bootstrap`
|
|
||||||
|
|
||||||
What it changed:
|
|
||||||
|
|
||||||
- restored the base workspace
|
|
||||||
- deferred some input-heavy controls to later batches
|
|
||||||
|
|
||||||
What it revealed:
|
|
||||||
|
|
||||||
- the crash moved later in startup
|
|
||||||
- the base play view could survive, but later structural updates still failed
|
|
||||||
|
|
||||||
Taken together, these commits are evidence that the app was being pushed toward compatibility through localized mitigations, while the larger architecture still tolerated unstable startup ownership.
|
|
||||||
|
|
||||||
## What Actually Failed
|
|
||||||
|
|
||||||
The practical failure mode was:
|
|
||||||
|
|
||||||
1. login succeeded
|
|
||||||
2. the authenticated workspace circuit started
|
|
||||||
3. early render batches built or reshaped a large DOM subtree
|
|
||||||
4. RoboForm touched form-related DOM inside that subtree
|
|
||||||
5. Blazor attempted to apply a later batch using DOM assumptions that were no longer true
|
|
||||||
6. the browser-side batch apply failed with `insertBefore ... parentNode is null`
|
|
||||||
7. the server terminated the circuit
|
|
||||||
|
|
||||||
The `Offline fallback` label was mostly a consequence:
|
|
||||||
|
|
||||||
- once the circuit failed, live-state coordination could not complete cleanly
|
|
||||||
- the connection-state UI then reflected that degraded state
|
|
||||||
|
|
||||||
## Findings
|
|
||||||
|
|
||||||
### Primary finding
|
|
||||||
|
|
||||||
The authenticated workspace should not have been architected as a multi-batch, structurally dynamic, form-heavy startup surface if compatibility with password managers and other DOM-mutating extensions is a requirement.
|
|
||||||
|
|
||||||
### Secondary findings
|
|
||||||
|
|
||||||
- `App.razor` became a hidden architecture boundary without being treated as one
|
|
||||||
- the workspace composition root is still too structurally broad
|
|
||||||
- frontend ownership is split between Blazor and handwritten JS in a way that complicates startup reasoning
|
|
||||||
- live updates were added as another reactivity source before the UI ownership model was made robust
|
|
||||||
- documentation no longer described the actual architecture, making corrective design work harder
|
|
||||||
|
|
||||||
## Remediation Directions
|
|
||||||
|
|
||||||
These are architectural directions, not the implementation plan.
|
|
||||||
|
|
||||||
### 1. Choose a single ownership model for the authenticated shell
|
|
||||||
|
|
||||||
The authenticated shell should not be partly “request-decided” and partly “interactive-decided” in a component tree that still relies on request-time state.
|
|
||||||
|
|
||||||
### 2. Stop using `OnAfterRenderAsync` as the main workspace bootstrap orchestrator
|
|
||||||
|
|
||||||
The authenticated workspace needs a stable initial render contract with fewer structural follow-up diffs.
|
|
||||||
|
|
||||||
### 3. Reduce startup-state multiplicity
|
|
||||||
|
|
||||||
The startup path should not require simultaneous coordination across:
|
|
||||||
|
|
||||||
- Blazor state
|
|
||||||
- `sessionStorage`
|
|
||||||
- `fetch`
|
|
||||||
- SSE
|
|
||||||
|
|
||||||
at least not before the UI is stable.
|
|
||||||
|
|
||||||
### 4. Shrink the render blast radius
|
|
||||||
|
|
||||||
The workspace root should own less structural branching. The more isolated the screen and control subtrees are, the less likely a third-party DOM mutation is to invalidate a broad diff.
|
|
||||||
|
|
||||||
### 5. Treat extension compatibility as a design requirement
|
|
||||||
|
|
||||||
Password managers are not an edge case for login and form-driven applications. The UI architecture must assume that form controls can be wrapped, annotated, or moved by browser software.
|
|
||||||
|
|
||||||
### 6. Realign documentation with the code
|
|
||||||
|
|
||||||
The design notes and README need to describe the actual architecture before a stable fix plan is made. Otherwise future changes will continue to optimize around an outdated mental model.
|
|
||||||
|
|
||||||
## Conclusion
|
|
||||||
|
|
||||||
RpgRoller did not fail because RoboForm existed. It failed because the app’s frontend architecture evolved into a shape where the authenticated workspace depended on fragile early render batches, mixed ownership boundaries, and multiple overlapping state channels.
|
|
||||||
|
|
||||||
RoboForm exposed that weakness reliably.
|
|
||||||
|
|
||||||
The correct next step is not another isolated workaround. The correct next step is to redesign the authenticated shell and workspace startup path around stable DOM ownership and simpler state flow.
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# RpgRoller
|
# RpgRoller
|
||||||
|
|
||||||
RpgRoller is an ASP.NET Core and Blazor Server app for lightweight tabletop campaign play, character sheets, and dice workflows.
|
RpgRoller is an ASP.NET Core and Blazor Server app for lightweight tabletop campaign play, character sheets, and dice workflows.
|
||||||
|
|
||||||
@@ -54,7 +54,8 @@ Frontend:
|
|||||||
- `RpgRoller/Components/RpgRollerApiClient.cs`: browser API client for write actions
|
- `RpgRoller/Components/RpgRollerApiClient.cs`: browser API client for write actions
|
||||||
- `RpgRoller/Components/WorkspaceQueryService.cs`: browser-facing read client for workspace data
|
- `RpgRoller/Components/WorkspaceQueryService.cs`: browser-facing read client for workspace data
|
||||||
- `RpgRoller/wwwroot/js/rpgroller-api.js`: browser interop for auth forms, session storage, SSE wiring, and DOM helpers
|
- `RpgRoller/wwwroot/js/rpgroller-api.js`: browser interop for auth forms, session storage, SSE wiring, and DOM helpers
|
||||||
- `RpgRoller/wwwroot/styles.css`: app styling and responsive layout
|
- `RpgRoller/wwwroot/styles.css`: app styling, light and dark theme variables, and responsive layout
|
||||||
|
- `RpgRoller/wwwroot/images/light.webp` and `RpgRoller/wwwroot/images/dark.webp`: themed workspace background art
|
||||||
|
|
||||||
Current repo note:
|
Current repo note:
|
||||||
|
|
||||||
@@ -75,10 +76,11 @@ Current repo note:
|
|||||||
- Supported campaign rulesets: D6 System, D&D 5e, and Rolemaster
|
- Supported campaign rulesets: D6 System, D&D 5e, and Rolemaster
|
||||||
- Account registration, login, session-based auth, and role-aware authorization
|
- Account registration, login, session-based auth, and role-aware authorization
|
||||||
- Admin tools for user listing, role updates, account deletion, and direct SQLite database download
|
- Admin tools for user listing, role updates, account deletion, and direct SQLite database download
|
||||||
|
- Per-user light and dark theme preference with OS-based initial selection
|
||||||
- Campaign creation, roster reads, participant-scoped visibility, and owner and admin deletion
|
- Campaign creation, roster reads, participant-scoped visibility, and owner and admin deletion
|
||||||
- Character creation, activation, owner transfer, campaign reassignment or unlinking, and owner and admin deletion
|
- Character creation, activation, owner transfer, campaign reassignment or unlinking, and owner and admin deletion
|
||||||
- Skill groups with reusable defaults plus skill and skill-group create, edit, reassign, and delete flows
|
- Skill groups with reusable defaults plus skill and skill-group create, edit, reassign, and delete flows
|
||||||
- Owner-scoped play workspace that lists only the current user's characters while preserving GM and admin management capabilities
|
- Play workspace that lists the current user's characters, or the full active campaign roster when the user is that campaign's GM
|
||||||
- Campaign log paging, lazy-loaded roll detail, compact summaries, and live state refresh through SSE
|
- Campaign log paging, lazy-loaded roll detail, compact summaries, and live state refresh through SSE
|
||||||
- Custom roll submission from the play screen without creating a persisted skill
|
- Custom roll submission from the play screen without creating a persisted skill
|
||||||
- Instant skill filtering in the character panel
|
- Instant skill filtering in the character panel
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace RpgRoller.Tests;
|
namespace RpgRoller.Tests;
|
||||||
|
|
||||||
public sealed class AuthApiTests(WebApplicationFactory<Program> factory) : ApiTestBase(factory)
|
public sealed class AuthApiTests(WebApplicationFactory<Program> factory) : ApiTestBase(factory)
|
||||||
{
|
{
|
||||||
@@ -12,8 +12,7 @@ public sealed class AuthApiTests(WebApplicationFactory<Program> factory) : ApiTe
|
|||||||
Assert.Equal("alice", registerResult.Username);
|
Assert.Equal("alice", registerResult.Username);
|
||||||
Assert.Contains(registerResult.Roles, role => string.Equals(role, "admin", StringComparison.OrdinalIgnoreCase));
|
Assert.Contains(registerResult.Roles, role => string.Equals(role, "admin", StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
var duplicate = await client.PostAsJsonAsync("/api/auth/register",
|
var duplicate = await client.PostAsJsonAsync("/api/auth/register", new RegisterRequest("alice", "Password123", "Alice 2"));
|
||||||
new RegisterRequest("alice", "Password123", "Alice 2"));
|
|
||||||
Assert.Equal(HttpStatusCode.BadRequest, duplicate.StatusCode);
|
Assert.Equal(HttpStatusCode.BadRequest, duplicate.StatusCode);
|
||||||
|
|
||||||
var loginResult = await client.PostAsJsonAsync("/api/auth/login", new LoginRequest("alice", "Password123"));
|
var loginResult = await client.PostAsJsonAsync("/api/auth/login", new LoginRequest("alice", "Password123"));
|
||||||
@@ -21,13 +20,39 @@ public sealed class AuthApiTests(WebApplicationFactory<Program> factory) : ApiTe
|
|||||||
|
|
||||||
var me = await GetAsync<MeResponse>(client, "/api/me");
|
var me = await GetAsync<MeResponse>(client, "/api/me");
|
||||||
Assert.Equal(registerResult.Id, me.User.Id);
|
Assert.Equal(registerResult.Id, me.User.Id);
|
||||||
|
Assert.Null(me.User.ThemePreference);
|
||||||
Assert.Null(me.ActiveCharacterId);
|
Assert.Null(me.ActiveCharacterId);
|
||||||
Assert.Null(me.CurrentCampaignId);
|
Assert.Null(me.CurrentCampaignId);
|
||||||
|
|
||||||
|
var themeUser = await PutAsync<UpdateThemePreferenceRequest, UserSummary>(client, "/api/me/theme", new("dark"));
|
||||||
|
Assert.Equal("dark", themeUser.ThemePreference);
|
||||||
|
|
||||||
|
var themedMe = await GetAsync<MeResponse>(client, "/api/me");
|
||||||
|
Assert.Equal("dark", themedMe.User.ThemePreference);
|
||||||
|
|
||||||
var invalidLogin = await client.PostAsJsonAsync("/api/auth/login", new LoginRequest("alice", "wrong-password"));
|
var invalidLogin = await client.PostAsJsonAsync("/api/auth/login", new LoginRequest("alice", "wrong-password"));
|
||||||
Assert.Equal(HttpStatusCode.BadRequest, invalidLogin.StatusCode);
|
Assert.Equal(HttpStatusCode.BadRequest, invalidLogin.StatusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ThemePreferenceEndpoint_RequiresAuthAndValidTheme()
|
||||||
|
{
|
||||||
|
using var factory = CreateFactory();
|
||||||
|
using var client = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||||
|
|
||||||
|
var unauthorized = await client.PutAsJsonAsync("/api/me/theme", new UpdateThemePreferenceRequest("dark"));
|
||||||
|
Assert.Equal(HttpStatusCode.Unauthorized, unauthorized.StatusCode);
|
||||||
|
|
||||||
|
var unauthorizedInvalid = await client.PutAsJsonAsync("/api/me/theme", new UpdateThemePreferenceRequest("sepia"));
|
||||||
|
Assert.Equal(HttpStatusCode.Unauthorized, unauthorizedInvalid.StatusCode);
|
||||||
|
|
||||||
|
await RegisterAsync(client, "theme-api", "Password123", "Theme Api");
|
||||||
|
await LoginAsync(client, "theme-api", "Password123");
|
||||||
|
|
||||||
|
var invalid = await client.PutAsJsonAsync("/api/me/theme", new UpdateThemePreferenceRequest("sepia"));
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, invalid.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task UsernamesEndpoint_RequiresAuthAndReturnsAlphabeticalList()
|
public async Task UsernamesEndpoint_RequiresAuthAndReturnsAlphabeticalList()
|
||||||
{
|
{
|
||||||
@@ -54,10 +79,7 @@ public sealed class AuthApiTests(WebApplicationFactory<Program> factory) : ApiTe
|
|||||||
|
|
||||||
await RegisterAsync(client, "proxy-user", "Password123", "Proxy User");
|
await RegisterAsync(client, "proxy-user", "Password123", "Proxy User");
|
||||||
|
|
||||||
using var request = new HttpRequestMessage(HttpMethod.Post, "/api/auth/login")
|
using var request = new HttpRequestMessage(HttpMethod.Post, "/api/auth/login") { Content = JsonContent.Create(new LoginRequest("proxy-user", "Password123")) };
|
||||||
{
|
|
||||||
Content = JsonContent.Create(new LoginRequest("proxy-user", "Password123"))
|
|
||||||
};
|
|
||||||
request.Headers.TryAddWithoutValidation("X-Forwarded-Proto", "https");
|
request.Headers.TryAddWithoutValidation("X-Forwarded-Proto", "https");
|
||||||
|
|
||||||
using var response = await client.SendAsync(request);
|
using var response = await client.SendAsync(request);
|
||||||
|
|||||||
@@ -13,26 +13,33 @@ public sealed class CampaignApiTests(WebApplicationFactory<Program> factory) : A
|
|||||||
await RegisterAsync(gmClient, "gm", "Password123", "Game Master");
|
await RegisterAsync(gmClient, "gm", "Password123", "Game Master");
|
||||||
await LoginAsync(gmClient, "gm", "Password123");
|
await LoginAsync(gmClient, "gm", "Password123");
|
||||||
|
|
||||||
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns", new("Alpha Campaign", "dnd5e"));
|
var campaign =
|
||||||
|
await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns",
|
||||||
|
new("Alpha Campaign", "dnd5e"));
|
||||||
|
|
||||||
var gmCharacter = await PostAsync<CreateCharacterRequest, CharacterSummary>(gmClient, "/api/characters", new("Arin", campaign.Id));
|
var gmCharacter =
|
||||||
|
await PostAsync<CreateCharacterRequest, CharacterSummary>(gmClient, "/api/characters",
|
||||||
|
new("Arin", campaign.Id));
|
||||||
Assert.Equal("Game Master", gmCharacter.OwnerDisplayName);
|
Assert.Equal("Game Master", gmCharacter.OwnerDisplayName);
|
||||||
|
|
||||||
var activateResponse = await gmClient.PostAsync($"/api/characters/{gmCharacter.Id}/activate", null);
|
var activateResponse = await gmClient.PostAsync($"/api/characters/{gmCharacter.Id}/activate", null);
|
||||||
Assert.Equal(HttpStatusCode.OK, activateResponse.StatusCode);
|
Assert.Equal(HttpStatusCode.OK, activateResponse.StatusCode);
|
||||||
|
|
||||||
var createdSkill = await PostAsync<CreateSkillRequest, SkillSummary>(gmClient, $"/api/characters/{gmCharacter.Id}/skills", new("Arcana", "2d12+2", 0, false));
|
var createdSkill = await PostAsync<CreateSkillRequest, SkillSummary>(gmClient,
|
||||||
|
$"/api/characters/{gmCharacter.Id}/skills", new("Arcana", "2d12+2", 0, false));
|
||||||
Assert.Equal("2d12+2", createdSkill.DiceRollDefinition);
|
Assert.Equal("2d12+2", createdSkill.DiceRollDefinition);
|
||||||
Assert.Equal(0, createdSkill.WildDice);
|
Assert.Equal(0, createdSkill.WildDice);
|
||||||
Assert.False(createdSkill.AllowFumble);
|
Assert.False(createdSkill.AllowFumble);
|
||||||
|
|
||||||
var updatedSkill = await PutAsync<UpdateSkillRequest, SkillSummary>(gmClient, $"/api/skills/{createdSkill.Id}", new("Arcana Mastery", "2d12+3", 0, false));
|
var updatedSkill = await PutAsync<UpdateSkillRequest, SkillSummary>(gmClient, $"/api/skills/{createdSkill.Id}",
|
||||||
|
new("Arcana Mastery", "2d12+3", 0, false));
|
||||||
Assert.Equal("Arcana Mastery", updatedSkill.Name);
|
Assert.Equal("Arcana Mastery", updatedSkill.Name);
|
||||||
Assert.Equal("2d12+3", updatedSkill.DiceRollDefinition);
|
Assert.Equal("2d12+3", updatedSkill.DiceRollDefinition);
|
||||||
Assert.Equal(0, updatedSkill.WildDice);
|
Assert.Equal(0, updatedSkill.WildDice);
|
||||||
Assert.False(updatedSkill.AllowFumble);
|
Assert.False(updatedSkill.AllowFumble);
|
||||||
|
|
||||||
var invalidSkill = await gmClient.PostAsJsonAsync($"/api/characters/{gmCharacter.Id}/skills", new CreateSkillRequest("Broken", "5D+4", 0, false));
|
var invalidSkill = await gmClient.PostAsJsonAsync($"/api/characters/{gmCharacter.Id}/skills",
|
||||||
|
new CreateSkillRequest("Broken", "5D+4", 0, false));
|
||||||
Assert.Equal(HttpStatusCode.BadRequest, invalidSkill.StatusCode);
|
Assert.Equal(HttpStatusCode.BadRequest, invalidSkill.StatusCode);
|
||||||
|
|
||||||
var details = await GetAsync<CampaignRoster>(gmClient, $"/api/campaigns/{campaign.Id}");
|
var details = await GetAsync<CampaignRoster>(gmClient, $"/api/campaigns/{campaign.Id}");
|
||||||
@@ -53,14 +60,49 @@ public sealed class CampaignApiTests(WebApplicationFactory<Program> factory) : A
|
|||||||
Assert.Equal(gmCharacter.Id, currentCampaignCharacters[0].Id);
|
Assert.Equal(gmCharacter.Id, currentCampaignCharacters[0].Id);
|
||||||
Assert.Equal("Game Master", currentCampaignCharacters[0].OwnerDisplayName);
|
Assert.Equal("Game Master", currentCampaignCharacters[0].OwnerDisplayName);
|
||||||
|
|
||||||
var otherCampaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns", new("Beta Campaign", "d6"));
|
var otherCampaign =
|
||||||
|
await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns",
|
||||||
|
new("Beta Campaign", "d6"));
|
||||||
|
|
||||||
var updatedCharacter = await PutAsync<UpdateCharacterRequest, CharacterSummary>(gmClient, $"/api/characters/{gmCharacter.Id}", new("Arin Updated", otherCampaign.Id));
|
var updatedCharacter = await PutAsync<UpdateCharacterRequest, CharacterSummary>(gmClient,
|
||||||
|
$"/api/characters/{gmCharacter.Id}", new("Arin Updated", otherCampaign.Id));
|
||||||
|
|
||||||
Assert.Equal("Arin Updated", updatedCharacter.Name);
|
Assert.Equal("Arin Updated", updatedCharacter.Name);
|
||||||
Assert.Equal(otherCampaign.Id, updatedCharacter.CampaignId);
|
Assert.Equal(otherCampaign.Id, updatedCharacter.CampaignId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GmCanActivateAnotherPlayersCharacter_AndMeReflectsCampaignContext()
|
||||||
|
{
|
||||||
|
using var factory = CreateFactory(3, 3, 3);
|
||||||
|
using var gmClient = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||||
|
using var playerClient = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||||
|
using var outsiderClient = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||||
|
|
||||||
|
await RegisterAsync(gmClient, "gm-activate", "Password123", "GM");
|
||||||
|
await RegisterAsync(playerClient, "player-activate", "Password123", "Player");
|
||||||
|
await RegisterAsync(outsiderClient, "outsider-activate", "Password123", "Outsider");
|
||||||
|
|
||||||
|
await LoginAsync(gmClient, "gm-activate", "Password123");
|
||||||
|
await LoginAsync(playerClient, "player-activate", "Password123");
|
||||||
|
await LoginAsync(outsiderClient, "outsider-activate", "Password123");
|
||||||
|
|
||||||
|
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns",
|
||||||
|
new("Activation Campaign", "d6"));
|
||||||
|
var playerCharacter = await PostAsync<CreateCharacterRequest, CharacterSummary>(playerClient, "/api/characters",
|
||||||
|
new("Scout", campaign.Id));
|
||||||
|
|
||||||
|
var gmActivate = await gmClient.PostAsync($"/api/characters/{playerCharacter.Id}/activate", null);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, gmActivate.StatusCode);
|
||||||
|
|
||||||
|
var gmMe = await GetAsync<MeResponse>(gmClient, "/api/me");
|
||||||
|
Assert.Equal(playerCharacter.Id, gmMe.ActiveCharacterId);
|
||||||
|
Assert.Equal(campaign.Id, gmMe.CurrentCampaignId);
|
||||||
|
|
||||||
|
var outsiderActivate = await outsiderClient.PostAsync($"/api/characters/{playerCharacter.Id}/activate", null);
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, outsiderActivate.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CampaignCreation_AcceptsRolemasterRuleset()
|
public async Task CampaignCreation_AcceptsRolemasterRuleset()
|
||||||
{
|
{
|
||||||
@@ -70,7 +112,9 @@ public sealed class CampaignApiTests(WebApplicationFactory<Program> factory) : A
|
|||||||
await RegisterAsync(gmClient, "gm-rm-api", "Password123", "Game Master");
|
await RegisterAsync(gmClient, "gm-rm-api", "Password123", "Game Master");
|
||||||
await LoginAsync(gmClient, "gm-rm-api", "Password123");
|
await LoginAsync(gmClient, "gm-rm-api", "Password123");
|
||||||
|
|
||||||
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns", new("Shadow World", "rolemaster"));
|
var campaign =
|
||||||
|
await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns",
|
||||||
|
new("Shadow World", "rolemaster"));
|
||||||
|
|
||||||
Assert.Equal("rolemaster", campaign.RulesetId);
|
Assert.Equal("rolemaster", campaign.RulesetId);
|
||||||
}
|
}
|
||||||
@@ -84,23 +128,32 @@ public sealed class CampaignApiTests(WebApplicationFactory<Program> factory) : A
|
|||||||
await RegisterAsync(gmClient, "gm-rm-skill", "Password123", "Game Master");
|
await RegisterAsync(gmClient, "gm-rm-skill", "Password123", "Game Master");
|
||||||
await LoginAsync(gmClient, "gm-rm-skill", "Password123");
|
await LoginAsync(gmClient, "gm-rm-skill", "Password123");
|
||||||
|
|
||||||
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns", new("Shadow World", "rolemaster"));
|
var campaign =
|
||||||
var character = await PostAsync<CreateCharacterRequest, CharacterSummary>(gmClient, "/api/characters", new("Kalen", campaign.Id));
|
await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns",
|
||||||
|
new("Shadow World", "rolemaster"));
|
||||||
|
var character =
|
||||||
|
await PostAsync<CreateCharacterRequest, CharacterSummary>(gmClient, "/api/characters",
|
||||||
|
new("Kalen", campaign.Id));
|
||||||
|
|
||||||
var missingFumbleRange = await gmClient.PostAsJsonAsync($"/api/characters/{character.Id}/skills", new CreateSkillRequest("Bad Open Ended", "d100!+35", 0, false));
|
var missingFumbleRange = await gmClient.PostAsJsonAsync($"/api/characters/{character.Id}/skills",
|
||||||
|
new CreateSkillRequest("Bad Open Ended", "d100!+35", 0, false));
|
||||||
Assert.Equal(HttpStatusCode.BadRequest, missingFumbleRange.StatusCode);
|
Assert.Equal(HttpStatusCode.BadRequest, missingFumbleRange.StatusCode);
|
||||||
|
|
||||||
var group = await PostAsync<CreateSkillGroupRequest, SkillGroupSummary>(gmClient, $"/api/characters/{character.Id}/skill-groups", new("Perception", "d100!+15", 0, false, 5));
|
var group = await PostAsync<CreateSkillGroupRequest, SkillGroupSummary>(gmClient,
|
||||||
|
$"/api/characters/{character.Id}/skill-groups", new("Perception", "d100!+15", 0, false, 5));
|
||||||
Assert.Equal(5, group.FumbleRange);
|
Assert.Equal(5, group.FumbleRange);
|
||||||
|
|
||||||
var invalidRetry = await gmClient.PostAsJsonAsync($"/api/characters/{character.Id}/skills", new CreateSkillRequest("Bad Retry", "d100+35", 0, false, group.Id, null, true));
|
var invalidRetry = await gmClient.PostAsJsonAsync($"/api/characters/{character.Id}/skills",
|
||||||
|
new CreateSkillRequest("Bad Retry", "d100+35", 0, false, group.Id, null, true));
|
||||||
Assert.Equal(HttpStatusCode.BadRequest, invalidRetry.StatusCode);
|
Assert.Equal(HttpStatusCode.BadRequest, invalidRetry.StatusCode);
|
||||||
|
|
||||||
var skill = await PostAsync<CreateSkillRequest, SkillSummary>(gmClient, $"/api/characters/{character.Id}/skills", new("Awareness", "d100!+35", 0, false, group.Id, 3, true));
|
var skill = await PostAsync<CreateSkillRequest, SkillSummary>(gmClient,
|
||||||
|
$"/api/characters/{character.Id}/skills", new("Awareness", "d100!+35", 0, false, group.Id, 3, true));
|
||||||
Assert.Equal(3, skill.FumbleRange);
|
Assert.Equal(3, skill.FumbleRange);
|
||||||
Assert.True(skill.RolemasterAutoRetry);
|
Assert.True(skill.RolemasterAutoRetry);
|
||||||
|
|
||||||
var updatedSkill = await PutAsync<UpdateSkillRequest, SkillSummary>(gmClient, $"/api/skills/{skill.Id}", new("Awareness", "d100!+45", 0, false, group.Id, 4, true));
|
var updatedSkill = await PutAsync<UpdateSkillRequest, SkillSummary>(gmClient, $"/api/skills/{skill.Id}",
|
||||||
|
new("Awareness", "d100!+45", 0, false, group.Id, 4, true));
|
||||||
Assert.Equal(4, updatedSkill.FumbleRange);
|
Assert.Equal(4, updatedSkill.FumbleRange);
|
||||||
Assert.True(updatedSkill.RolemasterAutoRetry);
|
Assert.True(updatedSkill.RolemasterAutoRetry);
|
||||||
|
|
||||||
@@ -128,23 +181,31 @@ public sealed class CampaignApiTests(WebApplicationFactory<Program> factory) : A
|
|||||||
await RegisterAsync(receiverClient, "receiver2", "Password123", "Receiver");
|
await RegisterAsync(receiverClient, "receiver2", "Password123", "Receiver");
|
||||||
await LoginAsync(receiverClient, "receiver2", "Password123");
|
await LoginAsync(receiverClient, "receiver2", "Password123");
|
||||||
|
|
||||||
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns", new("Grouped Campaign", "d6"));
|
var campaign =
|
||||||
var character = await PostAsync<CreateCharacterRequest, CharacterSummary>(ownerClient, "/api/characters", new("Grouped Hero", campaign.Id));
|
await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns",
|
||||||
|
new("Grouped Campaign", "d6"));
|
||||||
|
var character = await PostAsync<CreateCharacterRequest, CharacterSummary>(ownerClient, "/api/characters",
|
||||||
|
new("Grouped Hero", campaign.Id));
|
||||||
|
|
||||||
var createdGroup = await PostAsync<CreateSkillGroupRequest, SkillGroupSummary>(ownerClient, $"/api/characters/{character.Id}/skill-groups", new("Combat", "2D+1", 1, true));
|
var createdGroup = await PostAsync<CreateSkillGroupRequest, SkillGroupSummary>(ownerClient,
|
||||||
var renamedGroup = await PutAsync<UpdateSkillGroupRequest, SkillGroupSummary>(gmClient, $"/api/skill-groups/{createdGroup.Id}", new("Battle", "3D+2", 2, false));
|
$"/api/characters/{character.Id}/skill-groups", new("Combat", "2D+1", 1, true));
|
||||||
|
var renamedGroup = await PutAsync<UpdateSkillGroupRequest, SkillGroupSummary>(gmClient,
|
||||||
|
$"/api/skill-groups/{createdGroup.Id}", new("Battle", "3D+2", 2, false));
|
||||||
Assert.Equal("Battle", renamedGroup.Name);
|
Assert.Equal("Battle", renamedGroup.Name);
|
||||||
Assert.Equal("3D+2", renamedGroup.DiceRollDefinition);
|
Assert.Equal("3D+2", renamedGroup.DiceRollDefinition);
|
||||||
Assert.Equal(2, renamedGroup.WildDice);
|
Assert.Equal(2, renamedGroup.WildDice);
|
||||||
Assert.False(renamedGroup.AllowFumble);
|
Assert.False(renamedGroup.AllowFumble);
|
||||||
|
|
||||||
var groupedSkill = await PostAsync<CreateSkillRequest, SkillSummary>(ownerClient, $"/api/characters/{character.Id}/skills", new("Strike", "2D+1", 1, true, renamedGroup.Id));
|
var groupedSkill = await PostAsync<CreateSkillRequest, SkillSummary>(ownerClient,
|
||||||
|
$"/api/characters/{character.Id}/skills", new("Strike", "2D+1", 1, true, renamedGroup.Id));
|
||||||
Assert.Equal(renamedGroup.Id, groupedSkill.SkillGroupId);
|
Assert.Equal(renamedGroup.Id, groupedSkill.SkillGroupId);
|
||||||
|
|
||||||
var ungroupedSkill = await PutAsync<UpdateSkillRequest, SkillSummary>(ownerClient, $"/api/skills/{groupedSkill.Id}", new("Strike", "2D+1", 1, true));
|
var ungroupedSkill = await PutAsync<UpdateSkillRequest, SkillSummary>(ownerClient,
|
||||||
|
$"/api/skills/{groupedSkill.Id}", new("Strike", "2D+1", 1, true));
|
||||||
Assert.Null(ungroupedSkill.SkillGroupId);
|
Assert.Null(ungroupedSkill.SkillGroupId);
|
||||||
|
|
||||||
var groupedAgainSkill = await PutAsync<UpdateSkillRequest, SkillSummary>(ownerClient, $"/api/skills/{groupedSkill.Id}", new("Strike", "2D+1", 1, true, renamedGroup.Id));
|
var groupedAgainSkill = await PutAsync<UpdateSkillRequest, SkillSummary>(ownerClient,
|
||||||
|
$"/api/skills/{groupedSkill.Id}", new("Strike", "2D+1", 1, true, renamedGroup.Id));
|
||||||
Assert.Equal(renamedGroup.Id, groupedAgainSkill.SkillGroupId);
|
Assert.Equal(renamedGroup.Id, groupedAgainSkill.SkillGroupId);
|
||||||
|
|
||||||
var deleteSkill = await ownerClient.DeleteAsync($"/api/skills/{groupedAgainSkill.Id}");
|
var deleteSkill = await ownerClient.DeleteAsync($"/api/skills/{groupedAgainSkill.Id}");
|
||||||
@@ -153,7 +214,8 @@ public sealed class CampaignApiTests(WebApplicationFactory<Program> factory) : A
|
|||||||
var deleteGroup = await ownerClient.DeleteAsync($"/api/skill-groups/{renamedGroup.Id}");
|
var deleteGroup = await ownerClient.DeleteAsync($"/api/skill-groups/{renamedGroup.Id}");
|
||||||
Assert.Equal(HttpStatusCode.OK, deleteGroup.StatusCode);
|
Assert.Equal(HttpStatusCode.OK, deleteGroup.StatusCode);
|
||||||
|
|
||||||
var transferResult = await PutAsync<UpdateCharacterRequest, CharacterSummary>(gmClient, $"/api/characters/{character.Id}", new("Grouped Hero", campaign.Id, "receiver2"));
|
var transferResult = await PutAsync<UpdateCharacterRequest, CharacterSummary>(gmClient,
|
||||||
|
$"/api/characters/{character.Id}", new("Grouped Hero", campaign.Id, "receiver2"));
|
||||||
Assert.Equal("Grouped Hero", transferResult.Name);
|
Assert.Equal("Grouped Hero", transferResult.Name);
|
||||||
Assert.Equal("Receiver", transferResult.OwnerDisplayName);
|
Assert.Equal("Receiver", transferResult.OwnerDisplayName);
|
||||||
|
|
||||||
@@ -190,12 +252,17 @@ public sealed class CampaignApiTests(WebApplicationFactory<Program> factory) : A
|
|||||||
Assert.Contains(adminEntry.Roles, role => string.Equals(role, "admin", StringComparison.OrdinalIgnoreCase));
|
Assert.Contains(adminEntry.Roles, role => string.Equals(role, "admin", StringComparison.OrdinalIgnoreCase));
|
||||||
Assert.Empty(playerEntry.Roles);
|
Assert.Empty(playerEntry.Roles);
|
||||||
|
|
||||||
var promotedPlayer = await PutAsync<UpdateUserRolesRequest, AdminUserSummary>(adminClient, $"/api/admin/users/{player.Id}/roles", new(["admin"]));
|
var promotedPlayer = await PutAsync<UpdateUserRolesRequest, AdminUserSummary>(adminClient,
|
||||||
|
$"/api/admin/users/{player.Id}/roles", new(["admin"]));
|
||||||
Assert.Contains(promotedPlayer.Roles, role => string.Equals(role, "admin", StringComparison.OrdinalIgnoreCase));
|
Assert.Contains(promotedPlayer.Roles, role => string.Equals(role, "admin", StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns", new("Disposable Campaign", "d6"));
|
var campaign =
|
||||||
var character = await PostAsync<CreateCharacterRequest, CharacterSummary>(playerClient, "/api/characters", new("Disposable Hero", campaign.Id));
|
await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns",
|
||||||
var skill = await PostAsync<CreateSkillRequest, SkillSummary>(playerClient, $"/api/characters/{character.Id}/skills", new("Stealth", "2D+1", 1, true));
|
new("Disposable Campaign", "d6"));
|
||||||
|
var character = await PostAsync<CreateCharacterRequest, CharacterSummary>(playerClient, "/api/characters",
|
||||||
|
new("Disposable Hero", campaign.Id));
|
||||||
|
var skill = await PostAsync<CreateSkillRequest, SkillSummary>(playerClient,
|
||||||
|
$"/api/characters/{character.Id}/skills", new("Stealth", "2D+1", 1, true));
|
||||||
_ = await PostAsync<RollSkillRequest, RollResult>(playerClient, $"/api/skills/{skill.Id}/roll", new("public"));
|
_ = await PostAsync<RollSkillRequest, RollResult>(playerClient, $"/api/skills/{skill.Id}/roll", new("public"));
|
||||||
|
|
||||||
var deleteCampaign = await adminClient.DeleteAsync($"/api/campaigns/{campaign.Id}");
|
var deleteCampaign = await adminClient.DeleteAsync($"/api/campaigns/{campaign.Id}");
|
||||||
@@ -267,13 +334,18 @@ public sealed class CampaignApiTests(WebApplicationFactory<Program> factory) : A
|
|||||||
await RegisterAsync(playerClient, "player-options", "Password123", "Player");
|
await RegisterAsync(playerClient, "player-options", "Password123", "Player");
|
||||||
await LoginAsync(playerClient, "player-options", "Password123");
|
await LoginAsync(playerClient, "player-options", "Password123");
|
||||||
|
|
||||||
var firstCampaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns", new("Alpha Visible", "d6"));
|
var firstCampaign =
|
||||||
var secondCampaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(otherGmClient, "/api/campaigns", new("Beta Available", "d6"));
|
await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns",
|
||||||
|
new("Alpha Visible", "d6"));
|
||||||
|
var secondCampaign =
|
||||||
|
await PostAsync<CreateCampaignRequest, CampaignSummary>(otherGmClient, "/api/campaigns",
|
||||||
|
new("Beta Available", "d6"));
|
||||||
|
|
||||||
var playerVisibleCampaigns = await GetAsync<IReadOnlyList<CampaignSummary>>(playerClient, "/api/campaigns");
|
var playerVisibleCampaigns = await GetAsync<IReadOnlyList<CampaignSummary>>(playerClient, "/api/campaigns");
|
||||||
Assert.Empty(playerVisibleCampaigns);
|
Assert.Empty(playerVisibleCampaigns);
|
||||||
|
|
||||||
var playerCampaignOptions = await GetAsync<IReadOnlyList<CampaignOption>>(playerClient, "/api/campaigns/options");
|
var playerCampaignOptions =
|
||||||
|
await GetAsync<IReadOnlyList<CampaignOption>>(playerClient, "/api/campaigns/options");
|
||||||
Assert.Equal(2, playerCampaignOptions.Count);
|
Assert.Equal(2, playerCampaignOptions.Count);
|
||||||
Assert.Contains(playerCampaignOptions, option => option.Id == firstCampaign.Id);
|
Assert.Contains(playerCampaignOptions, option => option.Id == firstCampaign.Id);
|
||||||
Assert.Contains(playerCampaignOptions, option => option.Id == secondCampaign.Id);
|
Assert.Contains(playerCampaignOptions, option => option.Id == secondCampaign.Id);
|
||||||
@@ -300,9 +372,13 @@ public sealed class CampaignApiTests(WebApplicationFactory<Program> factory) : A
|
|||||||
await RegisterAsync(otherClient, "other-delete", "Password123", "Other");
|
await RegisterAsync(otherClient, "other-delete", "Password123", "Other");
|
||||||
await LoginAsync(otherClient, "other-delete", "Password123");
|
await LoginAsync(otherClient, "other-delete", "Password123");
|
||||||
|
|
||||||
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns", new("Deletion Campaign", "d6"));
|
var campaign =
|
||||||
var ownerCharacter = await PostAsync<CreateCharacterRequest, CharacterSummary>(ownerClient, "/api/characters", new("Owner Character", campaign.Id));
|
await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns",
|
||||||
var otherCharacter = await PostAsync<CreateCharacterRequest, CharacterSummary>(otherClient, "/api/characters", new("Other Character", campaign.Id));
|
new("Deletion Campaign", "d6"));
|
||||||
|
var ownerCharacter = await PostAsync<CreateCharacterRequest, CharacterSummary>(ownerClient, "/api/characters",
|
||||||
|
new("Owner Character", campaign.Id));
|
||||||
|
var otherCharacter = await PostAsync<CreateCharacterRequest, CharacterSummary>(otherClient, "/api/characters",
|
||||||
|
new("Other Character", campaign.Id));
|
||||||
|
|
||||||
var gmDeleteAttempt = await gmClient.DeleteAsync($"/api/characters/{ownerCharacter.Id}");
|
var gmDeleteAttempt = await gmClient.DeleteAsync($"/api/characters/{ownerCharacter.Id}");
|
||||||
Assert.Equal(HttpStatusCode.BadRequest, gmDeleteAttempt.StatusCode);
|
Assert.Equal(HttpStatusCode.BadRequest, gmDeleteAttempt.StatusCode);
|
||||||
@@ -333,14 +409,19 @@ public sealed class CampaignApiTests(WebApplicationFactory<Program> factory) : A
|
|||||||
await RegisterAsync(playerClient, "player-log-cap", "Password123", "Player");
|
await RegisterAsync(playerClient, "player-log-cap", "Password123", "Player");
|
||||||
await LoginAsync(playerClient, "player-log-cap", "Password123");
|
await LoginAsync(playerClient, "player-log-cap", "Password123");
|
||||||
|
|
||||||
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns", new("Log Cap", "d6"));
|
var campaign =
|
||||||
var character = await PostAsync<CreateCharacterRequest, CharacterSummary>(playerClient, "/api/characters", new("Roller", campaign.Id));
|
await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns", new("Log Cap", "d6"));
|
||||||
var skill = await PostAsync<CreateSkillRequest, SkillSummary>(playerClient, $"/api/characters/{character.Id}/skills", new("Stealth", "2D+1", 1, true));
|
var character =
|
||||||
|
await PostAsync<CreateCharacterRequest, CharacterSummary>(playerClient, "/api/characters",
|
||||||
|
new("Roller", campaign.Id));
|
||||||
|
var skill = await PostAsync<CreateSkillRequest, SkillSummary>(playerClient,
|
||||||
|
$"/api/characters/{character.Id}/skills", new("Stealth", "2D+1", 1, true));
|
||||||
|
|
||||||
var rollIds = new List<Guid>();
|
var rollIds = new List<Guid>();
|
||||||
for (var i = 0; i < 105; i++)
|
for (var i = 0; i < 105; i++)
|
||||||
{
|
{
|
||||||
var roll = await PostAsync<RollSkillRequest, RollResult>(playerClient, $"/api/skills/{skill.Id}/roll", new("public"));
|
var roll = await PostAsync<RollSkillRequest, RollResult>(playerClient, $"/api/skills/{skill.Id}/roll",
|
||||||
|
new("public"));
|
||||||
rollIds.Add(roll.RollId);
|
rollIds.Add(roll.RollId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,14 +450,19 @@ public sealed class CampaignApiTests(WebApplicationFactory<Program> factory) : A
|
|||||||
await RegisterAsync(playerClient, "player-log-page", "Password123", "Player");
|
await RegisterAsync(playerClient, "player-log-page", "Password123", "Player");
|
||||||
await LoginAsync(playerClient, "player-log-page", "Password123");
|
await LoginAsync(playerClient, "player-log-page", "Password123");
|
||||||
|
|
||||||
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns", new("Log Page", "d6"));
|
var campaign =
|
||||||
var character = await PostAsync<CreateCharacterRequest, CharacterSummary>(playerClient, "/api/characters", new("Roller", campaign.Id));
|
await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns", new("Log Page", "d6"));
|
||||||
var skill = await PostAsync<CreateSkillRequest, SkillSummary>(playerClient, $"/api/characters/{character.Id}/skills", new("Stealth", "2D+1", 1, true));
|
var character =
|
||||||
|
await PostAsync<CreateCharacterRequest, CharacterSummary>(playerClient, "/api/characters",
|
||||||
|
new("Roller", campaign.Id));
|
||||||
|
var skill = await PostAsync<CreateSkillRequest, SkillSummary>(playerClient,
|
||||||
|
$"/api/characters/{character.Id}/skills", new("Stealth", "2D+1", 1, true));
|
||||||
|
|
||||||
var rollIds = new List<Guid>();
|
var rollIds = new List<Guid>();
|
||||||
for (var i = 0; i < 5; i++)
|
for (var i = 0; i < 5; i++)
|
||||||
{
|
{
|
||||||
var roll = await PostAsync<RollSkillRequest, RollResult>(playerClient, $"/api/skills/{skill.Id}/roll", new("public"));
|
var roll = await PostAsync<RollSkillRequest, RollResult>(playerClient, $"/api/skills/{skill.Id}/roll",
|
||||||
|
new("public"));
|
||||||
rollIds.Add(roll.RollId);
|
rollIds.Add(roll.RollId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -393,8 +479,10 @@ public sealed class CampaignApiTests(WebApplicationFactory<Program> factory) : A
|
|||||||
Assert.False(string.IsNullOrWhiteSpace(entry.VisibilityLabel));
|
Assert.False(string.IsNullOrWhiteSpace(entry.VisibilityLabel));
|
||||||
});
|
});
|
||||||
|
|
||||||
var latestRoll = await PostAsync<RollSkillRequest, RollResult>(playerClient, $"/api/skills/{skill.Id}/roll", new("public"));
|
var latestRoll =
|
||||||
var incrementalPage = await GetAsync<CampaignLogPage>(gmClient, $"/api/campaigns/{campaign.Id}/log/page?afterRollId={initialPage.Cursor}&limit=3");
|
await PostAsync<RollSkillRequest, RollResult>(playerClient, $"/api/skills/{skill.Id}/roll", new("public"));
|
||||||
|
var incrementalPage = await GetAsync<CampaignLogPage>(gmClient,
|
||||||
|
$"/api/campaigns/{campaign.Id}/log/page?afterRollId={initialPage.Cursor}&limit=3");
|
||||||
|
|
||||||
Assert.Single(incrementalPage.Entries);
|
Assert.Single(incrementalPage.Entries);
|
||||||
Assert.Equal(latestRoll.RollId, incrementalPage.Entries[0].RollId);
|
Assert.Equal(latestRoll.RollId, incrementalPage.Entries[0].RollId);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using Microsoft.AspNetCore.Builder;
|
using Microsoft.AspNetCore.Builder;
|
||||||
using Microsoft.Data.Sqlite;
|
using Microsoft.Data.Sqlite;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
@@ -159,6 +159,7 @@ public sealed class HostingCoverageTests
|
|||||||
usersColumns.Add(usersTableInfoReader.GetString(1));
|
usersColumns.Add(usersTableInfoReader.GetString(1));
|
||||||
|
|
||||||
Assert.Contains("Roles", usersColumns);
|
Assert.Contains("Roles", usersColumns);
|
||||||
|
Assert.Contains("ThemePreference", usersColumns);
|
||||||
|
|
||||||
using var usersRoleCommand = verifyConnection.CreateCommand();
|
using var usersRoleCommand = verifyConnection.CreateCommand();
|
||||||
usersRoleCommand.CommandText = "SELECT Roles FROM Users WHERE UsernameNormalized = 'LEGACY-ADMIN';";
|
usersRoleCommand.CommandText = "SELECT Roles FROM Users WHERE UsernameNormalized = 'LEGACY-ADMIN';";
|
||||||
@@ -214,6 +215,11 @@ public sealed class HostingCoverageTests
|
|||||||
retryHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260414204309_AddRolemasterAutoRetry';";
|
retryHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260414204309_AddRolemasterAutoRetry';";
|
||||||
var retryHistoryCount = Convert.ToInt32(retryHistoryCommand.ExecuteScalar());
|
var retryHistoryCount = Convert.ToInt32(retryHistoryCommand.ExecuteScalar());
|
||||||
Assert.Equal(1, retryHistoryCount);
|
Assert.Equal(1, retryHistoryCount);
|
||||||
|
|
||||||
|
using var themeHistoryCommand = verifyConnection.CreateCommand();
|
||||||
|
themeHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260518183838_AddUserThemePreference';";
|
||||||
|
var themeHistoryCount = Convert.ToInt32(themeHistoryCommand.ExecuteScalar());
|
||||||
|
Assert.Equal(1, themeHistoryCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -359,6 +365,11 @@ public sealed class HostingCoverageTests
|
|||||||
retryHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260414204309_AddRolemasterAutoRetry';";
|
retryHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260414204309_AddRolemasterAutoRetry';";
|
||||||
var retryHistoryCount = Convert.ToInt32(retryHistoryCommand.ExecuteScalar());
|
var retryHistoryCount = Convert.ToInt32(retryHistoryCommand.ExecuteScalar());
|
||||||
Assert.Equal(1, retryHistoryCount);
|
Assert.Equal(1, retryHistoryCount);
|
||||||
|
|
||||||
|
using var themeHistoryCommand = verifyConnection.CreateCommand();
|
||||||
|
themeHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260518183838_AddUserThemePreference';";
|
||||||
|
var themeHistoryCount = Convert.ToInt32(themeHistoryCommand.ExecuteScalar());
|
||||||
|
Assert.Equal(1, themeHistoryCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -481,6 +492,15 @@ public sealed class HostingCoverageTests
|
|||||||
|
|
||||||
Assert.Contains("FumbleRange", skillGroupColumns);
|
Assert.Contains("FumbleRange", skillGroupColumns);
|
||||||
|
|
||||||
|
using var usersTableInfoCommand = verifyConnection.CreateCommand();
|
||||||
|
usersTableInfoCommand.CommandText = "PRAGMA table_info('Users');";
|
||||||
|
using var usersTableInfoReader = usersTableInfoCommand.ExecuteReader();
|
||||||
|
var usersColumns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
while (usersTableInfoReader.Read())
|
||||||
|
usersColumns.Add(usersTableInfoReader.GetString(1));
|
||||||
|
|
||||||
|
Assert.Contains("ThemePreference", usersColumns);
|
||||||
|
|
||||||
using var authorizationRolesHistoryCommand = verifyConnection.CreateCommand();
|
using var authorizationRolesHistoryCommand = verifyConnection.CreateCommand();
|
||||||
authorizationRolesHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260226170000_AddAuthorizationRoles';";
|
authorizationRolesHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260226170000_AddAuthorizationRoles';";
|
||||||
var authorizationRolesHistoryCount = Convert.ToInt32(authorizationRolesHistoryCommand.ExecuteScalar());
|
var authorizationRolesHistoryCount = Convert.ToInt32(authorizationRolesHistoryCommand.ExecuteScalar());
|
||||||
@@ -490,5 +510,10 @@ public sealed class HostingCoverageTests
|
|||||||
retryHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260414204309_AddRolemasterAutoRetry';";
|
retryHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260414204309_AddRolemasterAutoRetry';";
|
||||||
var retryHistoryCount = Convert.ToInt32(retryHistoryCommand.ExecuteScalar());
|
var retryHistoryCount = Convert.ToInt32(retryHistoryCommand.ExecuteScalar());
|
||||||
Assert.Equal(1, retryHistoryCount);
|
Assert.Equal(1, retryHistoryCount);
|
||||||
|
|
||||||
|
using var themeHistoryCommand = verifyConnection.CreateCommand();
|
||||||
|
themeHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260518183838_AddUserThemePreference';";
|
||||||
|
var themeHistoryCount = Convert.ToInt32(themeHistoryCommand.ExecuteScalar());
|
||||||
|
Assert.Equal(1, themeHistoryCount);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace RpgRoller.Tests;
|
namespace RpgRoller.Tests;
|
||||||
|
|
||||||
public sealed class ServiceAuthTests
|
public sealed class ServiceAuthTests
|
||||||
{
|
{
|
||||||
@@ -74,4 +74,26 @@ public sealed class ServiceAuthTests
|
|||||||
var usernames = ServiceTestSupport.GetValue(service.GetUsernames(session));
|
var usernames = ServiceTestSupport.GetValue(service.GetUsernames(session));
|
||||||
Assert.Equal(["amy", "bob", "zoe"], usernames);
|
Assert.Equal(["amy", "bob", "zoe"], usernames);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UpdateThemePreference_RequiresAuthAndPersistsSupportedTheme()
|
||||||
|
{
|
||||||
|
using var harness = ServiceTestSupport.CreateHarness();
|
||||||
|
var service = harness.Service;
|
||||||
|
|
||||||
|
service.Register("theme-user", "Password123", "Theme User");
|
||||||
|
var session = ServiceTestSupport.GetValue(service.Login("theme-user", "Password123")).SessionToken;
|
||||||
|
|
||||||
|
var unauthorized = service.UpdateThemePreference(string.Empty, "dark");
|
||||||
|
var invalid = service.UpdateThemePreference(session, "sepia");
|
||||||
|
var updated = service.UpdateThemePreference(session, "DARK");
|
||||||
|
|
||||||
|
Assert.False(unauthorized.Succeeded);
|
||||||
|
Assert.False(invalid.Succeeded);
|
||||||
|
Assert.True(updated.Succeeded);
|
||||||
|
Assert.Equal("dark", ServiceTestSupport.GetValue(updated).ThemePreference);
|
||||||
|
|
||||||
|
var me = ServiceTestSupport.GetValue(service.GetMe(session));
|
||||||
|
Assert.Equal("dark", me.User.ThemePreference);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace RpgRoller.Tests;
|
namespace RpgRoller.Tests;
|
||||||
|
|
||||||
public sealed class ServicePersistenceTests
|
public sealed class ServicePersistenceTests
|
||||||
{
|
{
|
||||||
@@ -32,12 +32,16 @@ public sealed class ServicePersistenceTests
|
|||||||
Assert.False(service.UpdateCharacter(string.Empty, ownerCharacter.Id, "Renamed", campaign.Id).Succeeded);
|
Assert.False(service.UpdateCharacter(string.Empty, ownerCharacter.Id, "Renamed", campaign.Id).Succeeded);
|
||||||
Assert.False(service.UpdateCharacter(ownerSession, Guid.NewGuid(), "Renamed", campaign.Id).Succeeded);
|
Assert.False(service.UpdateCharacter(ownerSession, Guid.NewGuid(), "Renamed", campaign.Id).Succeeded);
|
||||||
Assert.False(service.ActivateCharacter(string.Empty, ownerCharacter.Id).Succeeded);
|
Assert.False(service.ActivateCharacter(string.Empty, ownerCharacter.Id).Succeeded);
|
||||||
Assert.False(service.ActivateCharacter(gmSession, ownerCharacter.Id).Succeeded);
|
Assert.True(service.ActivateCharacter(gmSession, ownerCharacter.Id).Succeeded);
|
||||||
Assert.False(service.GetOwnCharacters(string.Empty).Succeeded);
|
Assert.False(service.GetOwnCharacters(string.Empty).Succeeded);
|
||||||
Assert.False(service.CreateSkill(string.Empty, ownerCharacter.Id, "Stealth", "2D+1", 1, true).Succeeded);
|
Assert.False(service.CreateSkill(string.Empty, ownerCharacter.Id, "Stealth", "2D+1", 1, true).Succeeded);
|
||||||
Assert.False(service.CreateSkill(ownerSession, Guid.NewGuid(), "Stealth", "2D+1", 1, true).Succeeded);
|
Assert.False(service.CreateSkill(ownerSession, Guid.NewGuid(), "Stealth", "2D+1", 1, true).Succeeded);
|
||||||
Assert.False(service.CreateSkill(otherSession, ownerCharacter.Id, "Stealth", "2D+1", 1, true).Succeeded);
|
Assert.False(service.CreateSkill(otherSession, ownerCharacter.Id, "Stealth", "2D+1", 1, true).Succeeded);
|
||||||
|
|
||||||
|
var gmMe = ServiceTestSupport.GetValue(service.GetMe(gmSession));
|
||||||
|
Assert.Equal(ownerCharacter.Id, gmMe.ActiveCharacterId);
|
||||||
|
Assert.Equal(campaign.Id, gmMe.CurrentCampaignId);
|
||||||
|
|
||||||
using (var db = harness.CreateDbContext())
|
using (var db = harness.CreateDbContext())
|
||||||
{
|
{
|
||||||
var ownerUser = db.Users.Single(u => u.UsernameNormalized == "OWNER");
|
var ownerUser = db.Users.Single(u => u.UsernameNormalized == "OWNER");
|
||||||
@@ -120,4 +124,22 @@ public sealed class ServicePersistenceTests
|
|||||||
Assert.Equal(3, reloadedSkill.FumbleRange);
|
Assert.Equal(3, reloadedSkill.FumbleRange);
|
||||||
Assert.True(reloadedSkill.RolemasterAutoRetry);
|
Assert.True(reloadedSkill.RolemasterAutoRetry);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UserThemePreference_PersistsAcrossDatabaseReload()
|
||||||
|
{
|
||||||
|
using var harness = ServiceTestSupport.CreateHarness();
|
||||||
|
var service = harness.Service;
|
||||||
|
|
||||||
|
service.Register("theme-persist", "Password123", "Theme Persist");
|
||||||
|
var session = ServiceTestSupport.GetValue(service.Login("theme-persist", "Password123")).SessionToken;
|
||||||
|
|
||||||
|
var updated = ServiceTestSupport.GetValue(service.UpdateThemePreference(session, "dark"));
|
||||||
|
Assert.Equal("dark", updated.ThemePreference);
|
||||||
|
|
||||||
|
using var reloadedHarness = ServiceTestSupport.CreateHarnessFromPath(harness.DbPath);
|
||||||
|
var me = ServiceTestSupport.GetValue(reloadedHarness.Service.GetMe(session));
|
||||||
|
|
||||||
|
Assert.Equal("dark", me.User.ThemePreference);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -41,7 +41,7 @@ public sealed class WorkspaceStateTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void PlaySelections_FilterToOwnedCharactersAndPreferSelectedThenActive()
|
public void PlaySelections_ForNonGm_FilterToOwnedCharactersAndPreferSelectedThenActive()
|
||||||
{
|
{
|
||||||
var userId = Guid.NewGuid();
|
var userId = Guid.NewGuid();
|
||||||
var ownedCharacter = new CharacterSummary(Guid.NewGuid(), "Owned", userId, Guid.NewGuid(), "User");
|
var ownedCharacter = new CharacterSummary(Guid.NewGuid(), "Owned", userId, Guid.NewGuid(), "User");
|
||||||
@@ -70,6 +70,25 @@ public sealed class WorkspaceStateTests
|
|||||||
Assert.Equal(ownedCharacter.Id, state.PlaySelectedCharacterId);
|
Assert.Equal(ownedCharacter.Id, state.PlaySelectedCharacterId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PlaySelections_ForGm_ExposeEntireCampaignAndKeepNonOwnedSelection()
|
||||||
|
{
|
||||||
|
var gmId = Guid.NewGuid();
|
||||||
|
var otherCharacter = new CharacterSummary(Guid.NewGuid(), "Other", Guid.NewGuid(), Guid.NewGuid(), "Other");
|
||||||
|
var ownedCharacter = new CharacterSummary(Guid.NewGuid(), "Owned", gmId, Guid.NewGuid(), "GM");
|
||||||
|
var state = new WorkspaceState
|
||||||
|
{
|
||||||
|
User = new(gmId, "gm", "GM", []),
|
||||||
|
SelectedCampaign = new(Guid.NewGuid(), "Alpha", "d6", new(gmId, "GM"),
|
||||||
|
[ownedCharacter, otherCharacter]),
|
||||||
|
SelectedCharacterId = otherCharacter.Id
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.Equal(2, state.PlaySelectedCampaign!.Characters.Length);
|
||||||
|
Assert.Equal(otherCharacter.Id, state.PlaySelectedCharacterId);
|
||||||
|
Assert.Equal(otherCharacter.Id, state.PlaySelectedCharacter!.Id);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void CampaignAndConnectionFlags_ReflectCurrentState()
|
public void CampaignAndConnectionFlags_ReflectCurrentState()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using Microsoft.AspNetCore.Http.HttpResults;
|
using Microsoft.AspNetCore.Http.HttpResults;
|
||||||
using RpgRoller.Contracts;
|
using RpgRoller.Contracts;
|
||||||
using RpgRoller.Services;
|
using RpgRoller.Services;
|
||||||
|
|
||||||
@@ -14,6 +14,12 @@ internal static class MeEndpoints
|
|||||||
return ApiResultMapper.ToApiResult(result);
|
return ApiResultMapper.ToApiResult(result);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group.MapPut("/me/theme", Results<Ok<UserSummary>, BadRequest<ApiError>, UnauthorizedHttpResult> (UpdateThemePreferenceRequest request, HttpContext context, IGameService game) =>
|
||||||
|
{
|
||||||
|
var result = game.UpdateThemePreference(context.GetRequiredSessionToken(), request.ThemePreference);
|
||||||
|
return ApiResultMapper.ToApiResult(result);
|
||||||
|
});
|
||||||
|
|
||||||
return group;
|
return group;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Binary file not shown.
@@ -1,4 +1,4 @@
|
|||||||
@using RpgRoller.Components.Pages.HomeControls
|
@using RpgRoller.Components.Pages.HomeControls
|
||||||
@attribute [ExcludeFromCodeCoverage]
|
@attribute [ExcludeFromCodeCoverage]
|
||||||
|
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
@@ -8,6 +8,12 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
<base href="@BaseHref"/>
|
<base href="@BaseHref"/>
|
||||||
<title>RpgRoller</title>
|
<title>RpgRoller</title>
|
||||||
|
<script>
|
||||||
|
document.documentElement.dataset.theme = window.matchMedia &&
|
||||||
|
window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||||
|
? "dark"
|
||||||
|
: "light";
|
||||||
|
</script>
|
||||||
<link rel="stylesheet" href="@Assets["styles.css"]"/>
|
<link rel="stylesheet" href="@Assets["styles.css"]"/>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
@using Microsoft.AspNetCore.Components
|
@using Microsoft.AspNetCore.Components
|
||||||
@using RpgRoller.Components.Pages.HomeControls
|
@using RpgRoller.Components.Pages.HomeControls
|
||||||
|
|
||||||
<CampaignManagementPanel
|
<CampaignManagementPanel
|
||||||
Campaigns="Workspace.State.Campaigns"
|
Campaigns="Workspace.State.Campaigns"
|
||||||
SelectedCampaignId="Workspace.State.SelectedCampaignId"
|
|
||||||
SelectedCampaign="Workspace.State.SelectedCampaign"
|
SelectedCampaign="Workspace.State.SelectedCampaign"
|
||||||
Rulesets="Workspace.State.Rulesets"
|
Rulesets="Workspace.State.Rulesets"
|
||||||
IsMutating="Workspace.State.IsMutating"
|
IsMutating="Workspace.State.IsMutating"
|
||||||
@@ -11,7 +10,6 @@
|
|||||||
CanEditCharacter="Workspace.Campaigns.CanEditCharacter"
|
CanEditCharacter="Workspace.Campaigns.CanEditCharacter"
|
||||||
CanDeleteCharacter="Workspace.Campaigns.CanDeleteCharacter"
|
CanDeleteCharacter="Workspace.Campaigns.CanDeleteCharacter"
|
||||||
CanDeleteCampaign="Workspace.State.CanDeleteSelectedCampaign"
|
CanDeleteCampaign="Workspace.State.CanDeleteSelectedCampaign"
|
||||||
CampaignSelectionChanged="OnCampaignSelectionChangedAsync"
|
|
||||||
CampaignCreated="Workspace.Campaigns.OnCampaignCreatedAsync"
|
CampaignCreated="Workspace.Campaigns.OnCampaignCreatedAsync"
|
||||||
DeleteCampaignRequested="Workspace.Campaigns.DeleteSelectedCampaignAsync"
|
DeleteCampaignRequested="Workspace.Campaigns.DeleteSelectedCampaignAsync"
|
||||||
CreateCharacterRequested="Workspace.Campaigns.OpenCreateCharacterModal"
|
CreateCharacterRequested="Workspace.Campaigns.OpenCreateCharacterModal"
|
||||||
@@ -22,10 +20,4 @@
|
|||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Parameter, EditorRequired] public WorkspacePageContext Workspace { get; set; } = null!;
|
[Parameter, EditorRequired] public WorkspacePageContext Workspace { get; set; } = null!;
|
||||||
|
|
||||||
private async Task OnCampaignSelectionChangedAsync(ChangeEventArgs args)
|
|
||||||
{
|
|
||||||
await Workspace.Campaigns.OnCampaignSelectionChangedAsync(args);
|
|
||||||
await Workspace.RequestRefreshAsync();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<header class="workspace-header">
|
<header class="workspace-header">
|
||||||
<div class="header-row">
|
<div class="header-row">
|
||||||
<h1>@Title</h1>
|
<h1>@Title</h1>
|
||||||
@if (User is null)
|
@if (User is null)
|
||||||
@@ -15,7 +15,23 @@
|
|||||||
}
|
}
|
||||||
@if (ShowCampaign)
|
@if (ShowCampaign)
|
||||||
{
|
{
|
||||||
<p class="header-campaign">Campaign: <strong>@(CampaignName ?? "No campaign selected")</strong></p>
|
<div class="header-campaign">
|
||||||
|
<label for="@CampaignSelectId">Campaign</label>
|
||||||
|
@if (Campaigns.Count == 0)
|
||||||
|
{
|
||||||
|
<span>No campaigns yet</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<select id="@CampaignSelectId"
|
||||||
|
@onchange="CampaignSelectionChanged">
|
||||||
|
@foreach (var campaign in Campaigns)
|
||||||
|
{
|
||||||
|
<option value="@campaign.Id" selected="@(campaign.Id == SelectedCampaignId)">@campaign.Name</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
<div class="header-connection-cell">
|
<div class="header-connection-cell">
|
||||||
@if (ShowConnectionState)
|
@if (ShowConnectionState)
|
||||||
@@ -24,6 +40,13 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<a href="" class="logout-link" @onclick:preventDefault="true" @onclick="LogoutRequested">Logout</a>
|
<a href="" class="logout-link" @onclick:preventDefault="true" @onclick="LogoutRequested">Logout</a>
|
||||||
|
<button type="button"
|
||||||
|
class="theme-toggle"
|
||||||
|
aria-label="@ThemeToggleAriaLabel"
|
||||||
|
title="@ThemeToggleAriaLabel"
|
||||||
|
@onclick="ThemeToggleRequested">
|
||||||
|
<span aria-hidden="true">@ThemeToggleLabel</span>
|
||||||
|
</button>
|
||||||
@if (MenuItems.Count > 0)
|
@if (MenuItems.Count > 0)
|
||||||
{
|
{
|
||||||
<div class="header-menu-wrap">
|
<div class="header-menu-wrap">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
using RpgRoller.Contracts;
|
using RpgRoller.Contracts;
|
||||||
|
|
||||||
@@ -12,31 +12,64 @@ public partial class AppHeader
|
|||||||
return item.OnSelected?.Invoke() ?? Task.CompletedTask;
|
return item.OnSelected?.Invoke() ?? Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
[Parameter] public string Title { get; set; } = "RpgRoller";
|
private string ThemeToggleAriaLabel => string.Equals(Theme, "dark", StringComparison.OrdinalIgnoreCase) ? "Switch to light theme" : "Switch to dark theme";
|
||||||
|
|
||||||
[Parameter] public UserSummary? User { get; set; }
|
[Parameter]
|
||||||
|
public string Title { get; set; } = "RpgRoller";
|
||||||
|
|
||||||
[Parameter] public bool ShowCampaign { get; set; }
|
[Parameter]
|
||||||
|
public UserSummary? User { get; set; }
|
||||||
|
|
||||||
[Parameter] public string? CampaignName { get; set; }
|
[Parameter]
|
||||||
|
public bool ShowCampaign { get; set; }
|
||||||
|
|
||||||
[Parameter] public bool ShowConnectionState { get; set; } = true;
|
[Parameter]
|
||||||
|
public IReadOnlyList<CampaignSummary> Campaigns { get; set; } = [];
|
||||||
|
|
||||||
[Parameter] public string ConnectionStateLabel { get; set; } = "Offline fallback";
|
[Parameter]
|
||||||
|
public Guid? SelectedCampaignId { get; set; }
|
||||||
|
|
||||||
[Parameter] public string ConnectionStateCssClass { get; set; } = "offline";
|
[Parameter]
|
||||||
|
public string CampaignSelectId { get; set; } = "header-campaign-select";
|
||||||
|
|
||||||
[Parameter] public bool IsMenuOpen { get; set; }
|
[Parameter]
|
||||||
|
public EventCallback<ChangeEventArgs> CampaignSelectionChanged { get; set; }
|
||||||
|
|
||||||
[Parameter] public string MenuButtonId { get; set; } = "screen-menu-button";
|
[Parameter]
|
||||||
|
public bool ShowConnectionState { get; set; } = true;
|
||||||
|
|
||||||
[Parameter] public string MenuId { get; set; } = "screen-menu";
|
[Parameter]
|
||||||
|
public string ConnectionStateLabel { get; set; } = "Offline fallback";
|
||||||
|
|
||||||
[Parameter] public IReadOnlyList<AppHeaderMenuItem> MenuItems { get; set; } = [];
|
[Parameter]
|
||||||
|
public string ConnectionStateCssClass { get; set; } = "offline";
|
||||||
|
|
||||||
[Parameter] public EventCallback ToggleMenuRequested { get; set; }
|
[Parameter]
|
||||||
|
public bool IsMenuOpen { get; set; }
|
||||||
|
|
||||||
[Parameter] public EventCallback LogoutRequested { get; set; }
|
[Parameter]
|
||||||
|
public string MenuButtonId { get; set; } = "screen-menu-button";
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public string MenuId { get; set; } = "screen-menu";
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public IReadOnlyList<AppHeaderMenuItem> MenuItems { get; set; } = [];
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback ToggleMenuRequested { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public string Theme { get; set; } = "light";
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public string ThemeToggleLabel { get; set; } = "☀️";
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback ThemeToggleRequested { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback LogoutRequested { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class AppHeaderMenuItem
|
public sealed class AppHeaderMenuItem
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<main class="management-screen">
|
<main class="management-screen">
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<h2>Campaign</h2>
|
<h2>Campaign</h2>
|
||||||
@@ -9,13 +9,14 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<label for="campaign-select">Current campaign</label>
|
<div class="campaign-current">
|
||||||
<select id="campaign-select" @onchange="CampaignSelectionChanged">
|
<span>Current campaign</span>
|
||||||
@foreach (var campaign in Campaigns)
|
<strong>@(SelectedCampaign is null ? "No campaign selected" : SelectedCampaign.Name)</strong>
|
||||||
|
@if (SelectedCampaign is not null)
|
||||||
{
|
{
|
||||||
<option value="@campaign.Id" selected="@(campaign.Id == SelectedCampaignId)">@campaign.Name (@campaign.RulesetId), GM: @campaign.Gm.DisplayName, @campaign.CharacterCount characters</option>
|
<p>@SelectedCampaign.RulesetId, GM: @SelectedCampaign.Gm.DisplayName, @SelectedCampaign.Characters.Length characters</p>
|
||||||
}
|
}
|
||||||
</select>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<button type="button"
|
<button type="button"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
using RpgRoller.Contracts;
|
using RpgRoller.Contracts;
|
||||||
|
|
||||||
@@ -74,9 +74,6 @@ public partial class CampaignManagementPanel
|
|||||||
[Parameter]
|
[Parameter]
|
||||||
public IReadOnlyList<CampaignSummary> Campaigns { get; set; } = [];
|
public IReadOnlyList<CampaignSummary> Campaigns { get; set; } = [];
|
||||||
|
|
||||||
[Parameter]
|
|
||||||
public Guid? SelectedCampaignId { get; set; }
|
|
||||||
|
|
||||||
[Parameter]
|
[Parameter]
|
||||||
public CampaignRoster? SelectedCampaign { get; set; }
|
public CampaignRoster? SelectedCampaign { get; set; }
|
||||||
|
|
||||||
@@ -98,9 +95,6 @@ public partial class CampaignManagementPanel
|
|||||||
[Parameter]
|
[Parameter]
|
||||||
public bool CanDeleteCampaign { get; set; }
|
public bool CanDeleteCampaign { get; set; }
|
||||||
|
|
||||||
[Parameter]
|
|
||||||
public EventCallback<ChangeEventArgs> CampaignSelectionChanged { get; set; }
|
|
||||||
|
|
||||||
[Parameter]
|
[Parameter]
|
||||||
public EventCallback<Guid> CampaignCreated { get; set; }
|
public EventCallback<Guid> CampaignCreated { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<section class="card character-panel">
|
<section class="card character-panel">
|
||||||
@if (IsCampaignDataLoading)
|
@if (IsCampaignDataLoading)
|
||||||
{
|
{
|
||||||
<div class="skeleton-stack">
|
<div class="skeleton-stack">
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
}
|
}
|
||||||
else if (SelectedCampaign is null)
|
else if (SelectedCampaign is null)
|
||||||
{
|
{
|
||||||
<p class="empty">No campaign selected. Choose one in Campaign Management.</p>
|
<p class="empty">No campaign selected. Choose one in the header.</p>
|
||||||
}
|
}
|
||||||
else if (SelectedCampaign.Characters.Length == 0)
|
else if (SelectedCampaign.Characters.Length == 0)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@using RpgRoller.Components.Pages.HomeControls
|
@using RpgRoller.Components.Pages.HomeControls
|
||||||
<div class="@AppCssClass">
|
<div class="@AppCssClass">
|
||||||
<p class="sr-only" aria-live="polite">@State.LiveAnnouncement</p>
|
<p class="sr-only" aria-live="polite">@State.LiveAnnouncement</p>
|
||||||
|
|
||||||
@@ -17,7 +17,9 @@
|
|||||||
<AppHeader
|
<AppHeader
|
||||||
User="State.User"
|
User="State.User"
|
||||||
ShowCampaign="@ShowCampaignInHeader"
|
ShowCampaign="@ShowCampaignInHeader"
|
||||||
CampaignName="@State.SelectedCampaignName"
|
Campaigns="State.Campaigns"
|
||||||
|
SelectedCampaignId="State.SelectedCampaignId"
|
||||||
|
CampaignSelectionChanged="OnHeaderCampaignSelectionChangedAsync"
|
||||||
ShowConnectionState="@ShowConnectionStateInHeader"
|
ShowConnectionState="@ShowConnectionStateInHeader"
|
||||||
ConnectionStateLabel="@State.ConnectionStateLabel"
|
ConnectionStateLabel="@State.ConnectionStateLabel"
|
||||||
ConnectionStateCssClass="@State.ConnectionStateCssClass"
|
ConnectionStateCssClass="@State.ConnectionStateCssClass"
|
||||||
@@ -26,6 +28,9 @@
|
|||||||
MenuId="workspace-screen-menu"
|
MenuId="workspace-screen-menu"
|
||||||
MenuItems="HeaderMenuItems"
|
MenuItems="HeaderMenuItems"
|
||||||
ToggleMenuRequested="ToggleScreenMenu"
|
ToggleMenuRequested="ToggleScreenMenu"
|
||||||
|
Theme="@State.ThemePreference"
|
||||||
|
ThemeToggleLabel="@State.ThemeToggleLabel"
|
||||||
|
ThemeToggleRequested="Session.ToggleThemePreferenceAsync"
|
||||||
LogoutRequested="Session.LogoutAsync"/>
|
LogoutRequested="Session.LogoutAsync"/>
|
||||||
|
|
||||||
@if (ChildContent is not null)
|
@if (ChildContent is not null)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
using Microsoft.JSInterop;
|
using Microsoft.JSInterop;
|
||||||
using RpgRoller.Components.Pages.HomeControls;
|
using RpgRoller.Components.Pages.HomeControls;
|
||||||
@@ -84,6 +84,12 @@ public partial class Workspace : IAsyncDisposable
|
|||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task OnHeaderCampaignSelectionChangedAsync(ChangeEventArgs args)
|
||||||
|
{
|
||||||
|
await Campaigns.OnCampaignSelectionChangedAsync(args);
|
||||||
|
await RequestRefreshAsync();
|
||||||
|
}
|
||||||
|
|
||||||
private Task RedirectToPlayAsync()
|
private Task RedirectToPlayAsync()
|
||||||
{
|
{
|
||||||
if (IsPlayRoute)
|
if (IsPlayRoute)
|
||||||
|
|||||||
@@ -328,7 +328,7 @@ public sealed class WorkspacePlayCoordinator(
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
var character = state.SelectedCampaign.Characters.FirstOrDefault(c => c.Id == state.SelectedCharacterId.Value);
|
var character = state.SelectedCampaign.Characters.FirstOrDefault(c => c.Id == state.SelectedCharacterId.Value);
|
||||||
if (character is null || !CanActivateCharacter(character, state.User) ||
|
if (character is null || !CanActivateCharacter(character) ||
|
||||||
state.ActiveCharacterId == character.Id)
|
state.ActiveCharacterId == character.Id)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
@@ -410,9 +410,10 @@ public sealed class WorkspacePlayCoordinator(
|
|||||||
state.FreshCampaignLogRollId = rollId;
|
state.FreshCampaignLogRollId = rollId;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool CanActivateCharacter(CharacterSummary character, UserSummary? user)
|
private bool CanActivateCharacter(CharacterSummary character)
|
||||||
{
|
{
|
||||||
return user is not null && character.OwnerUserId == user.Id;
|
return state.User is not null &&
|
||||||
|
(character.OwnerUserId == state.User.Id || state.IsCurrentUserGm);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static CampaignRollDetail ToCampaignRollDetail(RollResult roll)
|
private static CampaignRollDetail ToCampaignRollDetail(RollResult roll)
|
||||||
|
|||||||
@@ -1,25 +1,10 @@
|
|||||||
using Microsoft.JSInterop;
|
using Microsoft.JSInterop;
|
||||||
using RpgRoller.Contracts;
|
using RpgRoller.Contracts;
|
||||||
|
using RpgRoller.Domain;
|
||||||
|
|
||||||
namespace RpgRoller.Components.Pages;
|
namespace RpgRoller.Components.Pages;
|
||||||
|
|
||||||
public sealed class WorkspaceSessionCoordinator(
|
public sealed class WorkspaceSessionCoordinator(WorkspaceState state, WorkspaceFeedbackService feedback, IJSRuntime js, RpgRollerApiClient apiClient, WorkspaceQueryService workspaceQuery, Func<bool> isAdminRoute, Func<Task> redirectToPlayAsync, Func<Guid?, Task> reloadCampaignsAsync, Func<Task> reloadCharacterCampaignOptionsAsync, Func<Task> refreshCampaignScopeAsync, Func<Task> requestRefreshAsync, Func<Task> syncStateEventsAsync, Func<Task> stopStateEventsAsync, Func<Task> ensureAdminUsersLoadedAsync, Action resetCampaignLogDetailState, Func<string?, Task> onLoggedOutAsync)
|
||||||
WorkspaceState state,
|
|
||||||
WorkspaceFeedbackService feedback,
|
|
||||||
IJSRuntime js,
|
|
||||||
RpgRollerApiClient apiClient,
|
|
||||||
WorkspaceQueryService workspaceQuery,
|
|
||||||
Func<bool> isAdminRoute,
|
|
||||||
Func<Task> redirectToPlayAsync,
|
|
||||||
Func<Guid?, Task> reloadCampaignsAsync,
|
|
||||||
Func<Task> reloadCharacterCampaignOptionsAsync,
|
|
||||||
Func<Task> refreshCampaignScopeAsync,
|
|
||||||
Func<Task> requestRefreshAsync,
|
|
||||||
Func<Task> syncStateEventsAsync,
|
|
||||||
Func<Task> stopStateEventsAsync,
|
|
||||||
Func<Task> ensureAdminUsersLoadedAsync,
|
|
||||||
Action resetCampaignLogDetailState,
|
|
||||||
Func<string?, Task> onLoggedOutAsync)
|
|
||||||
{
|
{
|
||||||
public async Task InitializeAsync()
|
public async Task InitializeAsync()
|
||||||
{
|
{
|
||||||
@@ -27,8 +12,7 @@ public sealed class WorkspaceSessionCoordinator(
|
|||||||
if (string.Equals(storedPanel, "log", StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(storedPanel, "log", StringComparison.OrdinalIgnoreCase))
|
||||||
state.MobilePanel = "log";
|
state.MobilePanel = "log";
|
||||||
|
|
||||||
var storedRollVisibility =
|
var storedRollVisibility = await js.InvokeAsync<string?>("rpgRollerApi.getSessionValue", RollVisibilitySessionKey);
|
||||||
await js.InvokeAsync<string?>("rpgRollerApi.getSessionValue", RollVisibilitySessionKey);
|
|
||||||
state.RollVisibility = NormalizeRollVisibility(storedRollVisibility);
|
state.RollVisibility = NormalizeRollVisibility(storedRollVisibility);
|
||||||
|
|
||||||
Guid? preferredCampaignId = null;
|
Guid? preferredCampaignId = null;
|
||||||
@@ -101,6 +85,33 @@ public sealed class WorkspaceSessionCoordinator(
|
|||||||
await requestRefreshAsync();
|
await requestRefreshAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task ToggleThemePreferenceAsync()
|
||||||
|
{
|
||||||
|
if (state.User is null || state.IsMutating)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var previousTheme = state.ThemePreference;
|
||||||
|
var nextTheme = state.NextThemePreference;
|
||||||
|
state.ThemePreference = nextTheme;
|
||||||
|
await js.InvokeVoidAsync("rpgRollerApi.applyTheme", nextTheme);
|
||||||
|
await requestRefreshAsync();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
state.User = await apiClient.RequestAsync<UserSummary>("PUT", "/api/me/theme", new UpdateThemePreferenceRequest(nextTheme));
|
||||||
|
state.ThemePreference = NormalizeThemePreference(state.User.ThemePreference);
|
||||||
|
await js.InvokeVoidAsync("rpgRollerApi.applyTheme", state.ThemePreference);
|
||||||
|
}
|
||||||
|
catch (ApiRequestException ex)
|
||||||
|
{
|
||||||
|
state.ThemePreference = previousTheme;
|
||||||
|
await js.InvokeVoidAsync("rpgRollerApi.applyTheme", previousTheme);
|
||||||
|
feedback.SetStatus(ex.Message, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
await requestRefreshAsync();
|
||||||
|
}
|
||||||
|
|
||||||
public void ClearAuthenticatedState()
|
public void ClearAuthenticatedState()
|
||||||
{
|
{
|
||||||
state.User = null;
|
state.User = null;
|
||||||
@@ -117,6 +128,7 @@ public sealed class WorkspaceSessionCoordinator(
|
|||||||
state.SelectedCharacterId = null;
|
state.SelectedCharacterId = null;
|
||||||
state.LastRoll = null;
|
state.LastRoll = null;
|
||||||
state.KnownUsernames = [];
|
state.KnownUsernames = [];
|
||||||
|
state.ThemePreference = ThemePreferences.Light;
|
||||||
state.ShowCreateCharacterModal = false;
|
state.ShowCreateCharacterModal = false;
|
||||||
state.ShowEditCharacterModal = false;
|
state.ShowEditCharacterModal = false;
|
||||||
state.CanEditCharacterOwner = false;
|
state.CanEditCharacterOwner = false;
|
||||||
@@ -161,6 +173,7 @@ public sealed class WorkspaceSessionCoordinator(
|
|||||||
|
|
||||||
state.User = me.User;
|
state.User = me.User;
|
||||||
state.ActiveCharacterId = me.ActiveCharacterId;
|
state.ActiveCharacterId = me.ActiveCharacterId;
|
||||||
|
await EnsureThemePreferenceAsync();
|
||||||
if (!await EnsureRouteAccessAsync())
|
if (!await EnsureRouteAccessAsync())
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
@@ -211,6 +224,38 @@ public sealed class WorkspaceSessionCoordinator(
|
|||||||
return string.Equals(visibility, "private", StringComparison.OrdinalIgnoreCase) ? "private" : "public";
|
return string.Equals(visibility, "private", StringComparison.OrdinalIgnoreCase) ? "private" : "public";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task EnsureThemePreferenceAsync()
|
||||||
|
{
|
||||||
|
if (state.User is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var themePreference = state.User.ThemePreference;
|
||||||
|
if (ThemePreferences.IsSupported(themePreference))
|
||||||
|
{
|
||||||
|
state.ThemePreference = ThemePreferences.Normalize(themePreference!);
|
||||||
|
await js.InvokeVoidAsync("rpgRollerApi.applyTheme", state.ThemePreference);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var systemThemePreference = await js.InvokeAsync<string>("rpgRollerApi.getSystemTheme");
|
||||||
|
state.ThemePreference = NormalizeThemePreference(systemThemePreference);
|
||||||
|
await js.InvokeVoidAsync("rpgRollerApi.applyTheme", state.ThemePreference);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
state.User = await apiClient.RequestAsync<UserSummary>("PUT", "/api/me/theme", new UpdateThemePreferenceRequest(state.ThemePreference));
|
||||||
|
}
|
||||||
|
catch (ApiRequestException ex)
|
||||||
|
{
|
||||||
|
feedback.SetStatus(ex.Message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeThemePreference(string? themePreference)
|
||||||
|
{
|
||||||
|
return ThemePreferences.IsSupported(themePreference) ? ThemePreferences.Normalize(themePreference!) : ThemePreferences.Light;
|
||||||
|
}
|
||||||
|
|
||||||
private const string CampaignSessionKey = "campaign";
|
private const string CampaignSessionKey = "campaign";
|
||||||
private const string MobilePanelSessionKey = "play-panel";
|
private const string MobilePanelSessionKey = "play-panel";
|
||||||
private const string RollVisibilitySessionKey = "roll-visibility";
|
private const string RollVisibilitySessionKey = "roll-visibility";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using RpgRoller.Components.Pages.HomeControls;
|
using RpgRoller.Components.Pages.HomeControls;
|
||||||
using RpgRoller.Contracts;
|
using RpgRoller.Contracts;
|
||||||
using RpgRoller.Domain;
|
using RpgRoller.Domain;
|
||||||
|
|
||||||
@@ -17,9 +17,7 @@ public sealed class WorkspaceState
|
|||||||
if (ownerUserId == SelectedCampaign.Gm.Id)
|
if (ownerUserId == SelectedCampaign.Gm.Id)
|
||||||
return $"{SelectedCampaign.Gm.DisplayName} (GM)";
|
return $"{SelectedCampaign.Gm.DisplayName} (GM)";
|
||||||
|
|
||||||
var ownerDisplayName = SelectedCampaign.Characters.Where(character => character.OwnerUserId == ownerUserId)
|
var ownerDisplayName = SelectedCampaign.Characters.Where(character => character.OwnerUserId == ownerUserId).Select(character => character.OwnerDisplayName).FirstOrDefault(displayName => !string.IsNullOrWhiteSpace(displayName));
|
||||||
.Select(character => character.OwnerDisplayName)
|
|
||||||
.FirstOrDefault(displayName => !string.IsNullOrWhiteSpace(displayName));
|
|
||||||
|
|
||||||
return string.IsNullOrWhiteSpace(ownerDisplayName) ? "Unknown owner" : ownerDisplayName;
|
return string.IsNullOrWhiteSpace(ownerDisplayName) ? "Unknown owner" : ownerDisplayName;
|
||||||
}
|
}
|
||||||
@@ -28,10 +26,8 @@ public sealed class WorkspaceState
|
|||||||
{
|
{
|
||||||
if (!string.Equals(SelectedCampaign?.RulesetId, "d6", StringComparison.OrdinalIgnoreCase))
|
if (!string.Equals(SelectedCampaign?.RulesetId, "d6", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
if (string.Equals(SelectedCampaign?.RulesetId, RulesetFormHelpers.RulesetIds.Rolemaster,
|
if (string.Equals(SelectedCampaign?.RulesetId, RulesetFormHelpers.RulesetIds.Rolemaster, StringComparison.OrdinalIgnoreCase))
|
||||||
StringComparison.OrdinalIgnoreCase))
|
return RulesetFormHelpers.DescribeRolemasterExpression(skill.DiceRollDefinition, skill.FumbleRange, skill.RolemasterAutoRetry);
|
||||||
return RulesetFormHelpers.DescribeRolemasterExpression(skill.DiceRollDefinition, skill.FumbleRange,
|
|
||||||
skill.RolemasterAutoRetry);
|
|
||||||
|
|
||||||
return skill.DiceRollDefinition;
|
return skill.DiceRollDefinition;
|
||||||
}
|
}
|
||||||
@@ -55,6 +51,7 @@ public sealed class WorkspaceState
|
|||||||
public RollResult? LastRoll { get; set; }
|
public RollResult? LastRoll { get; set; }
|
||||||
public List<string> KnownUsernames { get; set; } = [];
|
public List<string> KnownUsernames { get; set; } = [];
|
||||||
public string RollVisibility { get; set; } = "public";
|
public string RollVisibility { get; set; } = "public";
|
||||||
|
public string ThemePreference { get; set; } = ThemePreferences.Light;
|
||||||
|
|
||||||
public bool IsMutating { get; set; }
|
public bool IsMutating { get; set; }
|
||||||
public bool IsCampaignDataLoading { get; set; }
|
public bool IsCampaignDataLoading { get; set; }
|
||||||
@@ -91,10 +88,6 @@ public sealed class WorkspaceState
|
|||||||
public HashSet<Guid> CampaignLogDetailsLoading { get; } = [];
|
public HashSet<Guid> CampaignLogDetailsLoading { get; } = [];
|
||||||
public Dictionary<Guid, string> CampaignLogDetailErrors { get; } = [];
|
public Dictionary<Guid, string> CampaignLogDetailErrors { get; } = [];
|
||||||
|
|
||||||
public string? SelectedCampaignName => SelectedCampaign?.Name ??
|
|
||||||
Campaigns.FirstOrDefault(campaign => campaign.Id == SelectedCampaignId)
|
|
||||||
?.Name;
|
|
||||||
|
|
||||||
public CharacterSummary? SelectedCharacter =>
|
public CharacterSummary? SelectedCharacter =>
|
||||||
SelectedCampaign?.Characters.FirstOrDefault(character => character.Id == SelectedCharacterId);
|
SelectedCampaign?.Characters.FirstOrDefault(character => character.Id == SelectedCharacterId);
|
||||||
|
|
||||||
@@ -106,14 +99,14 @@ public sealed class WorkspaceState
|
|||||||
return null;
|
return null;
|
||||||
|
|
||||||
if (User is null)
|
if (User is null)
|
||||||
return new(SelectedCampaign.Id, SelectedCampaign.Name, SelectedCampaign.RulesetId, SelectedCampaign.Gm,
|
return new(SelectedCampaign.Id, SelectedCampaign.Name, SelectedCampaign.RulesetId, SelectedCampaign.Gm, []);
|
||||||
[]);
|
|
||||||
|
|
||||||
var ownedCharacters = SelectedCampaign.Characters.Where(character => character.OwnerUserId == User.Id)
|
if (IsCurrentUserGm)
|
||||||
.ToArray();
|
return SelectedCampaign;
|
||||||
|
|
||||||
return new(SelectedCampaign.Id, SelectedCampaign.Name, SelectedCampaign.RulesetId, SelectedCampaign.Gm,
|
var ownedCharacters = SelectedCampaign.Characters.Where(character => character.OwnerUserId == User.Id).ToArray();
|
||||||
ownedCharacters);
|
|
||||||
|
return new(SelectedCampaign.Id, SelectedCampaign.Name, SelectedCampaign.RulesetId, SelectedCampaign.Gm, ownedCharacters);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,18 +120,14 @@ public sealed class WorkspaceState
|
|||||||
|
|
||||||
if (SelectedCharacterId.HasValue)
|
if (SelectedCharacterId.HasValue)
|
||||||
{
|
{
|
||||||
var selectedCharacter =
|
var selectedCharacter = playSelectedCampaign.Characters.FirstOrDefault(character => character.Id == SelectedCharacterId.Value);
|
||||||
playSelectedCampaign.Characters.FirstOrDefault(character =>
|
|
||||||
character.Id == SelectedCharacterId.Value);
|
|
||||||
if (selectedCharacter is not null)
|
if (selectedCharacter is not null)
|
||||||
return selectedCharacter;
|
return selectedCharacter;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ActiveCharacterId.HasValue)
|
if (ActiveCharacterId.HasValue)
|
||||||
{
|
{
|
||||||
var activeCharacter =
|
var activeCharacter = playSelectedCampaign.Characters.FirstOrDefault(character => character.Id == ActiveCharacterId.Value);
|
||||||
playSelectedCampaign.Characters.FirstOrDefault(character =>
|
|
||||||
character.Id == ActiveCharacterId.Value);
|
|
||||||
if (activeCharacter is not null)
|
if (activeCharacter is not null)
|
||||||
return activeCharacter;
|
return activeCharacter;
|
||||||
}
|
}
|
||||||
@@ -182,4 +171,9 @@ public sealed class WorkspaceState
|
|||||||
"reconnecting" => "warn",
|
"reconnecting" => "warn",
|
||||||
_ => "offline"
|
_ => "offline"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
public string ThemeToggleLabel => ThemePreference == ThemePreferences.Dark ? "⏾" : "☀️";
|
||||||
|
|
||||||
|
public string NextThemePreference =>
|
||||||
|
ThemePreference == ThemePreferences.Dark ? ThemePreferences.Light : ThemePreferences.Dark;
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
namespace RpgRoller.Contracts;
|
namespace RpgRoller.Contracts;
|
||||||
|
|
||||||
@@ -10,10 +10,12 @@ public sealed record RegisterRequest(string Username, string Password, string Di
|
|||||||
|
|
||||||
public sealed record LoginRequest(string Username, string Password);
|
public sealed record LoginRequest(string Username, string Password);
|
||||||
|
|
||||||
public sealed record UserSummary(Guid Id, string Username, string DisplayName, IReadOnlyList<string> Roles);
|
public sealed record UserSummary(Guid Id, string Username, string DisplayName, IReadOnlyList<string> Roles, string? ThemePreference = null);
|
||||||
|
|
||||||
public sealed record MeResponse(UserSummary User, Guid? ActiveCharacterId, Guid? CurrentCampaignId);
|
public sealed record MeResponse(UserSummary User, Guid? ActiveCharacterId, Guid? CurrentCampaignId);
|
||||||
|
|
||||||
|
public sealed record UpdateThemePreferenceRequest(string ThemePreference);
|
||||||
|
|
||||||
public sealed record AdminUserSummary(Guid Id, string Username, string DisplayName, IReadOnlyList<string> Roles);
|
public sealed record AdminUserSummary(Guid Id, string Username, string DisplayName, IReadOnlyList<string> Roles);
|
||||||
|
|
||||||
public sealed record UpdateUserRolesRequest(IReadOnlyList<string> Roles);
|
public sealed record UpdateUserRolesRequest(IReadOnlyList<string> Roles);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
namespace RpgRoller.Contracts;
|
namespace RpgRoller.Contracts;
|
||||||
@@ -52,6 +52,7 @@ namespace RpgRoller.Contracts;
|
|||||||
[JsonSerializable(typeof(UpdateCharacterRequest))]
|
[JsonSerializable(typeof(UpdateCharacterRequest))]
|
||||||
[JsonSerializable(typeof(UpdateSkillGroupRequest))]
|
[JsonSerializable(typeof(UpdateSkillGroupRequest))]
|
||||||
[JsonSerializable(typeof(UpdateSkillRequest))]
|
[JsonSerializable(typeof(UpdateSkillRequest))]
|
||||||
|
[JsonSerializable(typeof(UpdateThemePreferenceRequest))]
|
||||||
[JsonSerializable(typeof(UpdateUserRolesRequest))]
|
[JsonSerializable(typeof(UpdateUserRolesRequest))]
|
||||||
[JsonSerializable(typeof(UserSummary))]
|
[JsonSerializable(typeof(UserSummary))]
|
||||||
public partial class RpgRollerJsonSerializerContext : JsonSerializerContext
|
public partial class RpgRollerJsonSerializerContext : JsonSerializerContext
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using RpgRoller.Domain;
|
using RpgRoller.Domain;
|
||||||
|
|
||||||
namespace RpgRoller.Data;
|
namespace RpgRoller.Data;
|
||||||
@@ -15,6 +15,7 @@ public sealed class RpgRollerDbContext(DbContextOptions<RpgRollerDbContext> opti
|
|||||||
entity.Property(x => x.PasswordHash).IsRequired();
|
entity.Property(x => x.PasswordHash).IsRequired();
|
||||||
entity.Property(x => x.DisplayName).IsRequired().HasMaxLength(128);
|
entity.Property(x => x.DisplayName).IsRequired().HasMaxLength(128);
|
||||||
entity.Property(x => x.Roles).IsRequired().HasMaxLength(256);
|
entity.Property(x => x.Roles).IsRequired().HasMaxLength(256);
|
||||||
|
entity.Property(x => x.ThemePreference).IsRequired(false).HasMaxLength(16);
|
||||||
entity.HasIndex(x => x.UsernameNormalized).IsUnique();
|
entity.HasIndex(x => x.UsernameNormalized).IsUnique();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace RpgRoller.Domain;
|
namespace RpgRoller.Domain;
|
||||||
|
|
||||||
public enum RulesetKind
|
public enum RulesetKind
|
||||||
{
|
{
|
||||||
@@ -22,6 +22,7 @@ public sealed class UserAccount
|
|||||||
public required string DisplayName { get; set; }
|
public required string DisplayName { get; set; }
|
||||||
public required string Roles { get; set; }
|
public required string Roles { get; set; }
|
||||||
public Guid? ActiveCharacterId { get; set; }
|
public Guid? ActiveCharacterId { get; set; }
|
||||||
|
public string? ThemePreference { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class UserRoles
|
public static class UserRoles
|
||||||
|
|||||||
17
RpgRoller/Domain/ThemePreferences.cs
Normal file
17
RpgRoller/Domain/ThemePreferences.cs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
namespace RpgRoller.Domain;
|
||||||
|
|
||||||
|
public static class ThemePreferences
|
||||||
|
{
|
||||||
|
public const string Light = "light";
|
||||||
|
public const string Dark = "dark";
|
||||||
|
|
||||||
|
public static bool IsSupported(string? value)
|
||||||
|
{
|
||||||
|
return string.Equals(value, Light, StringComparison.OrdinalIgnoreCase) || string.Equals(value, Dark, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string Normalize(string value)
|
||||||
|
{
|
||||||
|
return string.Equals(value, Dark, StringComparison.OrdinalIgnoreCase) ? Dark : Light;
|
||||||
|
}
|
||||||
|
}
|
||||||
273
RpgRoller/Migrations/20260518183838_AddUserThemePreference.Designer.cs
generated
Normal file
273
RpgRoller/Migrations/20260518183838_AddUserThemePreference.Designer.cs
generated
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using RpgRoller.Data;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace RpgRoller.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(RpgRollerDbContext))]
|
||||||
|
[Migration("20260518183838_AddUserThemePreference")]
|
||||||
|
partial class AddUserThemePreference
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "10.0.2");
|
||||||
|
|
||||||
|
modelBuilder.Entity("RpgRoller.Domain.Campaign", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("GmUserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Ruleset")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("Version")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("GmUserId");
|
||||||
|
|
||||||
|
b.ToTable("Campaigns");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("RpgRoller.Domain.Character", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("CampaignId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("OwnerUserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CampaignId");
|
||||||
|
|
||||||
|
b.HasIndex("OwnerUserId");
|
||||||
|
|
||||||
|
b.ToTable("Characters");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("RpgRoller.Domain.RollLogEntry", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Breakdown")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("CampaignId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("CharacterId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Dice")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("Result")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid>("RollerUserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("SkillId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("TimestampUtc")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Visibility")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CampaignId");
|
||||||
|
|
||||||
|
b.HasIndex("CharacterId");
|
||||||
|
|
||||||
|
b.HasIndex("RollerUserId");
|
||||||
|
|
||||||
|
b.HasIndex("SkillId");
|
||||||
|
|
||||||
|
b.ToTable("RollLogEntries");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("RpgRoller.Domain.Skill", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("AllowFumble")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid>("CharacterId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("DiceRollDefinition")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("FumbleRange")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("RolemasterAutoRetry")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(false);
|
||||||
|
|
||||||
|
b.Property<Guid?>("SkillGroupId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("WildDice")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CharacterId");
|
||||||
|
|
||||||
|
b.HasIndex("SkillGroupId");
|
||||||
|
|
||||||
|
b.ToTable("Skills");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("RpgRoller.Domain.SkillGroup", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("AllowFumble")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid>("CharacterId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("DiceRollDefinition")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("FumbleRange")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("WildDice")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CharacterId");
|
||||||
|
|
||||||
|
b.ToTable("SkillGroups");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("RpgRoller.Domain.UserAccount", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ActiveCharacterId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Roles")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ThemePreference")
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Username")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("UsernameNormalized")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("UsernameNormalized")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Users");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("RpgRoller.Domain.UserSession", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Token")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Token");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("Sessions");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace RpgRoller.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddUserThemePreference : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(name: "ThemePreference", table: "Users", type: "TEXT", maxLength: 16, nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(name: "ThemePreference", table: "Users");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -224,6 +224,10 @@ namespace RpgRoller.Migrations
|
|||||||
.HasMaxLength(256)
|
.HasMaxLength(256)
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ThemePreference")
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<string>("Username")
|
b.Property<string>("Username")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(64)
|
.HasMaxLength(64)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using RpgRoller.Contracts;
|
using RpgRoller.Contracts;
|
||||||
using RpgRoller.Domain;
|
using RpgRoller.Domain;
|
||||||
|
|
||||||
@@ -32,7 +32,8 @@ public sealed class GameAuthService(GameStateStore stateStore, IPasswordHasher<U
|
|||||||
DisplayName = displayName.Trim(),
|
DisplayName = displayName.Trim(),
|
||||||
PasswordHash = string.Empty,
|
PasswordHash = string.Empty,
|
||||||
Roles = stateStore.UsersById.Count == 0 ? UserRoles.Admin : string.Empty,
|
Roles = stateStore.UsersById.Count == 0 ? UserRoles.Admin : string.Empty,
|
||||||
ActiveCharacterId = null
|
ActiveCharacterId = null,
|
||||||
|
ThemePreference = null
|
||||||
};
|
};
|
||||||
|
|
||||||
user.PasswordHash = passwordHasher.HashPassword(user, password);
|
user.PasswordHash = passwordHasher.HashPassword(user, password);
|
||||||
@@ -112,6 +113,23 @@ public sealed class GameAuthService(GameStateStore stateStore, IPasswordHasher<U
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public ServiceResult<UserSummary> UpdateThemePreference(string sessionToken, string themePreference)
|
||||||
|
{
|
||||||
|
lock (stateStore.Gate)
|
||||||
|
{
|
||||||
|
var user = GameContextResolver.ResolveUserLocked(stateStore, sessionToken);
|
||||||
|
if (user is null)
|
||||||
|
return ServiceResult<UserSummary>.Failure("unauthorized", "You must be logged in.");
|
||||||
|
|
||||||
|
if (!ThemePreferences.IsSupported(themePreference))
|
||||||
|
return ServiceResult<UserSummary>.Failure("invalid_theme_preference", "Theme preference must be light or dark.");
|
||||||
|
|
||||||
|
user.ThemePreference = ThemePreferences.Normalize(themePreference);
|
||||||
|
persistenceService.PersistStateLocked();
|
||||||
|
return ServiceResult<UserSummary>.Success(GameDtoMapper.ToUserSummary(user));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private UserSession CreateSession(Guid userId)
|
private UserSession CreateSession(Guid userId)
|
||||||
{
|
{
|
||||||
var token = Guid.NewGuid().ToString("N");
|
var token = Guid.NewGuid().ToString("N");
|
||||||
|
|||||||
@@ -36,7 +36,8 @@ public sealed class GameCharacterService(GameStateStore stateStore, GamePersiste
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public ServiceResult<CharacterSummary> UpdateCharacter(string sessionToken, Guid characterId, string name, Guid? campaignId, string? ownerUsername = null)
|
public ServiceResult<CharacterSummary> UpdateCharacter(string sessionToken, Guid characterId, string name,
|
||||||
|
Guid? campaignId, string? ownerUsername = null)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(name))
|
if (string.IsNullOrWhiteSpace(name))
|
||||||
return ServiceResult<CharacterSummary>.Failure("invalid_character_name", "Character name is required.");
|
return ServiceResult<CharacterSummary>.Failure("invalid_character_name", "Character name is required.");
|
||||||
@@ -56,10 +57,13 @@ public sealed class GameCharacterService(GameStateStore stateStore, GamePersiste
|
|||||||
|
|
||||||
var isOwner = character.OwnerUserId == user.Id;
|
var isOwner = character.OwnerUserId == user.Id;
|
||||||
var isAdmin = GameAuthorization.HasRole(user, UserRoles.Admin);
|
var isAdmin = GameAuthorization.HasRole(user, UserRoles.Admin);
|
||||||
var isSourceGm = character.CampaignId.HasValue && stateStore.CampaignsById.TryGetValue(character.CampaignId.Value, out var sourceCampaign) && sourceCampaign.GmUserId == user.Id;
|
var isSourceGm = character.CampaignId.HasValue &&
|
||||||
|
stateStore.CampaignsById.TryGetValue(character.CampaignId.Value, out var sourceCampaign) &&
|
||||||
|
sourceCampaign.GmUserId == user.Id;
|
||||||
var isTargetGm = targetCampaign is not null && targetCampaign.GmUserId == user.Id;
|
var isTargetGm = targetCampaign is not null && targetCampaign.GmUserId == user.Id;
|
||||||
if (!isOwner && !isAdmin && !isSourceGm && !isTargetGm)
|
if (!isOwner && !isAdmin && !isSourceGm && !isTargetGm)
|
||||||
return ServiceResult<CharacterSummary>.Failure("forbidden", "Only the owner, GM, or admin can edit this character.");
|
return ServiceResult<CharacterSummary>.Failure("forbidden",
|
||||||
|
"Only the owner, GM, or admin can edit this character.");
|
||||||
|
|
||||||
var sourceCampaignId = character.CampaignId;
|
var sourceCampaignId = character.CampaignId;
|
||||||
var previousOwnerUserId = character.OwnerUserId;
|
var previousOwnerUserId = character.OwnerUserId;
|
||||||
@@ -74,10 +78,13 @@ public sealed class GameCharacterService(GameStateStore stateStore, GamePersiste
|
|||||||
return ServiceResult<CharacterSummary>.Failure("owner_not_found", "Owner username was not found.");
|
return ServiceResult<CharacterSummary>.Failure("owner_not_found", "Owner username was not found.");
|
||||||
|
|
||||||
if (targetOwnerUserId != character.OwnerUserId && !isAdmin && !isSourceGm && !isTargetGm)
|
if (targetOwnerUserId != character.OwnerUserId && !isAdmin && !isSourceGm && !isTargetGm)
|
||||||
return ServiceResult<CharacterSummary>.Failure("forbidden", "Only the GM or admin can change character owner.");
|
return ServiceResult<CharacterSummary>.Failure("forbidden",
|
||||||
|
"Only the GM or admin can change character owner.");
|
||||||
|
|
||||||
character.OwnerUserId = targetOwnerUserId;
|
character.OwnerUserId = targetOwnerUserId;
|
||||||
if (character.OwnerUserId != previousOwnerUserId && stateStore.UsersById.TryGetValue(previousOwnerUserId, out var previousOwner) && previousOwner.ActiveCharacterId == character.Id)
|
if (character.OwnerUserId != previousOwnerUserId &&
|
||||||
|
stateStore.UsersById.TryGetValue(previousOwnerUserId, out var previousOwner) &&
|
||||||
|
previousOwner.ActiveCharacterId == character.Id)
|
||||||
previousOwner.ActiveCharacterId = null;
|
previousOwner.ActiveCharacterId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,7 +137,15 @@ public sealed class GameCharacterService(GameStateStore stateStore, GamePersiste
|
|||||||
return ServiceResult<bool>.Failure("character_not_found", "Character was not found.");
|
return ServiceResult<bool>.Failure("character_not_found", "Character was not found.");
|
||||||
|
|
||||||
if (character.OwnerUserId != user.Id)
|
if (character.OwnerUserId != user.Id)
|
||||||
return ServiceResult<bool>.Failure("forbidden", "You can activate only your own character.");
|
{
|
||||||
|
if (!GameContextResolver.TryResolveCharacterCampaignLocked(stateStore, character, out var campaign,
|
||||||
|
out var campaignError))
|
||||||
|
return ServiceResult<bool>.Failure(campaignError!.Code, campaignError.Message);
|
||||||
|
|
||||||
|
if (campaign!.GmUserId != user.Id)
|
||||||
|
return ServiceResult<bool>.Failure("forbidden",
|
||||||
|
"You can activate only your own character unless you GM its campaign.");
|
||||||
|
}
|
||||||
|
|
||||||
user.ActiveCharacterId = character.Id;
|
user.ActiveCharacterId = character.Id;
|
||||||
persistenceService.PersistStateLocked();
|
persistenceService.PersistStateLocked();
|
||||||
@@ -146,7 +161,9 @@ public sealed class GameCharacterService(GameStateStore stateStore, GamePersiste
|
|||||||
if (user is null)
|
if (user is null)
|
||||||
return ServiceResult<IReadOnlyList<CharacterSummary>>.Failure("unauthorized", "You must be logged in.");
|
return ServiceResult<IReadOnlyList<CharacterSummary>>.Failure("unauthorized", "You must be logged in.");
|
||||||
|
|
||||||
var characters = stateStore.CharactersById.Values.Where(character => character.OwnerUserId == user.Id).OrderBy(character => character.Name, StringComparer.OrdinalIgnoreCase).Select(character => GameDtoMapper.ToCharacterSummary(stateStore, character)).ToArray();
|
var characters = stateStore.CharactersById.Values.Where(character => character.OwnerUserId == user.Id)
|
||||||
|
.OrderBy(character => character.Name, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Select(character => GameDtoMapper.ToCharacterSummary(stateStore, character)).ToArray();
|
||||||
|
|
||||||
return ServiceResult<IReadOnlyList<CharacterSummary>>.Success(characters);
|
return ServiceResult<IReadOnlyList<CharacterSummary>>.Success(characters);
|
||||||
}
|
}
|
||||||
@@ -160,11 +177,13 @@ public sealed class GameCharacterService(GameStateStore stateStore, GamePersiste
|
|||||||
var campaignId = character.CampaignId;
|
var campaignId = character.CampaignId;
|
||||||
stateStore.CharactersById.Remove(characterId);
|
stateStore.CharactersById.Remove(characterId);
|
||||||
|
|
||||||
var skillGroupIds = stateStore.SkillGroupsById.Values.Where(group => group.CharacterId == characterId).Select(group => group.Id).ToHashSet();
|
var skillGroupIds = stateStore.SkillGroupsById.Values.Where(group => group.CharacterId == characterId)
|
||||||
|
.Select(group => group.Id).ToHashSet();
|
||||||
foreach (var skillGroupId in skillGroupIds)
|
foreach (var skillGroupId in skillGroupIds)
|
||||||
stateStore.SkillGroupsById.Remove(skillGroupId);
|
stateStore.SkillGroupsById.Remove(skillGroupId);
|
||||||
|
|
||||||
var skillIds = stateStore.SkillsById.Values.Where(skill => skill.CharacterId == characterId).Select(skill => skill.Id).ToHashSet();
|
var skillIds = stateStore.SkillsById.Values.Where(skill => skill.CharacterId == characterId)
|
||||||
|
.Select(skill => skill.Id).ToHashSet();
|
||||||
foreach (var skillId in skillIds)
|
foreach (var skillId in skillIds)
|
||||||
stateStore.SkillsById.Remove(skillId);
|
stateStore.SkillsById.Remove(skillId);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using RpgRoller.Contracts;
|
using RpgRoller.Contracts;
|
||||||
using RpgRoller.Domain;
|
using RpgRoller.Domain;
|
||||||
|
|
||||||
namespace RpgRoller.Services;
|
namespace RpgRoller.Services;
|
||||||
@@ -7,7 +7,7 @@ public static class GameDtoMapper
|
|||||||
{
|
{
|
||||||
public static UserSummary ToUserSummary(UserAccount user)
|
public static UserSummary ToUserSummary(UserAccount user)
|
||||||
{
|
{
|
||||||
return new(user.Id, user.Username, user.DisplayName, RoleSerializer.Parse(user.Roles));
|
return new(user.Id, user.Username, user.DisplayName, RoleSerializer.Parse(user.Roles), user.ThemePreference);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static AdminUserSummary ToAdminUserSummary(UserAccount user)
|
public static AdminUserSummary ToAdminUserSummary(UserAccount user)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using RpgRoller.Data;
|
using RpgRoller.Data;
|
||||||
using RpgRoller.Domain;
|
using RpgRoller.Domain;
|
||||||
|
|
||||||
@@ -40,7 +40,8 @@ public sealed class GamePersistenceService(IDbContextFactory<RpgRollerDbContext>
|
|||||||
PasswordHash = user.PasswordHash,
|
PasswordHash = user.PasswordHash,
|
||||||
DisplayName = user.DisplayName,
|
DisplayName = user.DisplayName,
|
||||||
Roles = string.IsNullOrWhiteSpace(user.Roles) ? string.Empty : RoleSerializer.Serialize(RoleSerializer.Parse(user.Roles)),
|
Roles = string.IsNullOrWhiteSpace(user.Roles) ? string.Empty : RoleSerializer.Serialize(RoleSerializer.Parse(user.Roles)),
|
||||||
ActiveCharacterId = user.ActiveCharacterId
|
ActiveCharacterId = user.ActiveCharacterId,
|
||||||
|
ThemePreference = string.IsNullOrWhiteSpace(user.ThemePreference) ? null : ThemePreferences.Normalize(user.ThemePreference)
|
||||||
};
|
};
|
||||||
stateStore.UsersById[storedUser.Id] = storedUser;
|
stateStore.UsersById[storedUser.Id] = storedUser;
|
||||||
stateStore.UserIdsByUsername[normalizedUsername] = storedUser.Id;
|
stateStore.UserIdsByUsername[normalizedUsername] = storedUser.Id;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using RpgRoller.Contracts;
|
using RpgRoller.Contracts;
|
||||||
using RpgRoller.Data;
|
using RpgRoller.Data;
|
||||||
@@ -55,6 +55,11 @@ public sealed class GameService : IGameService
|
|||||||
return m_AuthService.GetMe(sessionToken);
|
return m_AuthService.GetMe(sessionToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public ServiceResult<UserSummary> UpdateThemePreference(string sessionToken, string themePreference)
|
||||||
|
{
|
||||||
|
return m_AuthService.UpdateThemePreference(sessionToken, themePreference);
|
||||||
|
}
|
||||||
|
|
||||||
public ServiceResult<CampaignSummary> CreateCampaign(string sessionToken, string name, string rulesetId)
|
public ServiceResult<CampaignSummary> CreateCampaign(string sessionToken, string name, string rulesetId)
|
||||||
{
|
{
|
||||||
return m_CampaignService.CreateCampaign(sessionToken, name, rulesetId);
|
return m_CampaignService.CreateCampaign(sessionToken, name, rulesetId);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using RpgRoller.Domain;
|
using RpgRoller.Domain;
|
||||||
|
|
||||||
namespace RpgRoller.Services;
|
namespace RpgRoller.Services;
|
||||||
|
|
||||||
@@ -14,7 +14,8 @@ public static class GameStateCloneFactory
|
|||||||
PasswordHash = user.PasswordHash,
|
PasswordHash = user.PasswordHash,
|
||||||
DisplayName = user.DisplayName,
|
DisplayName = user.DisplayName,
|
||||||
Roles = user.Roles,
|
Roles = user.Roles,
|
||||||
ActiveCharacterId = user.ActiveCharacterId
|
ActiveCharacterId = user.ActiveCharacterId,
|
||||||
|
ThemePreference = user.ThemePreference
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using RpgRoller.Contracts;
|
using RpgRoller.Contracts;
|
||||||
|
|
||||||
namespace RpgRoller.Services;
|
namespace RpgRoller.Services;
|
||||||
|
|
||||||
@@ -11,6 +11,7 @@ public interface IGameService
|
|||||||
void Logout(string sessionToken);
|
void Logout(string sessionToken);
|
||||||
UserSummary? GetUserBySession(string sessionToken);
|
UserSummary? GetUserBySession(string sessionToken);
|
||||||
ServiceResult<MeResponse> GetMe(string sessionToken);
|
ServiceResult<MeResponse> GetMe(string sessionToken);
|
||||||
|
ServiceResult<UserSummary> UpdateThemePreference(string sessionToken, string themePreference);
|
||||||
|
|
||||||
ServiceResult<CampaignSummary> CreateCampaign(string sessionToken, string name, string rulesetId);
|
ServiceResult<CampaignSummary> CreateCampaign(string sessionToken, string name, string rulesetId);
|
||||||
ServiceResult<IReadOnlyList<CampaignSummary>> GetCampaigns(string sessionToken);
|
ServiceResult<IReadOnlyList<CampaignSummary>> GetCampaigns(string sessionToken);
|
||||||
|
|||||||
BIN
RpgRoller/wwwroot/images/dark.webp
Normal file
BIN
RpgRoller/wwwroot/images/dark.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 84 KiB |
BIN
RpgRoller/wwwroot/images/light.webp
Normal file
BIN
RpgRoller/wwwroot/images/light.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 258 KiB |
@@ -1,4 +1,4 @@
|
|||||||
window.rpgRollerApi = (() => {
|
window.rpgRollerApi = (() => {
|
||||||
const sessionPrefix = "rpgroller.";
|
const sessionPrefix = "rpgroller.";
|
||||||
const stateStream = {
|
const stateStream = {
|
||||||
source: null,
|
source: null,
|
||||||
@@ -22,6 +22,18 @@ window.rpgRollerApi = (() => {
|
|||||||
return new URL(relativeUrl, document.baseURI).toString();
|
return new URL(relativeUrl, document.baseURI).toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeTheme(theme) {
|
||||||
|
return theme === "dark" ? "dark" : "light";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSystemTheme() {
|
||||||
|
return window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTheme(theme) {
|
||||||
|
document.documentElement.dataset.theme = normalizeTheme(theme);
|
||||||
|
}
|
||||||
|
|
||||||
function clearReconnectTimer() {
|
function clearReconnectTimer() {
|
||||||
if (stateStream.reconnectTimer) {
|
if (stateStream.reconnectTimer) {
|
||||||
clearTimeout(stateStream.reconnectTimer);
|
clearTimeout(stateStream.reconnectTimer);
|
||||||
@@ -385,6 +397,8 @@ window.rpgRollerApi = (() => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
request,
|
request,
|
||||||
|
applyTheme,
|
||||||
|
getSystemTheme,
|
||||||
getSessionValue,
|
getSessionValue,
|
||||||
setSessionValue,
|
setSessionValue,
|
||||||
startStateEvents,
|
startStateEvents,
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
:root {
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
--bg-top: #f7f0d8;
|
--bg-top: #f7f0d8;
|
||||||
--bg-bottom: #ecdfc4;
|
--bg-bottom: #ecdfc4;
|
||||||
--button-hover: #dccfb4;
|
--button-hover: #dccfb4;
|
||||||
--card: #fffaf0;
|
--card: #fffaf0e0;
|
||||||
--card-border: #c3b28b;
|
--card-border: #c3b28b;
|
||||||
--text: #2b2418;
|
--text: #2b2418;
|
||||||
--muted: #6a5b3f;
|
--muted: #6a5b3f;
|
||||||
@@ -14,6 +15,147 @@
|
|||||||
--public: #2d6645;
|
--public: #2d6645;
|
||||||
--private-self: #4f3a8f;
|
--private-self: #4f3a8f;
|
||||||
--private-gm: #915119;
|
--private-gm: #915119;
|
||||||
|
--page-background: url("/images/light.webp");
|
||||||
|
--card-strong: #fff8ea;
|
||||||
|
--accent-dark: #2f4f34;
|
||||||
|
--accent-2-hover: #6b2419;
|
||||||
|
--header-bg: linear-gradient(120deg, #f1e4c9, #efe0bf);
|
||||||
|
--input-bg: #fffdf5;
|
||||||
|
--input-border: #8e7b57;
|
||||||
|
--button-text: #f8f7ef;
|
||||||
|
--switch-active-text: #fff9ef;
|
||||||
|
--tab-active-bg: linear-gradient(145deg, #e9d4a4, #d7b672);
|
||||||
|
--tab-active-border: #9e7328;
|
||||||
|
--section-border: #a89066;
|
||||||
|
--skill-group-bg: #f8f0de;
|
||||||
|
--chip-border: #decbb7;
|
||||||
|
--menu-shadow: rgba(34, 24, 9, 0.2);
|
||||||
|
--die-border: #2a2418;
|
||||||
|
--die-bg: #ffffff;
|
||||||
|
--die-text: #1f1a13;
|
||||||
|
--die-wild: #c79913;
|
||||||
|
--die-crit-bg: #d8ffc2;
|
||||||
|
--die-crit-text: #18490f;
|
||||||
|
--die-fumble-bg: #ffb5a8;
|
||||||
|
--die-fumble-text: #661110;
|
||||||
|
--die-added-bg: #dbffdf;
|
||||||
|
--die-added-text: #206029;
|
||||||
|
--die-removed-bg: #fde0dd;
|
||||||
|
--die-removed-text: #7f5f55;
|
||||||
|
--die-neutral-bg: #f8f1df;
|
||||||
|
--die-neutral-text: #3f2f12;
|
||||||
|
--die-open-high-bg: #dff6df;
|
||||||
|
--die-open-high-text: #1d5b26;
|
||||||
|
--die-open-high-border: #2a7c39;
|
||||||
|
--die-open-low-bg: #ffe1dc;
|
||||||
|
--die-open-low-text: #8a2217;
|
||||||
|
--die-open-low-border: #b74334;
|
||||||
|
--success-bg: #e8f7e8;
|
||||||
|
--success-border: #78a978;
|
||||||
|
--success-text: #1f5425;
|
||||||
|
--error-bg: #ffe9e5;
|
||||||
|
--error-border: #bb6e62;
|
||||||
|
--error-text: #7f2015;
|
||||||
|
--rare-bg: #fff1c7;
|
||||||
|
--rare-border: #b48b34;
|
||||||
|
--rare-text: #6d4c05;
|
||||||
|
--active-bg: #f6d28d;
|
||||||
|
--active-border: #8f5f12;
|
||||||
|
--active-text: #5d3808;
|
||||||
|
--skeleton-bg: linear-gradient(90deg, #dfd2b7, #efe3c9, #dfd2b7);
|
||||||
|
--health-bg: #fff2db;
|
||||||
|
--health-border: #b77a29;
|
||||||
|
--modal-overlay: rgba(35, 25, 9, 0.55);
|
||||||
|
--mobile-nav-bg: rgba(241, 228, 201, 0.96);
|
||||||
|
--toast-shadow: rgba(34, 24, 9, 0.22);
|
||||||
|
--surface-mix: #ffffff;
|
||||||
|
--transparent-mix: transparent;
|
||||||
|
--custom-roll-error-bg: #fff0ee;
|
||||||
|
--custom-roll-error-border: #6b2015;
|
||||||
|
--custom-roll-error-shadow: rgba(181, 58, 35, 0.12);
|
||||||
|
--entry-shadow: rgba(60, 41, 12, 0.07);
|
||||||
|
--entry-shadow-hover: rgba(60, 41, 12, 0.11);
|
||||||
|
--fresh-shadow: rgba(199, 153, 19, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark"] {
|
||||||
|
color-scheme: dark;
|
||||||
|
--bg-top: #060b13;
|
||||||
|
--bg-bottom: #0c1726;
|
||||||
|
--button-hover: rgba(62, 89, 123, 0.72);
|
||||||
|
--card: rgba(8, 15, 26, 0.84);
|
||||||
|
--card-border: #37516c;
|
||||||
|
--text: #edf5ff;
|
||||||
|
--muted: #adc1d6;
|
||||||
|
--accent: #5aa0cf;
|
||||||
|
--accent-2: #f0b35a;
|
||||||
|
--warn: #f4c35f;
|
||||||
|
--danger: #ff8b7a;
|
||||||
|
--focus: #91d5ff;
|
||||||
|
--public: #78d08f;
|
||||||
|
--private-self: #b9a0ff;
|
||||||
|
--private-gm: #f0b16c;
|
||||||
|
--page-background: url("/images/dark.webp");
|
||||||
|
--card-strong: rgba(13, 24, 39, 0.96);
|
||||||
|
--accent-dark: #2d638f;
|
||||||
|
--accent-2-hover: #ffd085;
|
||||||
|
--header-bg: linear-gradient(120deg, rgba(10, 18, 31, 0.94), rgba(17, 31, 48, 0.9));
|
||||||
|
--input-bg: rgba(7, 14, 24, 0.92);
|
||||||
|
--input-border: #52708f;
|
||||||
|
--button-text: #f3f8ff;
|
||||||
|
--switch-active-text: #0b1420;
|
||||||
|
--tab-active-bg: linear-gradient(145deg, #244967, #17324d);
|
||||||
|
--tab-active-border: #6ca6d0;
|
||||||
|
--section-border: #486986;
|
||||||
|
--skill-group-bg: rgba(19, 34, 52, 0.72);
|
||||||
|
--chip-border: #415f7b;
|
||||||
|
--menu-shadow: rgba(0, 0, 0, 0.42);
|
||||||
|
--die-border: #9cb8d3;
|
||||||
|
--die-bg: #0f1c2d;
|
||||||
|
--die-text: #edf5ff;
|
||||||
|
--die-wild: #ffd770;
|
||||||
|
--die-crit-bg: #163f2a;
|
||||||
|
--die-crit-text: #a5f0b5;
|
||||||
|
--die-fumble-bg: #4b1c20;
|
||||||
|
--die-fumble-text: #ffc0b8;
|
||||||
|
--die-added-bg: #163f2a;
|
||||||
|
--die-added-text: #a5f0b5;
|
||||||
|
--die-removed-bg: #3b2630;
|
||||||
|
--die-removed-text: #e0abb7;
|
||||||
|
--die-neutral-bg: #14283e;
|
||||||
|
--die-neutral-text: #e5f1ff;
|
||||||
|
--die-open-high-bg: #133b2b;
|
||||||
|
--die-open-high-text: #a5f0b5;
|
||||||
|
--die-open-high-border: #64c783;
|
||||||
|
--die-open-low-bg: #482025;
|
||||||
|
--die-open-low-text: #ffc0b8;
|
||||||
|
--die-open-low-border: #ff8b7a;
|
||||||
|
--success-bg: #173b29;
|
||||||
|
--success-border: #66bd7f;
|
||||||
|
--success-text: #b6f1c3;
|
||||||
|
--error-bg: #452126;
|
||||||
|
--error-border: #d46b62;
|
||||||
|
--error-text: #ffc2ba;
|
||||||
|
--rare-bg: #3d3218;
|
||||||
|
--rare-border: #cfae52;
|
||||||
|
--rare-text: #ffe09a;
|
||||||
|
--active-bg: #4a3514;
|
||||||
|
--active-border: #e0b35d;
|
||||||
|
--active-text: #ffe1a3;
|
||||||
|
--skeleton-bg: linear-gradient(90deg, #172438, #263b54, #172438);
|
||||||
|
--health-bg: #3a2d19;
|
||||||
|
--health-border: #d09b4c;
|
||||||
|
--modal-overlay: rgba(1, 6, 13, 0.74);
|
||||||
|
--mobile-nav-bg: rgba(10, 18, 31, 0.96);
|
||||||
|
--toast-shadow: rgba(0, 0, 0, 0.42);
|
||||||
|
--surface-mix: #000000;
|
||||||
|
--transparent-mix: transparent;
|
||||||
|
--custom-roll-error-bg: #371c21;
|
||||||
|
--custom-roll-error-border: #ffc0b8;
|
||||||
|
--custom-roll-error-shadow: rgba(255, 139, 122, 0.18);
|
||||||
|
--entry-shadow: rgba(0, 0, 0, 0.24);
|
||||||
|
--entry-shadow-hover: rgba(0, 0, 0, 0.34);
|
||||||
|
--fresh-shadow: rgba(255, 215, 112, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -27,9 +169,16 @@ body {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
background-image: var(--page-background);
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: cover;
|
||||||
|
background-attachment: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: radial-gradient(circle at 15% 10%, rgba(255, 255, 255, 0.32), transparent 45%),
|
background: transparent;
|
||||||
linear-gradient(165deg, var(--bg-top), var(--bg-bottom));
|
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-family:
|
font-family:
|
||||||
"Baloo 2",
|
"Baloo 2",
|
||||||
@@ -93,7 +242,7 @@ h3 {
|
|||||||
top: 0;
|
top: 0;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
display: flex;
|
display: flex;
|
||||||
background: linear-gradient(120deg, #f1e4c9, #efe0bf);
|
background: var(--header-bg);
|
||||||
border: 1px solid var(--card-border);
|
border: 1px solid var(--card-border);
|
||||||
border-radius: 0.8rem;
|
border-radius: 0.8rem;
|
||||||
padding: 0.5rem 0.7rem;
|
padding: 0.5rem 0.7rem;
|
||||||
@@ -113,14 +262,33 @@ h3 {
|
|||||||
font-size: 1.15rem;
|
font-size: 1.15rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-identity,
|
.header-identity {
|
||||||
.header-campaign {
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-campaign {
|
.header-campaign {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
|
min-width: 12rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-campaign label {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-campaign select {
|
||||||
|
max-width: 16rem;
|
||||||
|
min-width: 9rem;
|
||||||
|
padding: 0.25rem 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-campaign span {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-connection-cell {
|
.header-connection-cell {
|
||||||
@@ -139,7 +307,7 @@ h3 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
background: color-mix(in srgb, var(--card) 94%, #ffffff 6%);
|
background: var(--card);
|
||||||
border: 1px solid var(--card-border);
|
border: 1px solid var(--card-border);
|
||||||
border-radius: 0.8rem;
|
border-radius: 0.8rem;
|
||||||
padding: 0.7rem;
|
padding: 0.7rem;
|
||||||
@@ -149,6 +317,20 @@ h3 {
|
|||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.campaign-current {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.campaign-current span,
|
||||||
|
.campaign-current p {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.campaign-current p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.auth-grid {
|
.auth-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
@@ -169,19 +351,19 @@ select,
|
|||||||
button {
|
button {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
border-radius: 0.45rem;
|
border-radius: 0.45rem;
|
||||||
border: 1px solid #8e7b57;
|
border: 1px solid var(--input-border);
|
||||||
padding: 0.55rem 0.65rem;
|
padding: 0.55rem 0.65rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
input,
|
input,
|
||||||
select {
|
select {
|
||||||
background: #fffdf5;
|
background: var(--input-bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
background: linear-gradient(180deg, var(--accent), #2f4f34);
|
background: linear-gradient(180deg, var(--accent), var(--accent-dark));
|
||||||
color: #f8f7ef;
|
color: var(--button-text);
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
@@ -189,19 +371,19 @@ button {
|
|||||||
button.ghost {
|
button.ghost {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
border-color: #8e7b57;
|
border-color: var(--input-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
button.switch {
|
button.switch {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
border-color: #8e7b57;
|
border-color: var(--input-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
button.switch.active {
|
button.switch.active {
|
||||||
background: var(--accent-2);
|
background: var(--accent-2);
|
||||||
border-color: var(--accent-2);
|
border-color: var(--accent-2);
|
||||||
color: #fff9ef;
|
color: var(--switch-active-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
button:disabled {
|
button:disabled {
|
||||||
@@ -287,12 +469,12 @@ select:focus-visible {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
border-color: #8e7b57;
|
border-color: var(--input-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-tab.active {
|
.icon-tab.active {
|
||||||
background: linear-gradient(145deg, #e9d4a4, #d7b672);
|
background: var(--tab-active-bg);
|
||||||
border-color: #9e7328;
|
border-color: var(--tab-active-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-tab-glyph {
|
.icon-tab-glyph {
|
||||||
@@ -302,7 +484,7 @@ select:focus-visible {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: 1px solid #8e7b57;
|
border: 1px solid var(--input-border);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
}
|
}
|
||||||
@@ -313,7 +495,7 @@ select:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.skills-section {
|
.skills-section {
|
||||||
border: 1px dashed #a89066;
|
border: 1px dashed var(--section-border);
|
||||||
border-radius: 0.65rem;
|
border-radius: 0.65rem;
|
||||||
padding: 0.55rem;
|
padding: 0.55rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -374,8 +556,9 @@ select:focus-visible {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
background-color: #f8f0de;
|
background-color: var(--skill-group-bg);
|
||||||
padding-left: 0.1rem;
|
padding: 0.1rem;
|
||||||
|
border-top: 1px solid var(--card-border);
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -416,11 +599,11 @@ select:focus-visible {
|
|||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
border-color: #decbb7;
|
border-color: var(--chip-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chip-button:hover {
|
.chip-button:hover {
|
||||||
border-color: #8e7b57;
|
border-color: var(--input-border);
|
||||||
background: var(--button-hover);
|
background: var(--button-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -428,13 +611,13 @@ select:focus-visible {
|
|||||||
grid-template-columns: auto 1fr;
|
grid-template-columns: auto 1fr;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-items: start;
|
justify-items: start;
|
||||||
background: #00000000;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skill-create-icon {
|
.skill-create-icon {
|
||||||
width: 1.45rem;
|
width: 1.45rem;
|
||||||
height: 1.45rem;
|
height: 1.45rem;
|
||||||
border: 1px solid #8e7b57;
|
border: 1px solid var(--input-border);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -479,7 +662,7 @@ select:focus-visible {
|
|||||||
.menu-toggle {
|
.menu-toggle {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
border-color: #8e7b57;
|
border-color: var(--input-border);
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -496,12 +679,12 @@ select:focus-visible {
|
|||||||
z-index: 40;
|
z-index: 40;
|
||||||
min-width: 14.5rem;
|
min-width: 14.5rem;
|
||||||
padding: 0.35rem;
|
padding: 0.35rem;
|
||||||
background: #fff8ea;
|
background: var(--card-strong);
|
||||||
border: 1px solid var(--card-border);
|
border: 1px solid var(--card-border);
|
||||||
border-radius: 0.55rem;
|
border-radius: 0.55rem;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.3rem;
|
gap: 0.3rem;
|
||||||
box-shadow: 0 8px 16px rgba(34, 24, 9, 0.2);
|
box-shadow: 0 8px 16px var(--menu-shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-item {
|
.menu-item {
|
||||||
@@ -509,12 +692,12 @@ select:focus-visible {
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
border-color: #8e7b57;
|
border-color: var(--input-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-item.active {
|
.menu-item.active {
|
||||||
background: #ecd8ae;
|
background: var(--button-hover);
|
||||||
border-color: #9a7f43;
|
border-color: var(--tab-active-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.logout-link {
|
.logout-link {
|
||||||
@@ -525,7 +708,7 @@ select:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.logout-link:hover {
|
.logout-link:hover {
|
||||||
color: #6b2419;
|
color: var(--accent-2-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.logout-link:focus-visible {
|
.logout-link:focus-visible {
|
||||||
@@ -533,6 +716,22 @@ select:focus-visible {
|
|||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.theme-toggle {
|
||||||
|
width: 2.25rem;
|
||||||
|
height: 2.25rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--input-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle:hover {
|
||||||
|
background: var(--button-hover);
|
||||||
|
}
|
||||||
|
|
||||||
.roll-total {
|
.roll-total {
|
||||||
font-size: 1.8rem;
|
font-size: 1.8rem;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
@@ -556,10 +755,10 @@ select:focus-visible {
|
|||||||
min-width: 2.1rem;
|
min-width: 2.1rem;
|
||||||
height: 2.1rem;
|
height: 2.1rem;
|
||||||
padding: 0.2rem 0.45rem 0;
|
padding: 0.2rem 0.45rem 0;
|
||||||
border: 2px solid #2a2418;
|
border: 2px solid var(--die-border);
|
||||||
border-radius: 0.45rem;
|
border-radius: 0.45rem;
|
||||||
background: #ffffff;
|
background: var(--die-bg);
|
||||||
color: #1f1a13;
|
color: var(--die-text);
|
||||||
font-size: 2rem;
|
font-size: 2rem;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
@@ -567,27 +766,27 @@ select:focus-visible {
|
|||||||
|
|
||||||
.die-chip.wild {
|
.die-chip.wild {
|
||||||
border-width: 3px;
|
border-width: 3px;
|
||||||
border-color: #c79913;
|
border-color: var(--die-wild);
|
||||||
}
|
}
|
||||||
|
|
||||||
.die-chip.crit {
|
.die-chip.crit {
|
||||||
background: #d8ffc2;
|
background: var(--die-crit-bg);
|
||||||
color: #18490f;
|
color: var(--die-crit-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.die-chip.fumble {
|
.die-chip.fumble {
|
||||||
background: #ffb5a8;
|
background: var(--die-fumble-bg);
|
||||||
color: #661110;
|
color: var(--die-fumble-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.die-chip.added {
|
.die-chip.added {
|
||||||
background: #dbffdf;
|
background: var(--die-added-bg);
|
||||||
color: #206029;
|
color: var(--die-added-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.die-chip.removed {
|
.die-chip.removed {
|
||||||
background: #fde0dd;
|
background: var(--die-removed-bg);
|
||||||
color: #7f5f55;
|
color: var(--die-removed-text);
|
||||||
border-style: dashed;
|
border-style: dashed;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -605,20 +804,20 @@ select:focus-visible {
|
|||||||
.die-chip.rolemaster-initiative,
|
.die-chip.rolemaster-initiative,
|
||||||
.die-chip.rolemaster-percentile,
|
.die-chip.rolemaster-percentile,
|
||||||
.die-chip.rolemaster-open-ended-initial {
|
.die-chip.rolemaster-open-ended-initial {
|
||||||
background: #f8f1df;
|
background: var(--die-neutral-bg);
|
||||||
color: #3f2f12;
|
color: var(--die-neutral-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.die-chip.rolemaster-open-ended-high {
|
.die-chip.rolemaster-open-ended-high {
|
||||||
background: #dff6df;
|
background: var(--die-open-high-bg);
|
||||||
color: #1d5b26;
|
color: var(--die-open-high-text);
|
||||||
border-color: #2a7c39;
|
border-color: var(--die-open-high-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.die-chip.rolemaster-open-ended-low-subtract {
|
.die-chip.rolemaster-open-ended-low-subtract {
|
||||||
background: #ffe1dc;
|
background: var(--die-open-low-bg);
|
||||||
color: #8a2217;
|
color: var(--die-open-low-text);
|
||||||
border-color: #b74334;
|
border-color: var(--die-open-low-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty,
|
.empty,
|
||||||
@@ -635,11 +834,11 @@ select:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.custom-roll-panel {
|
.custom-roll-panel {
|
||||||
border-top: 1px solid color-mix(in srgb, var(--card-border) 72%, #ffffff 28%);
|
border-top: 1px solid color-mix(in srgb, var(--card-border) 72%, var(--surface-mix) 28%);
|
||||||
background: color-mix(in srgb, var(--card) 88%, #ffffff 12%);
|
background: color-mix(in srgb, var(--card) 88%, var(--surface-mix) 12%);
|
||||||
border-radius: 0.95rem;
|
border-radius: 0.95rem;
|
||||||
padding: 0.85rem 0.9rem 0.9rem;
|
padding: 0.85rem 0.9rem 0.9rem;
|
||||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.45);
|
box-shadow: inset 0 1px 0 color-mix(in srgb, var(--surface-mix) 45%, transparent 55%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-roll-composer {
|
.custom-roll-composer {
|
||||||
@@ -671,14 +870,14 @@ select:focus-visible {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 0.72rem 0.9rem;
|
padding: 0.72rem 0.9rem;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
border: 1px solid color-mix(in srgb, var(--card-border) 78%, #ffffff 22%);
|
border: 1px solid color-mix(in srgb, var(--card-border) 78%, var(--surface-mix) 22%);
|
||||||
background: color-mix(in srgb, var(--card) 90%, #ffffff 10%);
|
background: color-mix(in srgb, var(--card) 90%, var(--surface-mix) 10%);
|
||||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6);
|
box-shadow: inset 0 1px 0 color-mix(in srgb, var(--surface-mix) 60%, transparent 40%);
|
||||||
transition: border-color 180ms ease, box-shadow 180ms ease, background-color 180ms ease;
|
transition: border-color 180ms ease, box-shadow 180ms ease, background-color 180ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-roll-input::placeholder {
|
.custom-roll-input::placeholder {
|
||||||
color: color-mix(in srgb, var(--muted) 80%, #ffffff 20%);
|
color: color-mix(in srgb, var(--muted) 80%, var(--surface-mix) 20%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-roll-input:hover:not(:disabled) {
|
.custom-roll-input:hover:not(:disabled) {
|
||||||
@@ -686,9 +885,9 @@ select:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.custom-roll-input.error {
|
.custom-roll-input.error {
|
||||||
border-color: color-mix(in srgb, var(--danger) 74%, #6b2015 26%);
|
border-color: color-mix(in srgb, var(--danger) 74%, var(--custom-roll-error-border) 26%);
|
||||||
background: color-mix(in srgb, #fff0ee 84%, var(--card) 16%);
|
background: color-mix(in srgb, var(--custom-roll-error-bg) 84%, var(--card) 16%);
|
||||||
box-shadow: 0 0 0 3px rgba(181, 58, 35, 0.12);
|
box-shadow: 0 0 0 3px var(--custom-roll-error-shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-roll-composer-row button {
|
.custom-roll-composer-row button {
|
||||||
@@ -698,11 +897,11 @@ select:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.log-entry {
|
.log-entry {
|
||||||
border: 1px solid color-mix(in srgb, var(--card-border) 84%, #ffffff 16%);
|
border: 1px solid color-mix(in srgb, var(--card-border) 84%, var(--surface-mix) 16%);
|
||||||
border-radius: 0.85rem;
|
border-radius: 0.85rem;
|
||||||
background: color-mix(in srgb, var(--card) 96%, #ffffff 4%);
|
background: color-mix(in srgb, var(--card) 96%, var(--surface-mix) 4%);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: 0 0.45rem 1.2rem rgba(60, 41, 12, 0.07);
|
box-shadow: 0 0.45rem 1.2rem var(--entry-shadow);
|
||||||
transition: border-color 180ms ease, box-shadow 180ms ease, transform 180ms ease;
|
transition: border-color 180ms ease, box-shadow 180ms ease, transform 180ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -721,23 +920,23 @@ select:focus-visible {
|
|||||||
|
|
||||||
.log-entry:hover {
|
.log-entry:hover {
|
||||||
border-color: color-mix(in srgb, var(--accent) 28%, var(--card-border) 72%);
|
border-color: color-mix(in srgb, var(--accent) 28%, var(--card-border) 72%);
|
||||||
box-shadow: 0 0.7rem 1.55rem rgba(60, 41, 12, 0.11);
|
box-shadow: 0 0.7rem 1.55rem var(--entry-shadow-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-entry.private-self {
|
.log-entry.private-self {
|
||||||
border-left: 0.35rem solid color-mix(in srgb, var(--private-self) 78%, #ffffff 22%);
|
border-left: 0.35rem solid color-mix(in srgb, var(--private-self) 78%, var(--surface-mix) 22%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-entry.private-gm {
|
.log-entry.private-gm {
|
||||||
border-left: 0.35rem solid color-mix(in srgb, var(--private-gm) 78%, #ffffff 22%);
|
border-left: 0.35rem solid color-mix(in srgb, var(--private-gm) 78%, var(--surface-mix) 22%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-entry.public {
|
.log-entry.public {
|
||||||
border-left: 0.35rem solid color-mix(in srgb, var(--public) 70%, #ffffff 30%);
|
border-left: 0.35rem solid color-mix(in srgb, var(--public) 70%, var(--surface-mix) 30%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-entry.private-generic {
|
.log-entry.private-generic {
|
||||||
border-left: 0.35rem solid color-mix(in srgb, var(--muted) 52%, #ffffff 48%);
|
border-left: 0.35rem solid color-mix(in srgb, var(--muted) 52%, var(--surface-mix) 48%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-entry.expanded {
|
.log-entry.expanded {
|
||||||
@@ -745,12 +944,12 @@ select:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.log-entry.fresh {
|
.log-entry.fresh {
|
||||||
border-color: color-mix(in srgb, #c79913 52%, var(--card-border) 48%);
|
border-color: color-mix(in srgb, var(--die-wild) 52%, var(--card-border) 48%);
|
||||||
box-shadow: 0 0.9rem 1.8rem rgba(199, 153, 19, 0.16);
|
box-shadow: 0 0.9rem 1.8rem var(--fresh-shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-entry-toggle:hover {
|
.log-entry-toggle:hover {
|
||||||
background: color-mix(in srgb, var(--card) 84%, #ffffff 16%);
|
background: color-mix(in srgb, var(--card) 84%, var(--surface-mix) 16%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-entry-toggle:focus-visible {
|
.log-entry-toggle:focus-visible {
|
||||||
@@ -814,21 +1013,21 @@ select:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.log-event-badge.positive {
|
.log-event-badge.positive {
|
||||||
border-color: #79a85d;
|
border-color: var(--success-border);
|
||||||
background: #e7f6da;
|
background: var(--success-bg);
|
||||||
color: #235217;
|
color: var(--success-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-event-badge.danger {
|
.log-event-badge.danger {
|
||||||
border-color: #c56b5a;
|
border-color: var(--error-border);
|
||||||
background: #ffe3dc;
|
background: var(--error-bg);
|
||||||
color: #7d1f17;
|
color: var(--error-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-event-badge.rare {
|
.log-event-badge.rare {
|
||||||
border-color: #b48b34;
|
border-color: var(--rare-border);
|
||||||
background: #fff1c7;
|
background: var(--rare-bg);
|
||||||
color: #6d4c05;
|
color: var(--rare-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-meta {
|
.log-meta {
|
||||||
@@ -844,7 +1043,7 @@ select:focus-visible {
|
|||||||
margin: 0 0.65rem 0.65rem;
|
margin: 0 0.65rem 0.65rem;
|
||||||
padding: 0.7rem 0.8rem 0.75rem;
|
padding: 0.7rem 0.8rem 0.75rem;
|
||||||
border-top: 1px solid color-mix(in srgb, var(--card-border) 38%, transparent 62%);
|
border-top: 1px solid color-mix(in srgb, var(--card-border) 38%, transparent 62%);
|
||||||
background: color-mix(in srgb, #ffffff 42%, var(--card) 58%);
|
background: color-mix(in srgb, var(--surface-mix) 42%, var(--card) 58%);
|
||||||
border-radius: 0.7rem;
|
border-radius: 0.7rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -863,31 +1062,31 @@ select:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.badge.active {
|
.badge.active {
|
||||||
border-color: #8f5f12;
|
border-color: var(--active-border);
|
||||||
background: #f6d28d;
|
background: var(--active-bg);
|
||||||
color: #5d3808;
|
color: var(--active-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge.public {
|
.badge.public {
|
||||||
background: color-mix(in srgb, var(--public) 14%, #ffffff 86%);
|
background: color-mix(in srgb, var(--public) 14%, var(--surface-mix) 86%);
|
||||||
color: var(--public);
|
color: var(--public);
|
||||||
border-color: color-mix(in srgb, var(--public) 34%, transparent 66%);
|
border-color: color-mix(in srgb, var(--public) 34%, transparent 66%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge.private-self {
|
.badge.private-self {
|
||||||
background: color-mix(in srgb, var(--private-self) 12%, #ffffff 88%);
|
background: color-mix(in srgb, var(--private-self) 12%, var(--surface-mix) 88%);
|
||||||
color: var(--private-self);
|
color: var(--private-self);
|
||||||
border-color: color-mix(in srgb, var(--private-self) 30%, transparent 70%);
|
border-color: color-mix(in srgb, var(--private-self) 30%, transparent 70%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge.private-gm {
|
.badge.private-gm {
|
||||||
background: color-mix(in srgb, var(--private-gm) 12%, #ffffff 88%);
|
background: color-mix(in srgb, var(--private-gm) 12%, var(--surface-mix) 88%);
|
||||||
color: var(--private-gm);
|
color: var(--private-gm);
|
||||||
border-color: color-mix(in srgb, var(--private-gm) 30%, transparent 70%);
|
border-color: color-mix(in srgb, var(--private-gm) 30%, transparent 70%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge.private-generic {
|
.badge.private-generic {
|
||||||
background: color-mix(in srgb, var(--muted) 12%, #ffffff 88%);
|
background: color-mix(in srgb, var(--muted) 12%, var(--surface-mix) 88%);
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
border-color: color-mix(in srgb, var(--muted) 28%, transparent 72%);
|
border-color: color-mix(in srgb, var(--muted) 28%, transparent 72%);
|
||||||
}
|
}
|
||||||
@@ -952,7 +1151,7 @@ select:focus-visible {
|
|||||||
.skeleton-line {
|
.skeleton-line {
|
||||||
height: 0.85rem;
|
height: 0.85rem;
|
||||||
border-radius: 0.4rem;
|
border-radius: 0.4rem;
|
||||||
background: linear-gradient(90deg, #dfd2b7, #efe3c9, #dfd2b7);
|
background: var(--skeleton-bg);
|
||||||
background-size: 220% 100%;
|
background-size: 220% 100%;
|
||||||
animation: shimmer 1.1s linear infinite;
|
animation: shimmer 1.1s linear infinite;
|
||||||
}
|
}
|
||||||
@@ -962,8 +1161,8 @@ select:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.health-banner {
|
.health-banner {
|
||||||
border: 1px solid #b77a29;
|
border: 1px solid var(--health-border);
|
||||||
background: #fff2db;
|
background: var(--health-bg);
|
||||||
border-radius: 0.75rem;
|
border-radius: 0.75rem;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -985,7 +1184,7 @@ select:focus-visible {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
border: 1px solid #b8a37b;
|
border: 1px solid var(--card-border);
|
||||||
border-radius: 0.55rem;
|
border-radius: 0.55rem;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
}
|
}
|
||||||
@@ -1006,9 +1205,9 @@ select:focus-visible {
|
|||||||
gap: 0.45rem;
|
gap: 0.45rem;
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
padding: 0.55rem 0.65rem;
|
padding: 0.55rem 0.65rem;
|
||||||
border: 1px solid #b39f79;
|
border: 1px solid var(--card-border);
|
||||||
border-radius: 0.45rem;
|
border-radius: 0.45rem;
|
||||||
background: #f9f2e2;
|
background: var(--input-bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@@ -1028,15 +1227,15 @@ select:focus-visible {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.45rem;
|
gap: 0.45rem;
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
background: #f9f2e2;
|
background: var(--input-bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
border: 1px solid #b39f79;
|
border: 1px solid var(--card-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-row-icon {
|
.add-row-icon {
|
||||||
width: 1.2rem;
|
width: 1.2rem;
|
||||||
height: 1.2rem;
|
height: 1.2rem;
|
||||||
border: 1px solid #8e7b57;
|
border: 1px solid var(--input-border);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1047,7 +1246,7 @@ select:focus-visible {
|
|||||||
.modal-overlay {
|
.modal-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: rgba(35, 25, 9, 0.55);
|
background: var(--modal-overlay);
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
z-index: 20;
|
z-index: 20;
|
||||||
@@ -1077,7 +1276,7 @@ select:focus-visible {
|
|||||||
padding: 0.55rem;
|
padding: 0.55rem;
|
||||||
display: none;
|
display: none;
|
||||||
gap: 0.45rem;
|
gap: 0.45rem;
|
||||||
background: rgba(241, 228, 201, 0.96);
|
background: var(--mobile-nav-bg);
|
||||||
border-top: 1px solid var(--card-border);
|
border-top: 1px solid var(--card-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1095,7 +1294,7 @@ select:focus-visible {
|
|||||||
border-radius: 0.6rem;
|
border-radius: 0.6rem;
|
||||||
border: 1px solid;
|
border: 1px solid;
|
||||||
padding: 0.55rem 0.7rem;
|
padding: 0.55rem 0.7rem;
|
||||||
box-shadow: 0 6px 14px rgba(34, 24, 9, 0.22);
|
box-shadow: 0 6px 14px var(--toast-shadow);
|
||||||
backdrop-filter: blur(4px);
|
backdrop-filter: blur(4px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1105,15 +1304,15 @@ select:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.toast.success {
|
.toast.success {
|
||||||
background: #e8f7e8;
|
background: var(--success-bg);
|
||||||
border-color: #78a978;
|
border-color: var(--success-border);
|
||||||
color: #1f5425;
|
color: var(--success-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.toast.error {
|
.toast.error {
|
||||||
background: #ffe9e5;
|
background: var(--error-bg);
|
||||||
border-color: #bb6e62;
|
border-color: var(--error-border);
|
||||||
color: #7f2015;
|
color: var(--error-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sr-only {
|
.sr-only {
|
||||||
@@ -1172,6 +1371,15 @@ select:focus-visible {
|
|||||||
white-space: normal;
|
white-space: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-campaign {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-campaign select {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.mobile-bottom-nav {
|
.mobile-bottom-nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|||||||
332
TASKS.md
332
TASKS.md
@@ -1,332 +0,0 @@
|
|||||||
# Rewrite The Web App Into A Route-First Authenticated Shell
|
|
||||||
|
|
||||||
This ExecPlan is a living document. The sections `Progress`, `Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds.
|
|
||||||
|
|
||||||
`PLANS.md` is checked into the repository root. This document must be maintained in accordance with `PLANS.md`.
|
|
||||||
|
|
||||||
## Purpose / Big Picture
|
|
||||||
|
|
||||||
After this change, the browser URL will match the authenticated screen the user is actually using. Anonymous users who open `/` will be redirected to `/login`. Authenticated users who open `/` will be redirected to `/play`. The hamburger menu will navigate to real routes such as `/play`, `/campaigns`, and `/admin` instead of toggling large conditional branches inside one component at `/`.
|
|
||||||
|
|
||||||
This matters because the current authenticated workspace is still one large, structurally dynamic Blazor Server surface. `POSTMORTEM.md` shows that this architecture is fragile when browser extensions mutate form-related DOM during startup. The route-first rewrite reduces the amount of UI that wakes up at once, removes the dual-purpose `/` shell, and makes the authenticated shell easier to reason about, test, and evolve.
|
|
||||||
|
|
||||||
The change is complete when a human can run the app, open `/`, observe the correct redirect based on auth state, log in at `/login`, land on `/play`, navigate to `/campaigns` and `/admin` with real URLs, refresh any of those routes without being thrown back to `/`, and run the automated host and Selenium tests that prove the new behavior.
|
|
||||||
|
|
||||||
## Progress
|
|
||||||
|
|
||||||
- [x] (2026-05-04 17:52Z) Reviewed `POSTMORTEM.md`, the current app shell, workspace routing behavior, and the existing host and frontend smoke tests to define the rewrite around real routes instead of `sessionStorage` screen switching.
|
|
||||||
- [x] (2026-05-04 17:52Z) Updated `README.md` so it accurately describes the current architecture and the approved rewrite direction.
|
|
||||||
- [x] (2026-05-04 18:29Z) Implemented a host-level `/` redirect to `/login` or `/play`, moved the static auth document to `/login`, switched login/logout targets to `/play` and `/login`, and updated the root-path host and smoke coverage to the new contract.
|
|
||||||
- [x] (2026-05-04 19:26Z) Replaced the checked-in Playwright smoke coverage with a geckodriver+Selenium smoke runner, including a Firefox DOM-wrap addon for extension-like startup mutations, and updated repo scripts/docs to the new browser verification path.
|
|
||||||
- [x] (2026-05-04) Introduced real authenticated routes for `/play`, `/campaigns`, and `/admin` while preserving the shared `Workspace` behavior behind those routes.
|
|
||||||
- [x] (2026-05-04) Removed `screen` as a `sessionStorage` routing mechanism and replaced menu actions with URL navigation.
|
|
||||||
- [x] (2026-05-04 21:42Z) Split the large `Workspace` render tree into a shared shell plus route-owned play, campaign-management, and admin content components, and kept the Selenium route and DOM-wrap coverage green after the split.
|
|
||||||
- [x] (2026-05-04 21:58Z) Removed shell-level `OnAfterRenderAsync` bootstrapping, moved the JS-dependent authenticated startup into a route-owned `WorkspaceRouteView`, removed shell-owned staged control renders, restored the missing development database fixture, and updated README to describe the completed route-first architecture.
|
|
||||||
- [x] (2026-05-04) Updated host tests, Selenium smoke tests, and docs so the real-route model is the documented and verified Milestone 2 behavior.
|
|
||||||
- [x] (2026-05-04) Added expanded workspace startup diagnostics across Blazor lifecycle logging, route-content render logging, and browser-side DOM mutation snapshots to narrow the remaining Firefox batch-2 crash.
|
|
||||||
- [x] (2026-05-04) Extended the diagnostics to page-load time by wrapping the interactive host in a stable container and logging pre-Blazor body and host mutations before the first interactive batch applies.
|
|
||||||
- [x] (2026-05-04) Reworked authenticated route startup into phased interactive batches so the first render mounts only a tiny shell, the second render mounts a simple header placeholder, the third render mounts route skeletons, and real control-heavy content appears only after route initialization completes.
|
|
||||||
- [x] (2026-05-04 22:17Z) Removed global authenticated `Routes` interactivity, moved `InteractiveServerRenderMode(prerender: false)` onto the real authenticated pages, and switched to manual `Blazor.start({ ssr: { disableDomPreservation: true } })` startup based on the upstream Firefox guidance in `dotnet/aspnetcore#58824`.
|
|
||||||
- [x] (2026-05-05) Confirmed the real fix in Firefox plus RoboForm, documented it in `README.md`, and removed the failed phased-render and diagnostics-only mitigation layers from the codebase.
|
|
||||||
|
|
||||||
## Surprises & Discoveries
|
|
||||||
|
|
||||||
- Observation: the current browser API client is still implemented through JavaScript interop, so the authenticated UI cannot simply move all startup work into `OnInitializedAsync`.
|
|
||||||
Evidence: `RpgRoller/Components/RpgRollerApiClient.cs` calls `js.InvokeAsync("rpgRollerApi.request", ...)`, which means authenticated data fetches currently depend on an interactive render before they can run.
|
|
||||||
|
|
||||||
- Observation: the current smoke suite encodes the old dual-purpose `/` behavior and will fail as soon as `/` becomes a redirect entry point.
|
|
||||||
Evidence: the checked-in smoke coverage originally expected anonymous `GET /` to render static auth markup and authenticated `GET /` to render the Blazor workspace shell, so it had to be rewritten when `/` became a redirect entry point.
|
|
||||||
|
|
||||||
- Observation: the current host test also encodes an outdated assumption about `/`.
|
|
||||||
Evidence: `RpgRoller.Tests/Api/FrontendHostTests.cs` currently asserts that `GET /` returns HTTP 200 and a Blazor shell containing `_framework/blazor.web.js`.
|
|
||||||
|
|
||||||
- Observation: `MapRazorComponents<App>()` does not serve the static `/login` document unless a matching component route exists, even though `App.razor` itself renders the static auth markup outside the interactive router.
|
|
||||||
Evidence: the first Milestone 1 host test run returned HTTP 404 for `GET /login` until a minimal `RpgRoller/Components/Pages/LoginPage.razor` with `@page "/login"` was added.
|
|
||||||
|
|
||||||
- Observation: the repository-wide backend suite currently contains a missing-fixture failure unrelated to the route-first rewrite.
|
|
||||||
Evidence: `dotnet test RpgRoller.Tests/RpgRoller.Tests.csproj --collect:"XPlat Code Coverage" --settings RpgRoller.Tests/coverlet.runsettings` failed in `HostingCoverageTests.InitializeRpgRollerState_MigratesCopiedDevelopmentDatabaseAndPreservesD6Rolling` because `RpgRoller/App_Data/rpgroller.development.db` is not present in the worktree.
|
|
||||||
|
|
||||||
- Observation: once the route-owned components controlled their own modal and page subtree rendering, the extra shell-owned play-control staging was no longer necessary for the DOM-wrap smoke coverage.
|
|
||||||
Evidence: after moving authenticated startup into a route-owned wrapper and rendering play controls directly, `node ./scripts/run-selenium.js` still passed the extension-like DOM-wrap coverage against `/play`.
|
|
||||||
|
|
||||||
- Observation: the first Milestone 4 attempt was still incomplete because authenticated startup remained route-agnostic behind `Session.InitializeAsync()`.
|
|
||||||
Evidence: `/admin` and `/play` could still hit the Firefox `insertBefore` circuit crash until admin and campaign-management routes stopped preloading play-only campaign scope, selected sheets, logs, and SSE startup during their first interactive batch.
|
|
||||||
|
|
||||||
- Observation: the remaining Firefox failure still happens during Blazor batch application, so server-side coordinator logs alone are not enough to localize it.
|
|
||||||
Evidence: after route-scoping startup, Firefox still reported `There was an error applying batch 2` with `TypeError: can't access property "insertBefore", n.parentNode is null`, which motivated adding route render lifecycle logs plus browser-side workspace mutation snapshots.
|
|
||||||
|
|
||||||
- Observation: the RoboForm-triggered crash happens before any component `OnAfterRenderAsync` callback in the authenticated route tree.
|
|
||||||
Evidence: in the failing `/play` and `/admin` reproductions, the last server-side logs were only `OnInitialized` and `OnParametersSet` entries for `Workspace` and its immediate child components; there were no `WorkspaceRouteView.OnAfterRenderAsync` or `Workspace.InitializeRouteCoreAsync` entries before the circuit terminated.
|
|
||||||
|
|
||||||
- Observation: the current app still matched the upstream "global interactivity" failure shape even after the route-first rewrite, because `App.razor` continued to apply `@rendermode` to the root `Routes` component.
|
|
||||||
Evidence: `RpgRoller/Components/App.razor` still rendered `<Routes @rendermode="@(new InteractiveServerRenderMode(prerender: false))" />` until the final follow-up pass, while `dotnet/aspnetcore#58824` explicitly reports Firefox crashes for Global mode and says PerPage mode does not reproduce.
|
|
||||||
|
|
||||||
- Observation: once the authenticated pages moved to per-page interactivity, header route navigation needed full document reloads instead of in-circuit `NavigationManager.NavigateTo` transitions.
|
|
||||||
Evidence: the first Selenium run after the per-page render-mode change reached `/play` in the URL but never mounted `#skill-filter-input` after `/campaigns -> /play` until `Workspace.NavigateToRouteAsync` switched to `forceLoad: true`.
|
|
||||||
|
|
||||||
- Observation: the phased first-render shells and browser/server diagnostics were not part of the final fix.
|
|
||||||
Evidence: after the app switched to per-page interactive render modes plus manual `Blazor.start({ ssr: { disableDomPreservation: true } })`, the Firefox plus RoboForm repro stopped even after those extra mitigations were removed.
|
|
||||||
|
|
||||||
- Observation: the locally installed Snap Firefox build on this machine is viable for Selenium through `geckodriver`, but not for Playwright protocol control.
|
|
||||||
Evidence: Playwright stalled during the `-juggler-pipe` handshake, while a `geckodriver` plus Selenium session against `/snap/firefox/current/usr/lib/firefox/firefox` completed the same Milestone 1 verification successfully.
|
|
||||||
|
|
||||||
## Decision Log
|
|
||||||
|
|
||||||
- Decision: implement the approved route-first approach rather than continuing to add localized mitigations inside the current `/` workspace shell.
|
|
||||||
Rationale: the user approved this direction after reviewing three refactor options, and `POSTMORTEM.md` concludes that the problem is architectural rather than a single bug.
|
|
||||||
Date/Author: 2026-05-04 / Codex and user
|
|
||||||
|
|
||||||
- Decision: keep the anonymous auth page as plain HTML and JavaScript, but move it to `/login` instead of restoring it as an interactive Blazor form.
|
|
||||||
Rationale: the anonymous path was intentionally isolated from Blazor in commit `2d2ed56`, and the postmortem treats that isolation as a valid mitigation for the login surface. The rewrite should not reintroduce a form-heavy Blazor login page unless there is a compelling reason later.
|
|
||||||
Date/Author: 2026-05-04 / Codex
|
|
||||||
|
|
||||||
- Decision: make `/` a server-side redirect entry point instead of continuing to let `App.razor` choose between auth and workspace content based on request-time auth state.
|
|
||||||
Rationale: `App.razor` is currently a hidden architecture boundary. Moving auth-based entry selection to an HTTP redirect makes the boundary explicit, testable, and smaller.
|
|
||||||
Date/Author: 2026-05-04 / Codex
|
|
||||||
|
|
||||||
- Decision: use the URL path as the source of truth for the current authenticated screen.
|
|
||||||
Rationale: the current `screen` preference in `sessionStorage` causes the app state and the browser URL to disagree. Real routes remove that mismatch and make refresh, deep-linking, and testing simpler.
|
|
||||||
Date/Author: 2026-05-04 / Codex
|
|
||||||
|
|
||||||
- Decision: stop using global authenticated interactivity and move the authenticated pages to per-page `InteractiveServerRenderMode(prerender: false)` with manual startup that disables SSR DOM preservation.
|
|
||||||
Rationale: upstream issue `dotnet/aspnetcore#58824` identifies Firefox failures tied to Global interactivity and explicitly notes that PerPage mode does not share the problem. The Blazor startup guidance also documents manual `Blazor.start` configuration for SSR options such as `disableDomPreservation`.
|
|
||||||
Date/Author: 2026-05-04 / Codex
|
|
||||||
|
|
||||||
- Decision: remove the phased render system and the crash-diagnostics scaffolding after the real fix was confirmed.
|
|
||||||
Rationale: those changes were useful for isolating the failure, but they increased code complexity without contributing to the final Firefox plus RoboForm solution.
|
|
||||||
Date/Author: 2026-05-05 / Codex
|
|
||||||
|
|
||||||
- Decision: stage the rewrite in two layers: first introduce real routes while preserving existing feature behavior, then split the large workspace tree into route-owned subtrees.
|
|
||||||
Rationale: the current workspace is dense and risk-prone. A staged rewrite keeps the app working while the route model changes, and it gives the test suite meaningful checkpoints.
|
|
||||||
Date/Author: 2026-05-04 / Codex
|
|
||||||
|
|
||||||
- Decision: standardize frontend smoke verification on geckodriver plus Selenium instead of Playwright in this repository.
|
|
||||||
Rationale: the user updated the repo instructions to make Selenium the required browser automation path, and the locally installed Firefox stack works reliably through geckodriver while Playwright cannot control the Snap Firefox build on this machine.
|
|
||||||
Date/Author: 2026-05-04 / Codex and user
|
|
||||||
|
|
||||||
## Outcomes & Retrospective
|
|
||||||
|
|
||||||
At plan creation time, the repository has an updated README and a concrete implementation plan, but no code for the route-first rewrite has been started yet. The immediate risk is not uncertainty about direction; it is carrying old assumptions about `/`, `Home.razor`, and `sessionStorage`-based screen switching into the first code changes. The milestones below are written to make those assumptions explicit and retire them in an observable order.
|
|
||||||
|
|
||||||
After Milestone 1, the dual-purpose `/` entry point is gone. Anonymous requests to `/` are now redirected before Blazor renders, the static auth document lives at `/login`, and successful login lands on `/play`. The main residual risk is that the authenticated shell is still monolithic behind the new `/play` route, so later milestones still need to replace in-memory screen switching with real route ownership.
|
|
||||||
|
|
||||||
After the Selenium migration iteration, the repository’s browser smoke coverage once again matches the documented verification path. The smoke suite now runs against Firefox through geckodriver, and the DOM-wrap regression coverage remains intact through a temporary test addon. The next risk is purely architectural again: the authenticated shell still uses in-memory screen switching, so Milestone 2 remains the next code change on the critical path.
|
|
||||||
|
|
||||||
After Milestone 2, the authenticated shell now has first-class `/play`, `/campaigns`, and `/admin` routes, and the menu navigates with URLs instead of `sessionStorage` screen names. The remaining risk is now narrower and more structural: `Workspace.razor` still owns mutually exclusive authenticated branches, and the root `OnAfterRenderAsync` path still stages page-specific startup work that should move into route-owned components in Milestones 3 and 4.
|
|
||||||
|
|
||||||
After Milestone 3, `Workspace.razor` is now a shell that owns shared chrome, health state, and toast feedback, while the play, campaign-management, and admin DOM each live in route-owned components supplied by `/play`, `/campaigns`, and `/admin`. The route split preserved the host tests and full Selenium smoke coverage, including the DOM-wrap regression case, but the final startup path is still staged through `Workspace.razor.cs` and remains the next target for Milestone 4.
|
|
||||||
|
|
||||||
After Milestone 4, authenticated startup is now triggered by a route-owned wrapper instead of `Workspace.razor.cs`, the shared shell no longer uses `OnAfterRenderAsync`, and the play route renders its controls directly without shell-driven follow-up batches. The route-first rewrite is now functionally complete: host tests pass, the Selenium smoke suite passes, and the restored development-database fixture lets the backend coverage suite validate the full repo behavior again.
|
|
||||||
|
|
||||||
Follow-up: the first pass at Milestone 4 removed shell-level `OnAfterRenderAsync`, but did not yet split `Session.InitializeAsync()` by route. The final follow-up fix made startup genuinely route-scoped by keeping `/admin` off play-only campaign scope and SSE startup, gating the full shell behind authenticated initialization, and adding direct `/admin` smoke coverage so this regression path stays visible.
|
|
||||||
|
|
||||||
Follow-up 2: gating the entire authenticated shell behind `HasSessionInitialized` produced another large first-batch subtree swap and broke the `/campaigns` to `/play` refresh path. The final stabilization renders a consistent route skeleton from batch 1, derives loading UI from `HasSessionInitialized` instead of mutating shared loading state in `WorkspaceRouteView`, and refreshes route-specific scope explicitly when the same `Workspace` instance changes from one authenticated route to another.
|
|
||||||
|
|
||||||
This section must be updated after each major milestone. When the implementation is complete, summarize which parts of the old workspace architecture were fully removed, which compatibility constraints remain, and whether the final startup path still depends on any multi-batch structural rendering.
|
|
||||||
|
|
||||||
## Context and Orientation
|
|
||||||
|
|
||||||
The current app serves both anonymous and authenticated experiences from the same HTML shell. In `RpgRoller/Components/App.razor`, the shell checks the current request path through `HttpContext`. If the request is for `/login`, it renders `RpgRoller/Components/Pages/HomeControls/StaticAuthPage.razor` as plain HTML and loads `RpgRoller/wwwroot/js/rpgroller-api.js`. That JavaScript file binds the login and registration forms and sends `fetch` requests to `/api/auth/register` and `/api/auth/login`.
|
|
||||||
|
|
||||||
If a valid session cookie exists, `App.razor` instead renders the interactive Blazor router. The authenticated shell is now entered through `RpgRoller/Components/Pages/PlayPage.razor`, `CampaignsPage.razor`, and `AdminPage.razor`, which map `/play`, `/campaigns`, and `/admin` and forward into the shared `Workspace` component.
|
|
||||||
|
|
||||||
The authenticated workspace lives in `RpgRoller/Components/Pages/Workspace.razor` and `Workspace.razor.cs`. The Razor file still contains the header, play screen, campaign management screen, admin screen, toasts, and modals. The code-behind wires several coordinator classes, and `OnAfterRenderAsync` still drives session initialization and staged control enablement. The current route now comes from the page component parameter rather than `WorkspaceState.CurrentScreen`, and `WorkspaceSessionCoordinator.cs` no longer persists a screen name in browser `sessionStorage`.
|
|
||||||
|
|
||||||
In plain language, a “route-first authenticated shell” means that the browser path decides which authenticated page is being rendered. `/play` means the play page. `/campaigns` means the campaign management page. `/admin` means the admin page. The URL is not a decorative detail; it is the primary way the app chooses the screen. Menu clicks change the URL. Reloading the page preserves the same screen because the URL already says what the screen is.
|
|
||||||
|
|
||||||
In this repository, “server-side redirect” means an HTTP redirect response such as `302 Found` returned before any Blazor UI is rendered. For example, `GET /` should answer with a redirect to `/login` or `/play` based on whether the session cookie maps to a real user through `IGameService.GetUserBySession`.
|
|
||||||
|
|
||||||
The API surface is already session-cookie-based. `RpgRoller/Api/AuthEndpoints.cs` sets the session cookie on login, `RpgRoller/Api/MeEndpoints.cs` returns the authenticated user model, and the rest of the authenticated `/api` routes are behind `RequireSessionTokenFilter`. This means the routing rewrite does not need a new auth system. It needs a clearer frontend entry structure and smaller authenticated page ownership boundaries.
|
|
||||||
|
|
||||||
One constraint must be kept in mind from the start: `RpgRoller/Components/RpgRollerApiClient.cs` performs requests through JavaScript interop. That means the authenticated UI still needs an interactive render before it can make its first data request. The rewrite must therefore reduce the amount of structure that changes after interactivity begins, not pretend that interactivity can be avoided entirely with the current client stack.
|
|
||||||
|
|
||||||
## Plan of Work
|
|
||||||
|
|
||||||
Begin by separating the entry route from the anonymous auth page. Add a small host-level endpoint module, for example `RpgRoller/Api/FrontendEntryEndpoints.cs`, or an equivalent hosting extension, and map `GET /` before the Razor component host is mapped in `RpgRoller/Program.cs`. This endpoint must read the session cookie using the same cookie name defined in `RpgRoller/Api/SessionCookie.cs`, ask `IGameService` whether the cookie belongs to a real user, and return a redirect to `/play` for authenticated users or `/login` for anonymous users. This removes the dual-purpose `/` behavior.
|
|
||||||
|
|
||||||
Next, simplify `RpgRoller/Components/App.razor` so it no longer chooses between anonymous and authenticated content based on auth state. It may still choose between a static `/login` document and the interactive authenticated router based on the request path, because the anonymous page is intentionally plain HTML. The important change is that auth-state branching moves out of the component tree. `App.razor` should become a stable host for either the static login document at `/login` or the interactive authenticated route set everywhere else.
|
|
||||||
|
|
||||||
After that, introduce real component routes for the authenticated pages. Create `RpgRoller/Components/Pages/PlayPage.razor`, `CampaignsPage.razor`, and `AdminPage.razor`, each with an explicit `@page` directive. In the first implementation pass, it is acceptable to keep much of the existing state and coordinator logic by adapting `Workspace` so each route uses only the subtree it needs. The key result of this milestone is that the URL changes from `/play` to `/campaigns` to `/admin` and each route refreshes correctly.
|
|
||||||
|
|
||||||
Once the routes exist, remove `screen` as a persistence and navigation concept. Delete the `CurrentScreen` routing responsibility from `WorkspaceState.cs` and remove the `screen` `sessionStorage` reads and writes from `WorkspaceSessionCoordinator.cs`. Replace menu items in `Workspace.razor.cs` and `AppHeader.razor` wiring so they navigate through `NavigationManager.NavigateTo(...)` or plain links to `/play`, `/campaigns`, and `/admin`. Keep `sessionStorage` only for true view preferences such as mobile panel state, selected campaign when appropriate, and roll visibility if those still earn their complexity.
|
|
||||||
|
|
||||||
The next pass is the structural split. Extract the common authenticated chrome into a dedicated component such as `RpgRoller/Components/Pages/AuthenticatedShell.razor`. This shared shell should own the header, logout action, health banner, and toast stack. Then move the play-only DOM, campaign management DOM, and admin DOM out of the monolithic conditional branches in `Workspace.razor` and into page-specific route components. `PlayPage` should own SSE startup and the play-specific panels. `CampaignsPage` should own character create and edit workflows. `AdminPage` should own admin-only data loading and buttons. The goal is that each route owns a smaller and more stable subtree, rather than all authenticated screens living under one branching root.
|
|
||||||
|
|
||||||
Finally, revisit startup sequencing. Because API reads still depend on JS interop, some post-render initialization may remain necessary, but it should be limited to the page that actually needs it. Remove the pattern where the authenticated shell root performs several structural follow-up renders merely to decide which screen to show. If staged initialization remains on `/play`, it should be contained to the play page and should reveal a stable page-local loading shell rather than reshaping the entire authenticated app. Record the exact remaining `OnAfterRenderAsync` responsibilities in the code and in `README.md`.
|
|
||||||
|
|
||||||
Throughout the rewrite, keep the documentation and tests aligned. `README.md` must stop describing the rewrite as planned once the code lands, and the host and smoke tests must verify the new route-first behavior rather than preserve the old root-path assumptions.
|
|
||||||
|
|
||||||
## Milestones
|
|
||||||
|
|
||||||
### Milestone 1: Make `/` An Explicit Entry Redirect
|
|
||||||
|
|
||||||
At the end of this milestone, a browser request to `/` no longer renders either the auth page or the workspace directly. Instead, the server returns a redirect to `/login` or `/play` based on the session cookie. The anonymous auth page is reachable at `/login`, and logging in transitions the user to `/play`.
|
|
||||||
|
|
||||||
Implement this by adding the new entry endpoint mapping, updating `App.razor` to host `/login` without auth-state branching, and changing `rpgroller-api.js` so successful login goes to `/play` rather than `/`. Also update `Home.razor.cs` or its replacement logout helper so logout navigates to `/login` with the existing status message query behavior.
|
|
||||||
|
|
||||||
Proof for this milestone is simple and observable. Anonymous `GET /` returns a redirect to `/login`. Authenticated `GET /` returns a redirect to `/play`. Opening `/login` renders the current static auth markup without loading `_framework/blazor.web.js`. Logging in from `/login` lands on the play workspace.
|
|
||||||
|
|
||||||
### Milestone 2: Add Real Authenticated Routes Without Breaking Features
|
|
||||||
|
|
||||||
At the end of this milestone, `/play`, `/campaigns`, and `/admin` all exist as first-class routes, and the hamburger menu moves between them using URLs rather than `sessionStorage`. Feature behavior may still be backed by some shared workspace code, but the route model is now real.
|
|
||||||
|
|
||||||
Implement this by creating the new page components and adapting the current workspace logic so the correct route renders the correct content. During this milestone it is acceptable to keep a shared backing component or service if that reduces churn, but the URL must be the authoritative screen selection mechanism. Direct navigation to `/campaigns` should show campaign management, not the play screen followed by an in-memory switch. Direct navigation to `/admin` should either show the admin page for admins or redirect non-admin users to `/play`.
|
|
||||||
|
|
||||||
Acceptance for this milestone is that refreshing `/campaigns` leaves the user on `/campaigns`, refreshing `/play` leaves the user on `/play`, and opening `/admin` as a non-admin does not expose admin controls.
|
|
||||||
|
|
||||||
### Milestone 3: Split The Monolithic Workspace Tree
|
|
||||||
|
|
||||||
At the end of this milestone, there is no longer a single authenticated component that conditionally renders all major screens under one branch-heavy root. Shared authenticated chrome is extracted, and each route owns its own main content subtree.
|
|
||||||
|
|
||||||
Implement this by introducing a shared authenticated shell component and moving the play, campaign management, and admin markup and page-specific coordination into route-owned components. Keep shared models and helper methods where they still make sense, but stop letting the root workspace decide which major screen exists in the DOM. If common state still exists, narrow it to user identity, selected campaign context, and shared feedback only.
|
|
||||||
|
|
||||||
Acceptance for this milestone is partly structural and partly behavioral. Structurally, `Workspace.razor` should no longer contain mutually exclusive branches for play, management, and admin screens. Behaviorally, the DOM-wrap smoke test or its replacement should still pass while each route loads only the controls it needs.
|
|
||||||
|
|
||||||
### Milestone 4: Reduce Startup Churn And Finalize Docs
|
|
||||||
|
|
||||||
At the end of this milestone, the authenticated shell no longer uses `OnAfterRenderAsync` as the orchestration point for screen selection and broad structural staging. Any remaining post-render work is page-local, justified, and documented.
|
|
||||||
|
|
||||||
Implement this by moving any remaining screen-routing or shell-bootstrap logic out of `Workspace.razor.cs`, narrowing `OnAfterRenderAsync` responsibilities, and updating `README.md` to describe the completed route-first architecture rather than a planned rewrite. Also update `POSTMORTEM.md` only if a concise follow-up note is warranted; do not rewrite its historical analysis.
|
|
||||||
|
|
||||||
Acceptance for this milestone is a passing automated suite plus a manual browser run where `/`, `/login`, `/play`, `/campaigns`, and `/admin` all behave consistently with the final route model.
|
|
||||||
|
|
||||||
## Concrete Steps
|
|
||||||
|
|
||||||
Run all commands from the repository root, which is `/home/frank/Code/RpgRoller`.
|
|
||||||
|
|
||||||
Start by inspecting the current route and auth files before editing:
|
|
||||||
|
|
||||||
sed -n '1,220p' RpgRoller/Components/App.razor
|
|
||||||
sed -n '1,220p' RpgRoller/Components/Pages/Home.razor
|
|
||||||
sed -n '1,260p' RpgRoller/Components/Pages/Workspace.razor.cs
|
|
||||||
sed -n '1,260p' RpgRoller/Components/Pages/WorkspaceSessionCoordinator.cs
|
|
||||||
sed -n '1,260p' RpgRoller/wwwroot/js/rpgroller-api.js
|
|
||||||
sed -n '1,220p' RpgRoller.Tests/Api/FrontendHostTests.cs
|
|
||||||
sed -n '1,260p' tests/e2e/smoke.js
|
|
||||||
|
|
||||||
When implementing Milestone 1, update the host test first so the intended redirect behavior is explicit:
|
|
||||||
|
|
||||||
dotnet test RpgRoller.Tests/RpgRoller.Tests.csproj --filter FrontendHostTests
|
|
||||||
|
|
||||||
Expected direction after the edits:
|
|
||||||
|
|
||||||
RootPath_RedirectsToLogin_WhenAnonymous
|
|
||||||
RootPath_RedirectsToPlay_WhenAuthenticated
|
|
||||||
LoginPath_ServesStaticAuthMarkup
|
|
||||||
|
|
||||||
After wiring `/login` and the root redirect, run the app locally:
|
|
||||||
|
|
||||||
dotnet run --project RpgRoller/RpgRoller.csproj
|
|
||||||
|
|
||||||
Then verify in a browser:
|
|
||||||
|
|
||||||
open http://localhost:5000/
|
|
||||||
observe: anonymous request lands on /login
|
|
||||||
submit valid credentials
|
|
||||||
observe: browser lands on /play
|
|
||||||
|
|
||||||
When implementing route pages and navigation, prefer running the focused smoke suite against a temporary database:
|
|
||||||
|
|
||||||
node ./scripts/run-selenium.js
|
|
||||||
|
|
||||||
If the app is already running and a faster inner loop is needed, run the checked-in smoke file directly:
|
|
||||||
|
|
||||||
npm run e2e:smoke
|
|
||||||
|
|
||||||
After each milestone that touches C# files, run the relevant test suite and then the full backend suite before moving on:
|
|
||||||
|
|
||||||
dotnet test RpgRoller.Tests/RpgRoller.Tests.csproj --collect:"XPlat Code Coverage" --settings RpgRoller.Tests/coverlet.runsettings
|
|
||||||
|
|
||||||
After major frontend milestones, repeat browser verification in Firefox. If a Firefox profile with RoboForm is available, include that manual check and record the result in `Surprises & Discoveries` or `Outcomes & Retrospective`.
|
|
||||||
|
|
||||||
## Validation and Acceptance
|
|
||||||
|
|
||||||
The final implementation is acceptable only if all of the following behaviors are true and visible.
|
|
||||||
|
|
||||||
Anonymous navigation:
|
|
||||||
|
|
||||||
`GET /` returns an HTTP redirect to `/login`. Opening `/login` shows the static auth document with the current register and login forms. The `/login` document must not load `_framework/blazor.web.js`, and it must still include the existing auth page hooks used by `rpgroller-api.js`.
|
|
||||||
|
|
||||||
Authenticated navigation:
|
|
||||||
|
|
||||||
After a successful login, the browser lands on `/play`. Opening `/` with an already valid session cookie redirects to `/play`. Refreshing `/play`, `/campaigns`, or `/admin` preserves the same route instead of rebuilding everything behind `/`.
|
|
||||||
|
|
||||||
Menu behavior:
|
|
||||||
|
|
||||||
The header menu items navigate to real routes. The active state matches the current route. Non-admin users cannot remain on `/admin`; they are redirected to `/play` or shown a deliberate authorization result defined by the implementation, but not an exposed admin UI.
|
|
||||||
|
|
||||||
Workspace stability:
|
|
||||||
|
|
||||||
The authenticated play route continues to support the existing play workflow, including campaign log rendering, character controls, and custom roll actions. The DOM-wrap smoke coverage for extension-like mutations must still pass, either through the existing test or an updated equivalent that targets `/play`.
|
|
||||||
|
|
||||||
Automated coverage:
|
|
||||||
|
|
||||||
`dotnet test RpgRoller.Tests/RpgRoller.Tests.csproj --collect:"XPlat Code Coverage" --settings RpgRoller.Tests/coverlet.runsettings` passes.
|
|
||||||
|
|
||||||
`node ./scripts/run-selenium.js` passes against a temporary SQLite database.
|
|
||||||
|
|
||||||
If any previous tests are deleted or renamed because they encoded the old `/` behavior, replace them with tests that prove the new route model instead of simply removing coverage.
|
|
||||||
|
|
||||||
## Idempotence and Recovery
|
|
||||||
|
|
||||||
This rewrite should be implemented as a sequence of additive, testable steps. Each milestone must leave the app runnable and the tests meaningful. Avoid a large flag day where `/` is changed, the route pages half-exist, and the smoke suite is left broken for an extended period.
|
|
||||||
|
|
||||||
The safest recovery strategy is to keep the current workspace internals temporarily while introducing the new route model. That means it is acceptable to reuse `Workspace` behind the new page routes during Milestone 2, as long as the route behavior is correct and clearly transitional. After that, extract route-specific subtrees in Milestone 3.
|
|
||||||
|
|
||||||
When changing redirects or login targets, update the host and Selenium assertions in the same commit as the code change so the repository never has code and tests describing different route contracts.
|
|
||||||
|
|
||||||
Use a temporary SQLite database for Selenium verification, as required by the repo instructions, so browser tests do not mutate the canonical development database.
|
|
||||||
|
|
||||||
## Artifacts and Notes
|
|
||||||
|
|
||||||
Current evidence that must be retired by this rewrite:
|
|
||||||
|
|
||||||
RpgRoller.Tests/Api/FrontendHostTests.cs
|
|
||||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
||||||
Assert.Contains("_framework/blazor.web.js", html);
|
|
||||||
|
|
||||||
tests/e2e/smoke.js
|
|
||||||
browser checks for anonymous `/`, static `/login`, authenticated `/`, and the authenticated workspace flows
|
|
||||||
|
|
||||||
Current evidence that explains the bootstrap constraint:
|
|
||||||
|
|
||||||
RpgRoller/Components/RpgRollerApiClient.cs
|
|
||||||
var response = await js.InvokeAsync<JsApiResponse>("rpgRollerApi.request", method, path, payload);
|
|
||||||
|
|
||||||
Current evidence that Milestone 2 is intentionally transitional rather than final:
|
|
||||||
|
|
||||||
RpgRoller/Components/Pages/Workspace.razor
|
|
||||||
@if (IsPlayRoute) { ... }
|
|
||||||
else if (IsCampaignsRoute) { ... }
|
|
||||||
else if (IsAdminRoute) { ... }
|
|
||||||
|
|
||||||
## Interfaces and Dependencies
|
|
||||||
|
|
||||||
The implementation must continue to use the existing ASP.NET Core hosting model in `RpgRoller/Program.cs`, the minimal API auth surface in `RpgRoller/Api`, and the existing `IGameService` session lookup methods. Do not introduce a second auth mechanism.
|
|
||||||
|
|
||||||
At the end of Milestone 1, the codebase must contain a host-level entry point with behavior equivalent to:
|
|
||||||
|
|
||||||
GET /
|
|
||||||
if session cookie maps to a valid user: redirect to /play
|
|
||||||
otherwise: redirect to /login
|
|
||||||
|
|
||||||
At the end of Milestone 2, the codebase must contain route components equivalent to:
|
|
||||||
|
|
||||||
/play
|
|
||||||
/campaigns
|
|
||||||
/admin
|
|
||||||
|
|
||||||
Each of those routes must be directly navigable and refreshable.
|
|
||||||
|
|
||||||
At the end of Milestone 3, the codebase must contain a shared authenticated shell component or layout that owns common header and feedback concerns, while route pages own their feature-specific DOM. Stable names are recommended:
|
|
||||||
|
|
||||||
RpgRoller/Components/Pages/AuthenticatedShell.razor
|
|
||||||
RpgRoller/Components/Pages/PlayPage.razor
|
|
||||||
RpgRoller/Components/Pages/CampaignsPage.razor
|
|
||||||
RpgRoller/Components/Pages/AdminPage.razor
|
|
||||||
|
|
||||||
These exact filenames are recommended because they make the route split obvious to a new contributor, but equivalent names are acceptable if the same ownership boundaries are preserved and the README is updated accordingly.
|
|
||||||
|
|
||||||
## Revision Note
|
|
||||||
|
|
||||||
2026-05-04 17:52Z: Initial ExecPlan created after the route-first rewrite direction was approved and the README was overhauled. The main reason for the plan is to replace the dual-purpose `/` shell with explicit routes while keeping the repository testable at every step.
|
|
||||||
|
|||||||
17
deploy.ps1
17
deploy.ps1
@@ -1,17 +0,0 @@
|
|||||||
param(
|
|
||||||
[string]$Password,
|
|
||||||
[switch]$SkipRecycle,
|
|
||||||
[switch]$SkipMigrations
|
|
||||||
)
|
|
||||||
|
|
||||||
Set-StrictMode -Version Latest
|
|
||||||
$ErrorActionPreference = "Stop"
|
|
||||||
|
|
||||||
$scriptPath = Join-Path $PSScriptRoot "scripts/deploy-ftp1.ps1"
|
|
||||||
$profilePath = Join-Path $PSScriptRoot "scripts/deploy-ftp.profile.psd1"
|
|
||||||
|
|
||||||
& $scriptPath `
|
|
||||||
-ProfilePath $profilePath `
|
|
||||||
-Password $Password `
|
|
||||||
-SkipRecycle:$SkipRecycle `
|
|
||||||
-SkipMigrations:$SkipMigrations
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"openapi": "3.0.1",
|
"openapi": "3.0.1",
|
||||||
"info": {
|
"info": {
|
||||||
"title": "RpgRoller API",
|
"title": "RpgRoller API",
|
||||||
@@ -156,6 +156,46 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/me/theme": {
|
||||||
|
"put": {
|
||||||
|
"operationId": "updateThemePreference",
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/UpdateThemePreferenceRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Updated current user.",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/UserSummary"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Validation error.",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Unauthorized."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/campaigns": {
|
"/api/campaigns": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "getCampaigns",
|
"operationId": "getCampaigns",
|
||||||
@@ -701,12 +741,27 @@
|
|||||||
},
|
},
|
||||||
"displayName": {
|
"displayName": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
},
|
||||||
|
"roles": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"themePreference": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true,
|
||||||
|
"enum": [
|
||||||
|
"light",
|
||||||
|
"dark"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"id",
|
"id",
|
||||||
"username",
|
"username",
|
||||||
"displayName"
|
"displayName",
|
||||||
|
"roles"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"MeResponse": {
|
"MeResponse": {
|
||||||
@@ -730,6 +785,21 @@
|
|||||||
"user"
|
"user"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"UpdateThemePreferenceRequest": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"themePreference": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"light",
|
||||||
|
"dark"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"themePreference"
|
||||||
|
]
|
||||||
|
},
|
||||||
"RulesetDefinition": {
|
"RulesetDefinition": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
@{
|
|
||||||
ProjectPath = "..\RpgRoller\RpgRoller.csproj"
|
|
||||||
Configuration = "Release"
|
|
||||||
Runtime = "win-x64"
|
|
||||||
PublishDir = "%TEMP%\RpgRoller-publish"
|
|
||||||
SelfContained = $false
|
|
||||||
|
|
||||||
WinScpPath = "C:\Users\frank\AppData\Local\Programs\WinSCP\WinSCP.com"
|
|
||||||
RemoteDir = "/httpdocs/rpgroller"
|
|
||||||
BasePath = "/rpgroller"
|
|
||||||
FtpHost = "xTr1m.com"
|
|
||||||
FtpUser = "xTr1m"
|
|
||||||
|
|
||||||
RecycleAppPool = $true
|
|
||||||
AppPoolName = "xTr1m.com(domain)(4.0)(pool)"
|
|
||||||
WinRmComputer = "xTr1m.com"
|
|
||||||
WinRmCredentialUser = "Administrator"
|
|
||||||
UseWinRmHttps = $true
|
|
||||||
WinRmAuth = "Basic"
|
|
||||||
|
|
||||||
RunEfMigrations = $false
|
|
||||||
RemoteSitePath = "C:\Inetpub\vhosts\xTr1m.com\httpdocs\rpgroller"
|
|
||||||
}
|
|
||||||
@@ -1,255 +0,0 @@
|
|||||||
param(
|
|
||||||
[string]$ProfilePath = (Join-Path $PSScriptRoot "deploy-ftp.profile.psd1"),
|
|
||||||
[string]$Password,
|
|
||||||
[switch]$SkipRecycle,
|
|
||||||
[switch]$SkipMigrations
|
|
||||||
)
|
|
||||||
|
|
||||||
<#
|
|
||||||
.SYNOPSIS
|
|
||||||
Publish the app and mirror output to an FTP-deployed IIS site.
|
|
||||||
|
|
||||||
.DESCRIPTION
|
|
||||||
- Reads environment-specific settings from a PowerShell data file profile.
|
|
||||||
- Builds with dotnet publish.
|
|
||||||
- Uses WinSCP to mirror publish output into remote directory (deletes extraneous files).
|
|
||||||
- Optionally recycles IIS app pool and runs EF migrations remotely over WinRM.
|
|
||||||
|
|
||||||
.EXAMPLE
|
|
||||||
pwsh ./scripts/deploy-ftp.ps1 -ProfilePath ./scripts/deploy-ftp.profile.psd1
|
|
||||||
#>
|
|
||||||
|
|
||||||
Set-StrictMode -Version Latest
|
|
||||||
$ErrorActionPreference = "Stop"
|
|
||||||
|
|
||||||
function Assert-Tool {
|
|
||||||
param([Parameter(Mandatory = $true)][string]$Name)
|
|
||||||
if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) {
|
|
||||||
throw "Required tool '$Name' not found. Install it or update your deploy profile."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function Require-ConfigValue {
|
|
||||||
param(
|
|
||||||
[Parameter(Mandatory = $true)][hashtable]$Config,
|
|
||||||
[Parameter(Mandatory = $true)][string]$Key
|
|
||||||
)
|
|
||||||
|
|
||||||
if (-not $Config.ContainsKey($Key) -or [string]::IsNullOrWhiteSpace([string]$Config[$Key])) {
|
|
||||||
throw "Missing required deploy profile value '$Key'."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function Resolve-ProfilePath {
|
|
||||||
param(
|
|
||||||
[Parameter(Mandatory = $true)][string]$BaseDirectory,
|
|
||||||
[Parameter(Mandatory = $true)][string]$PathValue
|
|
||||||
)
|
|
||||||
|
|
||||||
$expanded = [Environment]::ExpandEnvironmentVariables($PathValue)
|
|
||||||
if ([System.IO.Path]::IsPathRooted($expanded)) {
|
|
||||||
return $expanded
|
|
||||||
}
|
|
||||||
|
|
||||||
return [System.IO.Path]::GetFullPath((Join-Path $BaseDirectory $expanded))
|
|
||||||
}
|
|
||||||
|
|
||||||
function Read-PlainOrPrompt {
|
|
||||||
param(
|
|
||||||
[string]$Value,
|
|
||||||
[Parameter(Mandatory = $true)][string]$Prompt,
|
|
||||||
[bool]$Secure = $false
|
|
||||||
)
|
|
||||||
|
|
||||||
if (-not [string]::IsNullOrWhiteSpace($Value)) {
|
|
||||||
return $Value
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($Secure) {
|
|
||||||
$pwd = Read-Host -Prompt $Prompt -AsSecureString
|
|
||||||
$ptr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($pwd)
|
|
||||||
try {
|
|
||||||
return [Runtime.InteropServices.Marshal]::PtrToStringUni($ptr)
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
if ($ptr -ne [IntPtr]::Zero) {
|
|
||||||
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($ptr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Read-Host -Prompt $Prompt
|
|
||||||
}
|
|
||||||
|
|
||||||
function Invoke-WinRmScript {
|
|
||||||
param(
|
|
||||||
[Parameter(Mandatory = $true)][hashtable]$Config,
|
|
||||||
[Parameter(Mandatory = $true)][string]$PasswordValue,
|
|
||||||
[Parameter(Mandatory = $true)][scriptblock]$ScriptBlock,
|
|
||||||
[object[]]$ArgumentList = @()
|
|
||||||
)
|
|
||||||
|
|
||||||
Require-ConfigValue $Config "WinRmComputer"
|
|
||||||
Require-ConfigValue $Config "WinRmCredentialUser"
|
|
||||||
|
|
||||||
$secure = ConvertTo-SecureString $PasswordValue -AsPlainText -Force
|
|
||||||
$cred = New-Object pscredential($Config.WinRmCredentialUser, $secure)
|
|
||||||
|
|
||||||
$invokeParams = @{
|
|
||||||
ComputerName = $Config.WinRmComputer
|
|
||||||
Credential = $cred
|
|
||||||
ScriptBlock = $ScriptBlock
|
|
||||||
ArgumentList = $ArgumentList
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($Config.ContainsKey("UseWinRmHttps") -and [bool]$Config.UseWinRmHttps) {
|
|
||||||
$invokeParams["UseSSL"] = $true
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($Config.ContainsKey("WinRmAuth") -and -not [string]::IsNullOrWhiteSpace([string]$Config.WinRmAuth)) {
|
|
||||||
$invokeParams["Authentication"] = [string]$Config.WinRmAuth
|
|
||||||
}
|
|
||||||
|
|
||||||
Invoke-Command @invokeParams
|
|
||||||
}
|
|
||||||
|
|
||||||
if (-not (Test-Path $ProfilePath)) {
|
|
||||||
throw "Deploy profile not found: $ProfilePath. Copy scripts/deploy-ftp.profile.sample.psd1 and fill environment-specific values."
|
|
||||||
}
|
|
||||||
|
|
||||||
$resolvedProfilePath = (Resolve-Path $ProfilePath).Path
|
|
||||||
$profileDirectory = Split-Path -Parent $resolvedProfilePath
|
|
||||||
$config = Import-PowerShellDataFile -Path $resolvedProfilePath
|
|
||||||
|
|
||||||
Require-ConfigValue $config "ProjectPath"
|
|
||||||
Require-ConfigValue $config "Configuration"
|
|
||||||
Require-ConfigValue $config "Runtime"
|
|
||||||
Require-ConfigValue $config "PublishDir"
|
|
||||||
Require-ConfigValue $config "WinScpPath"
|
|
||||||
Require-ConfigValue $config "RemoteDir"
|
|
||||||
|
|
||||||
$winScpSessionName = if ($config.ContainsKey("WinScpSessionName")) { [string]$config.WinScpSessionName } else { "" }
|
|
||||||
$useStoredSession = -not [string]::IsNullOrWhiteSpace($winScpSessionName)
|
|
||||||
|
|
||||||
if (-not $useStoredSession) {
|
|
||||||
Require-ConfigValue $config "FtpHost"
|
|
||||||
Require-ConfigValue $config "FtpUser"
|
|
||||||
}
|
|
||||||
|
|
||||||
$projectPath = Resolve-ProfilePath $profileDirectory ([string]$config.ProjectPath)
|
|
||||||
$publishDir = Resolve-ProfilePath $profileDirectory ([string]$config.PublishDir)
|
|
||||||
$winScpPath = Resolve-ProfilePath $profileDirectory ([string]$config.WinScpPath)
|
|
||||||
$selfContained = if ($config.ContainsKey("SelfContained")) { [bool]$config.SelfContained } else { $false }
|
|
||||||
$recycleAppPool = if ($config.ContainsKey("RecycleAppPool")) { [bool]$config.RecycleAppPool } else { $false }
|
|
||||||
$runEfMigrations = if ($config.ContainsKey("RunEfMigrations")) { [bool]$config.RunEfMigrations } else { $false }
|
|
||||||
$recycleAppPool = $recycleAppPool -and -not $SkipRecycle
|
|
||||||
$runEfMigrations = $runEfMigrations -and -not $SkipMigrations
|
|
||||||
|
|
||||||
$passwordFromEnv = $env:PICKNPLAY_FTP_PASSWORD
|
|
||||||
$passwordFromInput = if (-not [string]::IsNullOrWhiteSpace($Password)) { $Password } else { $passwordFromEnv }
|
|
||||||
$needsFtpPassword = -not $useStoredSession
|
|
||||||
$needsWinRmPassword = $recycleAppPool -or $runEfMigrations
|
|
||||||
$sharedPassword = ""
|
|
||||||
|
|
||||||
if ($needsFtpPassword -or $needsWinRmPassword) {
|
|
||||||
$prompt = if ($needsFtpPassword -and $needsWinRmPassword) { "FTP/WinRM password" } elseif ($needsFtpPassword) { "FTP password" } else { "WinRM password" }
|
|
||||||
$sharedPassword = Read-PlainOrPrompt -Value $passwordFromInput -Prompt $prompt -Secure $true
|
|
||||||
}
|
|
||||||
|
|
||||||
$passwordForSession = if ($needsFtpPassword) { $sharedPassword } else { "" }
|
|
||||||
$passwordForWinRm = if ($needsWinRmPassword) { $sharedPassword } else { "" }
|
|
||||||
|
|
||||||
Assert-Tool "dotnet"
|
|
||||||
Assert-Tool $winScpPath
|
|
||||||
|
|
||||||
Write-Host "1) Publishing..." -ForegroundColor Cyan
|
|
||||||
if (Test-Path $publishDir) {
|
|
||||||
Remove-Item $publishDir -Recurse -Force -ErrorAction SilentlyContinue
|
|
||||||
}
|
|
||||||
New-Item -ItemType Directory -Force -Path $publishDir | Out-Null
|
|
||||||
|
|
||||||
$publishArgs = @("publish", $projectPath, "-c", [string]$config.Configuration, "-r", [string]$config.Runtime, "-o", $publishDir)
|
|
||||||
if (-not $selfContained) {
|
|
||||||
$publishArgs += "--self-contained=false"
|
|
||||||
}
|
|
||||||
dotnet @publishArgs
|
|
||||||
|
|
||||||
Write-Host "2) Skipping legacy index.html app-base rewrite (Blazor frontend)." -ForegroundColor Cyan
|
|
||||||
|
|
||||||
if ($recycleAppPool) {
|
|
||||||
Require-ConfigValue $config "AppPoolName"
|
|
||||||
$appPoolName = [string]$config.AppPoolName
|
|
||||||
Write-Host "2) Stopping IIS app pool via WinRM..." -ForegroundColor Cyan
|
|
||||||
try {
|
|
||||||
Invoke-WinRmScript -Config $config -PasswordValue $passwordForWinRm -ScriptBlock {
|
|
||||||
param($poolName)
|
|
||||||
Import-Module WebAdministration
|
|
||||||
Stop-WebAppPool -Name $poolName -ErrorAction SilentlyContinue
|
|
||||||
Get-Process GameList -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
|
|
||||||
Get-Process dotnet -ErrorAction SilentlyContinue | Where-Object { $_.Path -like "*picknplay*" } | Stop-Process -Force -ErrorAction SilentlyContinue
|
|
||||||
} -ArgumentList @($appPoolName)
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
Write-Warning "WinRM stop failed: $($_.Exception.Message)."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host "3) Syncing via WinSCP..." -ForegroundColor Cyan
|
|
||||||
$openCommand = if ($useStoredSession) {
|
|
||||||
"open `"$winScpSessionName`""
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
$ftpUser = [Uri]::EscapeDataString([string]$config.FtpUser)
|
|
||||||
$ftpPassword = [Uri]::EscapeDataString($passwordForSession.Replace("`n", "").Replace("`r", ""))
|
|
||||||
$ftpHost = [string]$config.FtpHost
|
|
||||||
"open ftp://$ftpUser`:$ftpPassword@$ftpHost"
|
|
||||||
}
|
|
||||||
|
|
||||||
$tempScript = New-TemporaryFile
|
|
||||||
@(
|
|
||||||
"option batch continue"
|
|
||||||
"option confirm off"
|
|
||||||
$openCommand
|
|
||||||
"lcd `"$publishDir`""
|
|
||||||
"cd $([string]$config.RemoteDir)"
|
|
||||||
"synchronize remote . -delete -filemask=`"|web.config;App_Data/;logs/;GameList.Tests/`""
|
|
||||||
"exit"
|
|
||||||
) | Set-Content -Path $tempScript -Encoding UTF8
|
|
||||||
|
|
||||||
& $winScpPath "/ini=nul" "/script=$tempScript"
|
|
||||||
Remove-Item $tempScript -ErrorAction SilentlyContinue
|
|
||||||
|
|
||||||
if ($recycleAppPool) {
|
|
||||||
Write-Host "4) Starting IIS app pool via WinRM..." -ForegroundColor Cyan
|
|
||||||
try {
|
|
||||||
Invoke-WinRmScript -Config $config -PasswordValue $passwordForWinRm -ScriptBlock {
|
|
||||||
param($poolName)
|
|
||||||
Import-Module WebAdministration
|
|
||||||
Start-WebAppPool -Name $poolName
|
|
||||||
} -ArgumentList @($appPoolName)
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
Write-Warning "WinRM start failed: $($_.Exception.Message)."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($runEfMigrations) {
|
|
||||||
Require-ConfigValue $config "RemoteSitePath"
|
|
||||||
Write-Host "5) Running EF Core migrations on remote site..." -ForegroundColor Cyan
|
|
||||||
try {
|
|
||||||
Invoke-WinRmScript -Config $config -PasswordValue $passwordForWinRm -ScriptBlock {
|
|
||||||
param($sitePath)
|
|
||||||
Set-Location $sitePath
|
|
||||||
if (-not (Get-Command dotnet -ErrorAction SilentlyContinue)) {
|
|
||||||
throw "dotnet is not available on remote host."
|
|
||||||
}
|
|
||||||
|
|
||||||
dotnet ef database update --no-build
|
|
||||||
} -ArgumentList @([string]$config.RemoteSitePath)
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
Write-Warning "WinRM migrations failed: $($_.Exception.Message)."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host "Done." -ForegroundColor Green
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
param(
|
|
||||||
[string]$ProfilePath = (Join-Path $PSScriptRoot "deploy-ftp.profile.psd1"),
|
|
||||||
[string]$Password,
|
|
||||||
[switch]$SkipRecycle,
|
|
||||||
[switch]$SkipMigrations
|
|
||||||
)
|
|
||||||
|
|
||||||
$scriptPath = Join-Path $PSScriptRoot "deploy-ftp.ps1"
|
|
||||||
|
|
||||||
& $scriptPath `
|
|
||||||
-ProfilePath $ProfilePath `
|
|
||||||
-Password $Password `
|
|
||||||
-SkipRecycle:$SkipRecycle `
|
|
||||||
-SkipMigrations:$SkipMigrations
|
|
||||||
183
scripts/deploy.ps1
Normal file
183
scripts/deploy.ps1
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
Set-StrictMode -Version Latest
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
function Require-Tool {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)][string]$Name
|
||||||
|
)
|
||||||
|
|
||||||
|
if ($null -eq (Get-Command $Name -ErrorAction SilentlyContinue)) {
|
||||||
|
throw "Required tool not found: $Name"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-NativeCommand {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)][string]$Name,
|
||||||
|
[Parameter(Mandatory = $true)][scriptblock]$Action
|
||||||
|
)
|
||||||
|
|
||||||
|
Write-Host $Name
|
||||||
|
& $Action
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "Step failed: $Name"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConvertTo-LinuxLineEnding {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)][string]$Value
|
||||||
|
)
|
||||||
|
|
||||||
|
return $Value.Replace("`r`n", "`n").Replace("`r", "`n")
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-RemoteScript {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)][string]$RemoteReleaseDir,
|
||||||
|
[Parameter(Mandatory = $true)][string]$RemoteCurrentLink,
|
||||||
|
[Parameter(Mandatory = $true)][string]$ContainerName,
|
||||||
|
[Parameter(Mandatory = $true)][string]$ImageName,
|
||||||
|
[Parameter(Mandatory = $true)][string]$ReleaseTimestamp,
|
||||||
|
[Parameter(Mandatory = $true)][string]$RemoteDataDir,
|
||||||
|
[Parameter(Mandatory = $true)][string]$ContainerPort,
|
||||||
|
[Parameter(Mandatory = $true)][string]$HostPort
|
||||||
|
)
|
||||||
|
|
||||||
|
$script = @'
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
remote_release_dir='__REMOTE_RELEASE_DIR__'
|
||||||
|
remote_current_link='__REMOTE_CURRENT_LINK__'
|
||||||
|
container_name='__CONTAINER_NAME__'
|
||||||
|
image_name='__IMAGE_NAME__'
|
||||||
|
release_timestamp='__RELEASE_TIMESTAMP__'
|
||||||
|
remote_data_dir='__REMOTE_DATA_DIR__'
|
||||||
|
container_port='__CONTAINER_PORT__'
|
||||||
|
host_port='__HOST_PORT__'
|
||||||
|
|
||||||
|
previous_current_target=''
|
||||||
|
if [ -L "${remote_current_link}" ]; then
|
||||||
|
previous_current_target="$(readlink -f "${remote_current_link}")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker build -t "${image_name}:${release_timestamp}" -t "${image_name}:latest" "${remote_release_dir}"
|
||||||
|
ln -sfn "${remote_release_dir}" "${remote_current_link}"
|
||||||
|
|
||||||
|
if docker ps -aq --filter "name=^/${container_name}$" | grep -q .; then
|
||||||
|
docker rm -f "${container_name}" >/dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! docker run -d \
|
||||||
|
--name "${container_name}" \
|
||||||
|
--restart unless-stopped \
|
||||||
|
-p "127.0.0.1:${host_port}:${container_port}" \
|
||||||
|
-e ASPNETCORE_ENVIRONMENT=Production \
|
||||||
|
-e ASPNETCORE_URLS="http://+:${container_port}" \
|
||||||
|
-e ConnectionStrings__RpgRoller="Data Source=/app/data/rpgroller.db" \
|
||||||
|
-v "${remote_data_dir}:/app/data" \
|
||||||
|
"${image_name}:${release_timestamp}" >/dev/null; then
|
||||||
|
if [ -n "${previous_current_target}" ]; then
|
||||||
|
ln -sfn "${previous_current_target}" "${remote_current_link}"
|
||||||
|
fi
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
'@
|
||||||
|
|
||||||
|
$script = $script.
|
||||||
|
Replace("__REMOTE_RELEASE_DIR__", $RemoteReleaseDir).
|
||||||
|
Replace("__REMOTE_CURRENT_LINK__", $RemoteCurrentLink).
|
||||||
|
Replace("__CONTAINER_NAME__", $ContainerName).
|
||||||
|
Replace("__IMAGE_NAME__", $ImageName).
|
||||||
|
Replace("__RELEASE_TIMESTAMP__", $ReleaseTimestamp).
|
||||||
|
Replace("__REMOTE_DATA_DIR__", $RemoteDataDir).
|
||||||
|
Replace("__CONTAINER_PORT__", $ContainerPort).
|
||||||
|
Replace("__HOST_PORT__", $HostPort)
|
||||||
|
|
||||||
|
return ConvertTo-LinuxLineEnding -Value $script
|
||||||
|
}
|
||||||
|
|
||||||
|
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
$repoRoot = Split-Path -Parent $scriptDir
|
||||||
|
$projectPath = Join-Path $repoRoot "RpgRoller\RpgRoller.csproj"
|
||||||
|
|
||||||
|
$remoteHost = "myvserver"
|
||||||
|
$remoteRoot = "/root/docker/rpgroller"
|
||||||
|
$remoteReleasesDir = "$remoteRoot/releases"
|
||||||
|
$remoteCurrentLink = "$remoteRoot/current"
|
||||||
|
$remoteDataDir = "$remoteRoot/data"
|
||||||
|
|
||||||
|
$containerName = "rpgroller"
|
||||||
|
$imageName = "rpgroller"
|
||||||
|
$containerPort = "8080"
|
||||||
|
$hostPort = "8082"
|
||||||
|
$releaseTimestamp = (Get-Date).ToUniversalTime().ToString("yyyyMMddHHmmss")
|
||||||
|
$localStageDir = Join-Path $repoRoot "artifacts\deploy\$releaseTimestamp"
|
||||||
|
$localPublishDir = Join-Path $localStageDir "publish"
|
||||||
|
$remoteReleaseDir = "$remoteReleasesDir/$releaseTimestamp"
|
||||||
|
|
||||||
|
Write-Host "Deploying release $releaseTimestamp"
|
||||||
|
|
||||||
|
Require-Tool -Name "dotnet"
|
||||||
|
Require-Tool -Name "ssh"
|
||||||
|
Require-Tool -Name "scp"
|
||||||
|
|
||||||
|
try {
|
||||||
|
New-Item -ItemType Directory -Path $localPublishDir -Force | Out-Null
|
||||||
|
|
||||||
|
Invoke-NativeCommand -Name "1) Publishing app locally..." -Action {
|
||||||
|
dotnet publish $projectPath -c Release -o $localPublishDir
|
||||||
|
}
|
||||||
|
|
||||||
|
$dockerfile = @'
|
||||||
|
FROM mcr.microsoft.com/dotnet/aspnet:10.0
|
||||||
|
WORKDIR /app
|
||||||
|
ENV ASPNETCORE_URLS=http://+:8080
|
||||||
|
ENV DOTNET_EnableDiagnostics=0
|
||||||
|
EXPOSE 8080
|
||||||
|
COPY publish/ ./
|
||||||
|
RUN mkdir -p /app/data
|
||||||
|
ENTRYPOINT ["dotnet", "RpgRoller.dll"]
|
||||||
|
'@
|
||||||
|
|
||||||
|
ConvertTo-LinuxLineEnding -Value $dockerfile |
|
||||||
|
Set-Content -Path (Join-Path $localStageDir "Dockerfile") -NoNewline
|
||||||
|
|
||||||
|
$remoteScript = Get-RemoteScript `
|
||||||
|
-RemoteReleaseDir $remoteReleaseDir `
|
||||||
|
-RemoteCurrentLink $remoteCurrentLink `
|
||||||
|
-ContainerName $containerName `
|
||||||
|
-ImageName $imageName `
|
||||||
|
-ReleaseTimestamp $releaseTimestamp `
|
||||||
|
-RemoteDataDir $remoteDataDir `
|
||||||
|
-ContainerPort $containerPort `
|
||||||
|
-HostPort $hostPort
|
||||||
|
|
||||||
|
ConvertTo-LinuxLineEnding -Value $remoteScript |
|
||||||
|
Set-Content -Path (Join-Path $localStageDir "deploy-remote.sh") -NoNewline
|
||||||
|
|
||||||
|
Invoke-NativeCommand -Name "2) Preparing remote release directory..." -Action {
|
||||||
|
ssh $remoteHost "mkdir -p '$remoteReleasesDir' '$remoteDataDir' && test ! -e '$remoteReleaseDir' && mkdir -p '$remoteReleaseDir'"
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-NativeCommand -Name "3) Uploading release payload..." -Action {
|
||||||
|
Push-Location $localStageDir
|
||||||
|
try {
|
||||||
|
scp -r "Dockerfile" "deploy-remote.sh" "publish" "${remoteHost}:$remoteReleaseDir/"
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-NativeCommand -Name "4) Building image and restarting container on remote host..." -Action {
|
||||||
|
ssh $remoteHost "bash '$remoteReleaseDir/deploy-remote.sh'"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "5) Deployment complete."
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
if (Test-Path $localStageDir) {
|
||||||
|
Remove-Item -Path $localStageDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user