Add skill groups and GM character owner transfer across stack
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# Agent Guide
|
||||
|
||||
Also see the other related technical documentation: TECH.md, REQUIREMENTS.md and possibly other markdown files.
|
||||
Also see the other related technical documentation: README.md.
|
||||
|
||||
## Rules
|
||||
|
||||
|
||||
121
FAQ.md
121
FAQ.md
@@ -1,121 +0,0 @@
|
||||
# FAQ
|
||||
|
||||
## Does this project still require npm/frontend TypeScript tooling?
|
||||
|
||||
No. The legacy TypeScript frontend pipeline was removed after the Blazor rewrite.
|
||||
`scripts/ci-local.ps1` is now a .NET-only flow (restore, build, tests, coverage checks).
|
||||
|
||||
## Is frontend JavaScript handwritten?
|
||||
|
||||
The runtime UI is now built with Blazor components (`RpgRoller/Components/*`).
|
||||
|
||||
There is still small handwritten JavaScript in `RpgRoller/wwwroot/js/rpgroller-api.js` for:
|
||||
|
||||
- browser `fetch` calls with cookie auth
|
||||
- SSE connection/reconnect handling
|
||||
- per-tab session storage helpers used by the Blazor UI
|
||||
|
||||
There is no TypeScript runtime frontend in the current codebase.
|
||||
|
||||
## Where is backend state stored locally?
|
||||
|
||||
Backend state is persisted via EF Core + SQLite.
|
||||
|
||||
- Development default: `RpgRoller/App_Data/rpgroller.development.db`
|
||||
- Non-development default: `RpgRoller/App_Data/rpgroller.db`
|
||||
|
||||
To start with a clean backend state, stop the app and remove the corresponding SQLite file.
|
||||
|
||||
## Do I need to run manual DB migrations after updating the app?
|
||||
|
||||
Usually no. Startup now uses EF Core migration-based schema upgrades (`Database.Migrate`) and applies pending migrations automatically.
|
||||
|
||||
## How do I add a new EF migration in this repo?
|
||||
|
||||
Use the repo-local tool manifest:
|
||||
|
||||
```powershell
|
||||
dotnet tool restore
|
||||
dotnet dotnet-ef migrations add <MigrationName> --project RpgRoller/RpgRoller.csproj --startup-project RpgRoller/RpgRoller.csproj
|
||||
```
|
||||
|
||||
## Does the backend read SQLite on every API call?
|
||||
|
||||
No. The backend loads state from SQLite once during startup into in-memory state and serves requests from memory. Successful state mutations are then written back to SQLite.
|
||||
|
||||
## Where is the frontend UX plan documented?
|
||||
|
||||
The canonical frontend UX design lives in `UX.md` at the repository root. It defines roles, flows, screen behavior, validation/error handling, responsive behavior, and real-time update expectations to guide implementation.
|
||||
|
||||
## What does test coverage include?
|
||||
|
||||
Coverage now includes the entire backend project (`RpgRoller`), including API/hosting/bootstrap code and services. It is no longer restricted to `RpgRoller.Services.*`.
|
||||
|
||||
## Why do backend services avoid API request DTO dependencies?
|
||||
|
||||
Service workflows accept explicit parameters (for example, `CreateCampaign(sessionToken, name, rulesetId)`) instead of API request DTOs. This keeps the service layer independent from HTTP transport contracts while avoiding extra service-only wrapper command types.
|
||||
|
||||
## How do d6 wild dice and fumbles work now?
|
||||
|
||||
d6 skills now store two explicit options:
|
||||
|
||||
- `wildDice`: number of wild dice for the skill
|
||||
- `allowFumble`: whether wild dice rolling `1` can trigger fumble removal
|
||||
|
||||
Roll responses and campaign log entries include per-die state flags (`crit`, `fumble`, `wild`, `removed`, `added`) so the frontend can render the full die-by-die outcome, not just the final total.
|
||||
|
||||
## How is the active character chosen in the Play screen?
|
||||
|
||||
There is no separate activate button in Play. The selected character in the character picker is treated as active context and the UI syncs that choice to the backend for owned characters.
|
||||
|
||||
## Where did the Home page logic move after the refactor?
|
||||
|
||||
`Home.razor` + `Home.razor.cs` now act as a small gateway that switches between loading, anonymous auth, and workspace views.
|
||||
Authenticated application state and behavior were moved into `Components/Pages/Workspace.razor`, while reusable concern UI remains under `Components/Pages/HomeControls/`.
|
||||
|
||||
## Why does the workspace header sometimes show "Loading user..." briefly?
|
||||
|
||||
`Workspace` initializes authenticated session data after the first render (`OnAfterRenderAsync`). During that first render pass, the header now intentionally shows a null-safe fallback label instead of dereferencing user fields before `/api/me` has been loaded.
|
||||
|
||||
## Where did workspace success/error messages go?
|
||||
|
||||
Inline status message rows in the workspace were replaced with timed toast notifications. This keeps the main layout compact while still surfacing operation outcomes.
|
||||
|
||||
## Where did Play/Campaign Management switching move?
|
||||
|
||||
Screen switching is now inside the header hamburger menu. The menu exposes `Play` and `Campaign Management` options while keeping the top bar compact.
|
||||
|
||||
## How do I create, edit, and roll skills in the Play column now?
|
||||
|
||||
Skills now use inline row chip actions:
|
||||
|
||||
- `✎` chip on each skill row to edit that skill
|
||||
- `⚄` chip on each skill row to roll immediately
|
||||
- a final `+` dummy row styled like a skill row to create a new skill
|
||||
|
||||
Roll visibility remains controlled in the skills header row.
|
||||
The selected visibility is remembered per browser tab via session storage.
|
||||
|
||||
## Why was the "Last Roll" card removed from the character panel?
|
||||
|
||||
The character column now prioritizes vertical density. Roll history is represented by the campaign log feed, so the dedicated last-roll card was removed to free space for skills and character context.
|
||||
|
||||
## Why does the character picker scroll horizontally?
|
||||
|
||||
The picker was intentionally compressed into a single compact row to minimize vertical real estate in the Play layout. When many characters exist, it scrolls horizontally instead of growing taller.
|
||||
|
||||
## Why is there empty space below skills in the character panel?
|
||||
|
||||
The panel now keeps card content condensed at the top and lets any remaining height expand below the skills list. This avoids stretched card sections while preserving full-height column behavior.
|
||||
|
||||
## Why is auth form state kept in `AuthSection` instead of `Home`?
|
||||
|
||||
Auth inputs, validation, and submit workflows are transient UI concerns, so they now live in `AuthSection`. `Home` keeps shared session/workspace state and cross-control refresh/orchestration only.
|
||||
|
||||
## Why are there `.razor.cs` files next to Razor components?
|
||||
|
||||
Component behavior was moved out of inline `@code` blocks into code-behind classes so `.razor` files stay markup-focused while state, parameters, handlers, and injected services live in typed C# files.
|
||||
|
||||
## How do I add campaigns and characters in Campaign Management now?
|
||||
|
||||
Campaign creation is launched from a compact `Add campaign` row button that opens a modal form. Character management is directly below the campaign card and uses per-row edit chips plus an `Add character` row button for create flow.
|
||||
@@ -1,49 +0,0 @@
|
||||
# Frontend Rebuild Progress (Blazor)
|
||||
|
||||
Tracking against `UX.md` tasks and decisions.
|
||||
|
||||
## Status Snapshot
|
||||
|
||||
- Branch: `feature/blazor-frontend-rebuild-ux`
|
||||
- Runtime frontend stack: Blazor (`RpgRoller/Components/*`) + browser JS interop (`wwwroot/js/rpgroller-api.js`)
|
||||
- Legacy TypeScript frontend/runtime artifacts: removed
|
||||
- Home was simplified to a minimal gateway (`Loading` / `Anonymous` / `Workspace`) in a single `Home.razor.cs` class.
|
||||
- The authenticated workspace shell/state/behavior was moved to `Components/Pages/Workspace.razor`.
|
||||
- Workspace header user identity rendering is now null-safe during first render (`Loading user...` fallback until `/api/me` loads).
|
||||
- Workspace header was compacted into a single horizontal row with hamburger menu screen switching and link-style logout.
|
||||
- Header alignment was tightened so connection status occupies the growing middle cell and the hamburger menu remains pinned to the right edge.
|
||||
- Workspace status/error feedback moved from inline messages to timed toast notifications.
|
||||
- Concern controls now own their local form state and mutation workflows; the workspace host handles shared cross-control state refresh.
|
||||
- Skill create/edit flow is now owned by `CharacterPanel` (where characters and their skills are presented together).
|
||||
- Skill interactions are now row-local chip actions (edit/roll) with an inline dummy `+` row for create-skill.
|
||||
- Character picker was reduced to a compact single-row horizontal scroller to minimize vertical footprint.
|
||||
- The standalone "Last Roll" panel was removed; campaign log entries are the roll history surface.
|
||||
- Card layout now uses condensed flex stacking (instead of stretching grid tracks), with remaining character-panel height absorbed by a post-skills spacer.
|
||||
- Campaign log now auto-scrolls to the newest entry when new entries arrive.
|
||||
|
||||
## UX Checklist
|
||||
|
||||
| UX area | Status | Notes |
|
||||
|---|---|---|
|
||||
| 9.1 App load + session restore | Implemented | Health check on load, rulesets/session load, unauthorized session reset, API unhealthy retry banner. |
|
||||
| 9.2 Authentication view | Implemented | Register/login cards, required validation, register password length check, server-error display. |
|
||||
| 9.3 Shared authenticated header | Implemented | Compact single-row header with user/campaign context, growing connection-status cell, right-aligned hamburger screen switch, and link-style logout. |
|
||||
| 9.4 Play screen character column | Implemented | Compact character picker, merged character+skills header row, modal edit/create flows, inline per-skill edit/roll chips, d6 skill options (wild/fumble), remembered roll-visibility preference, and no separate last-roll panel. |
|
||||
| 9.5 Play screen log column | Implemented | Chronological feed, private/public badges, private perspective styles (roller vs GM), per-entry dice visualization with die-state flags, local time + ISO tooltip. |
|
||||
| 9.6 Campaign management screen | Implemented | Campaign selector and details are merged into one card, campaign create moved behind an inline `Add campaign` row opening a modal, and character management sits beneath with chip-style edit actions plus an inline `Add character` row. |
|
||||
| 9.7 Tablet/mobile bottom bar | Implemented | `Character` / `Log` panel switch in play screen and per-tab session persistence. |
|
||||
| 10 Validation and error UX | Partially implemented | Required-field and common API errors are mapped; message/code-specific mapping is limited by current API exposing only text messages. |
|
||||
| 11 Empty/loading/disabled states | Implemented | Empty states, skeleton placeholders, mutation button disabling. |
|
||||
| 12 Real-time and sync rules | Implemented | Campaign-scoped SSE subscribe/unsubscribe, reconnect with exponential backoff, manual refresh fallback. |
|
||||
| 13 Accessibility requirements | Partially implemented | Keyboard-friendly controls, labels, focus styling, `aria-live` announcements; screen-reader validation for all flows still needs dedicated accessibility QA. |
|
||||
| 14 Content and copy guidance | Implemented | Direct action labels and corrective error copy used throughout. |
|
||||
| 15 Visual direction | Implemented | Tabletop utility styling, tokenized colors, responsive layout, private/public visual differentiation. |
|
||||
| 17 Next iteration targets: wireframes | Not yet implemented | No separate low-fidelity wireframe artifact added in repo. |
|
||||
| 17 Next iteration targets: component contracts doc | Not yet implemented | Component contract document not yet extracted from implementation. |
|
||||
| 17 Next iteration targets: visual token doc | Not yet implemented | Tokens are implemented in CSS but not yet documented in a dedicated spec file. |
|
||||
|
||||
## Follow-up Candidates
|
||||
|
||||
1. Add explicit machine-readable API error codes to HTTP responses for richer field-level mapping.
|
||||
2. Add automated accessibility checks (focus order, contrast, and screen-reader behavior assertions).
|
||||
3. Document component contracts and visual token references as separate markdown artifacts.
|
||||
33
README.md
33
README.md
@@ -5,8 +5,6 @@ Fresh full-stack starter scaffold:
|
||||
- `RpgRoller/`: ASP.NET Core backend + Blazor frontend host (`Components` + `wwwroot`)
|
||||
- `RpgRoller.Tests/`: xUnit integration-heavy test project
|
||||
- `RpgRoller.sln`: solution used by local CI script
|
||||
- `UX.md`: frontend UX and interaction design specification (pre-implementation baseline)
|
||||
- `FRONTEND_PROGRESS.md`: implementation tracking (`Implemented` / `Partially implemented` / `Not yet implemented`)
|
||||
|
||||
Test layout:
|
||||
|
||||
@@ -46,6 +44,11 @@ Backend state persistence:
|
||||
- Database schema is created/upgraded automatically on startup via EF Core migrations (`Database.Migrate`)
|
||||
- Runtime state is loaded once at startup into memory and written back to SQLite on successful state changes
|
||||
|
||||
Gameplay capabilities now include:
|
||||
|
||||
- Skill groups per character (create, rename, and assign/reassign skills to groups)
|
||||
- GM-driven character owner transfer within campaign management flows
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- .NET SDK 10.0+
|
||||
@@ -96,29 +99,3 @@ dotnet dotnet-ef migrations add <MigrationName> --project RpgRoller/RpgRoller.cs
|
||||
```
|
||||
- Coverage collector scope:
|
||||
- `RpgRoller.Tests/coverlet.runsettings` now measures the full backend assembly (`RpgRoller`), not only service namespace files.
|
||||
|
||||
## Implemented Backend Scope
|
||||
|
||||
- Auth: register, login, logout, current user context
|
||||
- Session cookie: `HttpOnly`, `SameSite=Strict`, `Secure` when served over HTTPS
|
||||
- Rulesets: d6 and dnd5e validation rules
|
||||
- Campaigns: create/list/read
|
||||
- Characters: create/update/activate/current-campaign list
|
||||
- Skills: create/update with ruleset-aware dice expression validation and d6 wild-dice/fumble options
|
||||
- Rolls: public/private skill rolls with append-only campaign log; d6 rolls include wild/crit/fumble/add/remove die-state payloads
|
||||
- State stream: SSE endpoint for campaign version updates
|
||||
|
||||
## Implemented Frontend Scope
|
||||
|
||||
- Blazor-driven UI for:
|
||||
- registration, login, logout
|
||||
- play screen and campaign management screen switch
|
||||
- campaign creation and selection
|
||||
- character create/edit via modal forms, with picker selection treated as active character context
|
||||
- skill create/edit via modal forms including d6 wild dice + allow-fumble controls
|
||||
- public/private rolling and campaign log viewing
|
||||
- die-state visualization in Last Roll and Campaign Log (critical, fumble, wild, removed, added)
|
||||
- responsive play UX:
|
||||
- desktop two-column (character + log)
|
||||
- tablet/mobile panel switching with bottom tab bar (`Character` / `Log`)
|
||||
- SSE-backed live refresh with reconnect status + manual refresh fallback
|
||||
|
||||
308
REQUIREMENTS.md
308
REQUIREMENTS.md
@@ -1,308 +0,0 @@
|
||||
# 1. Stakeholders
|
||||
|
||||
### Primary Actors
|
||||
|
||||
* **Player**
|
||||
|
||||
* Owns and manages characters
|
||||
* Participates in campaigns
|
||||
* Performs skill checks (dice rolls)
|
||||
|
||||
* **Game Master (GM)**
|
||||
|
||||
* Like a player (with regards to owning and managing characters)
|
||||
* Owns and manages campaigns
|
||||
* Oversees gameplay within a campaign
|
||||
* Has visibility into all rolls (including private ones)
|
||||
|
||||
* **System / Platform**
|
||||
|
||||
* Enforces rulesets
|
||||
* Manages state (active character, current campaign)
|
||||
* Stores logs and configurations
|
||||
|
||||
---
|
||||
|
||||
### Secondary Stakeholders
|
||||
|
||||
* **Ruleset Designers / Maintainers**
|
||||
|
||||
* Define dice mechanics (e.g., d6, D&D 5e)
|
||||
* Configure skill roll formulas and constraints
|
||||
|
||||
* **Observers (future optional role)**
|
||||
|
||||
* View campaign logs without participating
|
||||
|
||||
---
|
||||
|
||||
# 2. Core Domain Model (Refined)
|
||||
|
||||
### User
|
||||
|
||||
* Attributes:
|
||||
|
||||
* username (unique)
|
||||
* password (secured)
|
||||
* displayName
|
||||
* Relationships:
|
||||
|
||||
* Owns multiple **Characters**
|
||||
* Owns multiple **Campaigns** (as GM)
|
||||
|
||||
---
|
||||
|
||||
### Campaign
|
||||
|
||||
* Attributes:
|
||||
|
||||
* name
|
||||
* ruleset (exactly one)
|
||||
* Relationships:
|
||||
|
||||
* Owned by one **User (GM)**
|
||||
* Contains multiple **Characters**
|
||||
* Has a **shared log**
|
||||
|
||||
---
|
||||
|
||||
### Ruleset
|
||||
|
||||
* Predefined initially:
|
||||
|
||||
* d6 system
|
||||
* D&D 5e
|
||||
* Defines:
|
||||
|
||||
* Dice notation rules
|
||||
* Skill behavior constraints
|
||||
|
||||
---
|
||||
|
||||
### Character
|
||||
|
||||
* Attributes:
|
||||
|
||||
* name
|
||||
* Relationships:
|
||||
|
||||
* Owned by one **User (Player)**
|
||||
* Belongs to one **Campaign**
|
||||
* Has multiple **Skills**
|
||||
|
||||
---
|
||||
|
||||
### Skill
|
||||
|
||||
* Attributes:
|
||||
|
||||
* name
|
||||
* diceRollDefinition (ruleset-compliant expression, e.g. `5D+4`, `2d12+2`)
|
||||
* wildDice (d6 only; number of wild dice)
|
||||
* allowFumble (d6 only; whether wild-1 fumbles remove dice)
|
||||
* Behavior:
|
||||
|
||||
* Can be rolled
|
||||
* Can be edited by Player or GM
|
||||
|
||||
---
|
||||
|
||||
### Dice Roll
|
||||
|
||||
* Attributes:
|
||||
|
||||
* result
|
||||
* visibility (public | private)
|
||||
* timestamp
|
||||
* Relationships:
|
||||
|
||||
* Linked to a **Skill**
|
||||
* Logged in **Campaign Log**
|
||||
|
||||
---
|
||||
|
||||
### Campaign Log
|
||||
|
||||
* Contains:
|
||||
|
||||
* Chronological list of dice rolls
|
||||
* Visibility:
|
||||
|
||||
* Visible to all users in the campaign
|
||||
* Private rolls visible only to:
|
||||
|
||||
* roller
|
||||
* GM
|
||||
|
||||
---
|
||||
|
||||
# 3. Functional Requirements (Expanded)
|
||||
|
||||
### User Management
|
||||
|
||||
* Users must be able to:
|
||||
|
||||
* Register with username, password, display name
|
||||
* Authenticate securely
|
||||
* System must:
|
||||
|
||||
* Enforce unique usernames
|
||||
* Store passwords securely (hashed)
|
||||
|
||||
---
|
||||
|
||||
### Campaign Management
|
||||
|
||||
* A GM can:
|
||||
|
||||
* Create campaigns
|
||||
* Assign a ruleset to a campaign
|
||||
* Manage participating characters
|
||||
* A campaign:
|
||||
|
||||
* Must always have exactly one GM
|
||||
* Must always use exactly one ruleset
|
||||
|
||||
---
|
||||
|
||||
### Character Management
|
||||
|
||||
* A Player can:
|
||||
|
||||
* Create characters
|
||||
* Assign characters to campaigns
|
||||
* Edit character details
|
||||
* Constraints:
|
||||
|
||||
* A character belongs to exactly one campaign
|
||||
* A player may have multiple characters across campaigns
|
||||
|
||||
---
|
||||
|
||||
### Active Character Context
|
||||
|
||||
* A Player can:
|
||||
|
||||
* Activate one character at a time
|
||||
* System must:
|
||||
|
||||
* Enforce **at most one active character per player**
|
||||
* Derive the **current campaign** from the active character
|
||||
|
||||
---
|
||||
|
||||
### Skill Management
|
||||
|
||||
* Players and GMs can:
|
||||
|
||||
* Create skills for characters
|
||||
* Edit skill definitions
|
||||
* System must:
|
||||
|
||||
* Validate dice expressions against the campaign ruleset
|
||||
* Validate d6 skill options (`wildDice`, `allowFumble`) as part of skill create/edit
|
||||
|
||||
---
|
||||
|
||||
### Dice Rolling
|
||||
|
||||
* Players and GMs can:
|
||||
|
||||
* Roll dice for a skill
|
||||
* Choose visibility:
|
||||
|
||||
* Public → visible to all
|
||||
* Private → visible only to roller + GM
|
||||
* System must:
|
||||
|
||||
* Evaluate dice expressions deterministically and fairly
|
||||
* For d6 skills, apply wild-die explosions and fumble-removal logic
|
||||
* Record all rolls in the campaign log
|
||||
* Return die-by-die roll states so the frontend can visualize critical/fumble/wild/removed/added outcomes
|
||||
|
||||
---
|
||||
|
||||
### Campaign Log
|
||||
|
||||
* System must:
|
||||
|
||||
* Maintain a chronological log of all dice rolls
|
||||
* Users can:
|
||||
|
||||
* View the log of the current campaign
|
||||
* Visibility rules:
|
||||
|
||||
* Public rolls → visible to all participants
|
||||
* Private rolls → restricted to roller + GM
|
||||
|
||||
---
|
||||
|
||||
# 4. User Stories
|
||||
|
||||
### User / Account
|
||||
|
||||
* As a **user**, I want to register with a username and password so that I can access the system.
|
||||
* As a **user**, I want a display name so that others can identify me in campaigns.
|
||||
|
||||
---
|
||||
|
||||
### Campaign (GM)
|
||||
|
||||
* As a **GM**, I want to create a campaign so that I can run a game session.
|
||||
* As a **GM**, I want to activate a campaign so that the system knows my current context.
|
||||
* As a **GM**, I want to select a ruleset so that gameplay follows a defined system.
|
||||
* As a **GM**, I want to manage which characters participate so that I control the session.
|
||||
* As a **GM**, I want to see all characters of my current campaign, including all of their skills.
|
||||
|
||||
---
|
||||
|
||||
### Character (Player)
|
||||
|
||||
* As a **player**, I want to create characters so that I can participate in campaigns.
|
||||
* As a **player**, I want to assign my character to a campaign so that I can join a game.
|
||||
* As a **player**, I want to activate one campaign so that the system knows my current context.
|
||||
* As a **player**, I want to see all my characters of the current current campaign, including all of their skills.
|
||||
|
||||
---
|
||||
|
||||
### Skills
|
||||
|
||||
* As a **player**, I want to define skills with dice formulas so that I can perform actions.
|
||||
* As a **player**, I want to configure wild dice and fumble behavior for d6 skills so the roll follows my table rules.
|
||||
* As a **GM**, I want to edit character skills so that I can enforce or adjust rules.
|
||||
|
||||
---
|
||||
|
||||
### Dice Rolling
|
||||
|
||||
* As a **player**, I want to roll dice for a skill so that I can resolve actions.
|
||||
* As a **player**, I want to see which dice were wild, exploded, fumbled, removed, or added so that I can audit the roll result.
|
||||
* As a **user**, I want to choose whether a roll is public or private so that I can control information visibility.
|
||||
* As a **GM**, I want to see all rolls (including private ones) so that I can oversee the game.
|
||||
|
||||
---
|
||||
|
||||
### Campaign Log
|
||||
|
||||
* As a **user**, I want to see the campaign log so that I can track what happened.
|
||||
* As a **user**, I want the log to update in real time so that I stay synchronized with the session.
|
||||
* As a **user**, I want private rolls hidden unless permitted so that secrecy is preserved.
|
||||
|
||||
---
|
||||
|
||||
# 5. Implicit Constraints & Edge Cases
|
||||
|
||||
* A player cannot:
|
||||
|
||||
* Activate multiple campaigns simultaneously
|
||||
* Join a campaign without a character
|
||||
* A character cannot:
|
||||
|
||||
* Exist without an owner
|
||||
* Belong to multiple campaigns simultaneously
|
||||
* Dice expressions must:
|
||||
|
||||
* Be validated per ruleset (no cross-system syntax leakage)
|
||||
* Log integrity:
|
||||
|
||||
* Must be append-only (no tampering with past rolls)
|
||||
@@ -52,4 +52,47 @@ public sealed class CampaignApiTests : ApiTestBase
|
||||
Assert.Equal("Arin Updated", updatedCharacter.Name);
|
||||
Assert.Equal(otherCampaign.Id, updatedCharacter.CampaignId);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SkillGroupsAndOwnerTransfer_WorkThroughApi()
|
||||
{
|
||||
using var factory = CreateFactory(6, 4, 5, 3);
|
||||
using var gmClient = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
using var ownerClient = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
using var receiverClient = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
|
||||
await RegisterAsync(gmClient, "gm2", "Password123", "GM");
|
||||
await LoginAsync(gmClient, "gm2", "Password123");
|
||||
|
||||
await RegisterAsync(ownerClient, "owner2", "Password123", "Owner");
|
||||
await LoginAsync(ownerClient, "owner2", "Password123");
|
||||
|
||||
await RegisterAsync(receiverClient, "receiver2", "Password123", "Receiver");
|
||||
await LoginAsync(receiverClient, "receiver2", "Password123");
|
||||
|
||||
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns", new("Grouped Campaign", "d6"));
|
||||
var character = await PostAsync<CreateCharacterRequest, CharacterSummary>(ownerClient, "/api/characters", new("Grouped Hero", campaign.Id));
|
||||
|
||||
var createdGroup = await PostAsync<CreateSkillGroupRequest, SkillGroupSummary>(ownerClient, $"/api/characters/{character.Id}/skill-groups", new("Combat"));
|
||||
var renamedGroup = await PutAsync<UpdateSkillGroupRequest, SkillGroupSummary>(gmClient, $"/api/skill-groups/{createdGroup.Id}", new("Battle"));
|
||||
Assert.Equal("Battle", renamedGroup.Name);
|
||||
|
||||
var groupedSkill = await PostAsync<CreateSkillRequest, SkillSummary>(ownerClient, $"/api/characters/{character.Id}/skills", new("Strike", "2D+1", 1, true, renamedGroup.Id));
|
||||
Assert.Equal(renamedGroup.Id, groupedSkill.SkillGroupId);
|
||||
|
||||
var ungroupedSkill = await PutAsync<UpdateSkillRequest, SkillSummary>(ownerClient, $"/api/skills/{groupedSkill.Id}", new("Strike", "2D+1", 1, true, null));
|
||||
Assert.Null(ungroupedSkill.SkillGroupId);
|
||||
|
||||
var transferResult = await PutAsync<UpdateCharacterRequest, CharacterSummary>(gmClient, $"/api/characters/{character.Id}", new("Grouped Hero", campaign.Id, "receiver2"));
|
||||
Assert.Equal("Grouped Hero", transferResult.Name);
|
||||
|
||||
var ownerActivate = await ownerClient.PostAsync($"/api/characters/{character.Id}/activate", null);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, ownerActivate.StatusCode);
|
||||
|
||||
var receiverActivate = await receiverClient.PostAsync($"/api/characters/{character.Id}/activate", null);
|
||||
Assert.Equal(HttpStatusCode.OK, receiverActivate.StatusCode);
|
||||
|
||||
var details = await GetAsync<CampaignDetails>(gmClient, $"/api/campaigns/{campaign.Id}");
|
||||
Assert.Contains(details.SkillGroups, group => group.Id == renamedGroup.Id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
namespace RpgRoller.Tests;
|
||||
|
||||
// Service-level tests were split by concern under RpgRoller.Tests/Services.
|
||||
@@ -0,0 +1,79 @@
|
||||
namespace RpgRoller.Tests;
|
||||
|
||||
public sealed class ServiceSkillGroupAndOwnershipTests
|
||||
{
|
||||
[Fact]
|
||||
public void SkillGroups_CanBeManagedAndAssignedWithinCharacterScope()
|
||||
{
|
||||
using var harness = ServiceTestSupport.CreateHarness(4, 3, 2);
|
||||
var service = harness.Service;
|
||||
|
||||
service.Register("gm", "Password123", "GM");
|
||||
service.Register("owner", "Password123", "Owner");
|
||||
service.Register("other", "Password123", "Other");
|
||||
|
||||
var gmSession = ServiceTestSupport.GetValue(service.Login("gm", "Password123")).SessionToken;
|
||||
var ownerSession = ServiceTestSupport.GetValue(service.Login("owner", "Password123")).SessionToken;
|
||||
var otherSession = ServiceTestSupport.GetValue(service.Login("other", "Password123")).SessionToken;
|
||||
|
||||
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Main", "d6"));
|
||||
var ownerCharacter = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Owner Char", campaign.Id));
|
||||
var otherCharacter = ServiceTestSupport.GetValue(service.CreateCharacter(otherSession, "Other Char", campaign.Id));
|
||||
|
||||
var ownerGroup = ServiceTestSupport.GetValue(service.CreateSkillGroup(ownerSession, ownerCharacter.Id, "Combat"));
|
||||
Assert.False(service.CreateSkillGroup(ownerSession, otherCharacter.Id, "Not Allowed").Succeeded);
|
||||
|
||||
Assert.False(service.UpdateSkillGroup(otherSession, ownerGroup.Id, "Renamed by Other").Succeeded);
|
||||
var renamedGroup = ServiceTestSupport.GetValue(service.UpdateSkillGroup(gmSession, ownerGroup.Id, "Battle"));
|
||||
Assert.Equal("Battle", renamedGroup.Name);
|
||||
|
||||
var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, ownerCharacter.Id, "Strike", "2D+1", 1, true, renamedGroup.Id));
|
||||
Assert.Equal(renamedGroup.Id, skill.SkillGroupId);
|
||||
|
||||
var otherGroup = ServiceTestSupport.GetValue(service.CreateSkillGroup(otherSession, otherCharacter.Id, "Other Group"));
|
||||
Assert.False(service.UpdateSkill(ownerSession, skill.Id, "Strike", "2D+1", 1, true, otherGroup.Id).Succeeded);
|
||||
|
||||
var ungroupedSkill = ServiceTestSupport.GetValue(service.UpdateSkill(ownerSession, skill.Id, "Strike", "2D+1", 1, true, null));
|
||||
Assert.Null(ungroupedSkill.SkillGroupId);
|
||||
|
||||
var ownerView = ServiceTestSupport.GetValue(service.GetCampaign(ownerSession, campaign.Id));
|
||||
Assert.Single(ownerView.SkillGroups);
|
||||
Assert.Equal(ownerCharacter.Id, ownerView.SkillGroups[0].CharacterId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CharacterOwnerTransfer_RequiresGmPrivileges()
|
||||
{
|
||||
using var harness = ServiceTestSupport.CreateHarness();
|
||||
var service = harness.Service;
|
||||
|
||||
service.Register("gm", "Password123", "GM");
|
||||
service.Register("owner", "Password123", "Owner");
|
||||
service.Register("receiver", "Password123", "Receiver");
|
||||
|
||||
var gmSession = ServiceTestSupport.GetValue(service.Login("gm", "Password123")).SessionToken;
|
||||
var ownerSession = ServiceTestSupport.GetValue(service.Login("owner", "Password123")).SessionToken;
|
||||
var receiverSession = ServiceTestSupport.GetValue(service.Login("receiver", "Password123")).SessionToken;
|
||||
|
||||
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Main", "d6"));
|
||||
var character = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Transfer Me", campaign.Id));
|
||||
Assert.True(service.ActivateCharacter(ownerSession, character.Id).Succeeded);
|
||||
|
||||
var ownerTransferAttempt = service.UpdateCharacter(ownerSession, character.Id, "Transfer Me", campaign.Id, "receiver");
|
||||
Assert.False(ownerTransferAttempt.Succeeded);
|
||||
|
||||
var missingOwnerAttempt = service.UpdateCharacter(gmSession, character.Id, "Transfer Me", campaign.Id, "missing-user");
|
||||
Assert.False(missingOwnerAttempt.Succeeded);
|
||||
|
||||
var gmTransfer = ServiceTestSupport.GetValue(service.UpdateCharacter(gmSession, character.Id, "Transferred", campaign.Id, "receiver"));
|
||||
var receiver = service.GetUserBySession(receiverSession);
|
||||
Assert.NotNull(receiver);
|
||||
Assert.Equal(receiver!.Id, gmTransfer.OwnerUserId);
|
||||
|
||||
var previousOwnerMe = ServiceTestSupport.GetValue(service.GetMe(ownerSession));
|
||||
Assert.Null(previousOwnerMe.ActiveCharacterId);
|
||||
|
||||
Assert.False(service.ActivateCharacter(ownerSession, character.Id).Succeeded);
|
||||
Assert.True(service.ActivateCharacter(receiverSession, character.Id).Succeeded);
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
<Solution>
|
||||
</Solution>
|
||||
@@ -15,7 +15,7 @@ internal static class CharacterEndpoints
|
||||
|
||||
group.MapPut("/characters/{characterId:guid}", (Guid characterId, UpdateCharacterRequest request, HttpContext context, IGameService game) =>
|
||||
{
|
||||
var result = game.UpdateCharacter(context.GetRequiredSessionToken(), characterId, request.Name, request.CampaignId);
|
||||
var result = game.UpdateCharacter(context.GetRequiredSessionToken(), characterId, request.Name, request.CampaignId, request.OwnerUsername);
|
||||
return ApiResultMapper.ToApiResult(result);
|
||||
});
|
||||
|
||||
@@ -33,4 +33,4 @@ internal static class CharacterEndpoints
|
||||
|
||||
return group;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,13 +9,25 @@ internal static class SkillEndpoints
|
||||
{
|
||||
group.MapPost("/characters/{characterId:guid}/skills", (Guid characterId, CreateSkillRequest request, HttpContext context, IGameService game) =>
|
||||
{
|
||||
var result = game.CreateSkill(context.GetRequiredSessionToken(), characterId, request.Name, request.DiceRollDefinition, request.WildDice, request.AllowFumble);
|
||||
var result = game.CreateSkill(context.GetRequiredSessionToken(), characterId, request.Name, request.DiceRollDefinition, request.WildDice, request.AllowFumble, request.SkillGroupId);
|
||||
return ApiResultMapper.ToApiResult(result);
|
||||
});
|
||||
|
||||
group.MapPut("/skills/{skillId:guid}", (Guid skillId, UpdateSkillRequest request, HttpContext context, IGameService game) =>
|
||||
{
|
||||
var result = game.UpdateSkill(context.GetRequiredSessionToken(), skillId, request.Name, request.DiceRollDefinition, request.WildDice, request.AllowFumble);
|
||||
var result = game.UpdateSkill(context.GetRequiredSessionToken(), skillId, request.Name, request.DiceRollDefinition, request.WildDice, request.AllowFumble, request.SkillGroupId);
|
||||
return ApiResultMapper.ToApiResult(result);
|
||||
});
|
||||
|
||||
group.MapPost("/characters/{characterId:guid}/skill-groups", (Guid characterId, CreateSkillGroupRequest request, HttpContext context, IGameService game) =>
|
||||
{
|
||||
var result = game.CreateSkillGroup(context.GetRequiredSessionToken(), characterId, request.Name);
|
||||
return ApiResultMapper.ToApiResult(result);
|
||||
});
|
||||
|
||||
group.MapPut("/skill-groups/{skillGroupId:guid}", (Guid skillGroupId, UpdateSkillGroupRequest request, HttpContext context, IGameService game) =>
|
||||
{
|
||||
var result = game.UpdateSkillGroup(context.GetRequiredSessionToken(), skillGroupId, request.Name);
|
||||
return ApiResultMapper.ToApiResult(result);
|
||||
});
|
||||
|
||||
@@ -27,4 +39,4 @@ internal static class SkillEndpoints
|
||||
|
||||
return group;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,19 +36,26 @@ public sealed class CharacterFormModel
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string CampaignId { get; set; } = string.Empty;
|
||||
public string OwnerUsername { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class SkillFormModel
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string DiceRollDefinition { get; set; } = string.Empty;
|
||||
public string SkillGroupId { get; set; } = string.Empty;
|
||||
public int WildDice { get; set; }
|
||||
public bool AllowFumble { get; set; }
|
||||
}
|
||||
|
||||
public sealed class SkillGroupFormModel
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public enum HomeViewMode
|
||||
{
|
||||
Loading,
|
||||
Anonymous,
|
||||
Workspace
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,15 @@
|
||||
{
|
||||
<p class="field-error">@campaignError</p>
|
||||
}
|
||||
@if (AllowOwnerEdit)
|
||||
{
|
||||
<label for="@OwnerUsernameInputId">Owner username</label>
|
||||
<input id="@OwnerUsernameInputId" @bind="FormState.Model.OwnerUsername" @bind:event="oninput" placeholder="Leave empty to keep current owner"/>
|
||||
@if (FormState.Errors.TryGetValue("ownerUsername", out var ownerUsernameError))
|
||||
{
|
||||
<p class="field-error">@ownerUsernameError</p>
|
||||
}
|
||||
}
|
||||
<div class="inline-actions">
|
||||
<button type="submit" disabled="@(IsMutating || IsSubmitting)">@SubmitLabel</button>
|
||||
<button type="button" class="ghost" @onclick="CancelRequested">Cancel</button>
|
||||
|
||||
@@ -14,6 +14,7 @@ public partial class CharacterFormModal
|
||||
|
||||
FormState.Model.Name = InitialModel.Name;
|
||||
FormState.Model.CampaignId = InitialModel.CampaignId;
|
||||
FormState.Model.OwnerUsername = InitialModel.OwnerUsername;
|
||||
FormState.ResetValidation();
|
||||
AppliedFormVersion = FormVersion;
|
||||
}
|
||||
@@ -40,7 +41,8 @@ public partial class CharacterFormModal
|
||||
CharacterSummary character;
|
||||
if (EditingCharacterId.HasValue)
|
||||
{
|
||||
character = await ApiClient.RequestAsync<CharacterSummary>("PUT", $"/api/characters/{EditingCharacterId.Value}", new UpdateCharacterRequest(FormState.Model.Name.Trim(), campaignId));
|
||||
var ownerUsername = AllowOwnerEdit && !string.IsNullOrWhiteSpace(FormState.Model.OwnerUsername) ? FormState.Model.OwnerUsername.Trim() : null;
|
||||
character = await ApiClient.RequestAsync<CharacterSummary>("PUT", $"/api/characters/{EditingCharacterId.Value}", new UpdateCharacterRequest(FormState.Model.Name.Trim(), campaignId, ownerUsername));
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -81,6 +83,9 @@ public partial class CharacterFormModal
|
||||
[Parameter]
|
||||
public string CampaignInputId { get; set; } = "character-campaign";
|
||||
|
||||
[Parameter]
|
||||
public string OwnerUsernameInputId { get; set; } = "character-owner-username";
|
||||
|
||||
[Parameter]
|
||||
public CharacterFormModel InitialModel { get; set; } = new();
|
||||
|
||||
@@ -96,9 +101,12 @@ public partial class CharacterFormModal
|
||||
[Parameter]
|
||||
public bool IsMutating { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public bool AllowOwnerEdit { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<Guid> CharacterSaved { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback CancelRequested { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,14 @@
|
||||
class="muted">| Owner: @OwnerLabel(SelectedCharacter.OwnerUserId) | Campaign: @SelectedCampaign.Name</span>
|
||||
</h3>
|
||||
<div class="chip-toolbar">
|
||||
<button type="button"
|
||||
class="chip-button"
|
||||
title="Add skill group"
|
||||
disabled="@(IsMutating || !CanEditCharacter(SelectedCharacter))"
|
||||
@onclick="OpenCreateSkillGroupModal">
|
||||
<span aria-hidden="true">+</span>
|
||||
<span class="sr-only">Add skill group</span>
|
||||
</button>
|
||||
<label class="visibility-control" for="roll-visibility">Visibility</label>
|
||||
<select id="roll-visibility" value="@(RollVisibility == "private" ? "private" : "public")" @onchange="OnRollVisibilityChangedAsync">
|
||||
<option value="public">Public</option>
|
||||
@@ -52,40 +60,107 @@
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@if (SelectedCharacterSkills.Count == 0)
|
||||
@{
|
||||
var orderedSkillGroups = SelectedCharacterSkillGroups.OrderBy(group => group.Name).ToList();
|
||||
var ungroupedSkills = SelectedCharacterSkills.Where(skill => !skill.SkillGroupId.HasValue).ToList();
|
||||
}
|
||||
@if (SelectedCharacterSkills.Count == 0 && orderedSkillGroups.Count == 0)
|
||||
{
|
||||
<p class="empty">No skills for this character yet.</p>
|
||||
}
|
||||
<div class="skill-list">
|
||||
@foreach (var skill in SelectedCharacterSkills)
|
||||
{
|
||||
<div class="skill-item">
|
||||
<div class="skill-details">
|
||||
<strong>@skill.Name</strong>
|
||||
<span>@SkillDefinitionLabel(skill)</span>
|
||||
</div>
|
||||
<div class="skill-chip-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="chip-button"
|
||||
title="Edit skill"
|
||||
disabled="@(IsMutating || !CanEditSkill(skill))"
|
||||
@onclick="() => OpenEditSkillModal(skill)">
|
||||
<span aria-hidden="true">✎</span>
|
||||
<span class="sr-only">Edit @skill.Name</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="chip-button"
|
||||
title="Roll skill"
|
||||
disabled="@(IsMutating || !CanRollSkill(skill))"
|
||||
@onclick="() => RollSkillAsync(skill.Id)">
|
||||
<span aria-hidden="true">⚄</span>
|
||||
<span class="sr-only">Roll @skill.Name</span>
|
||||
</button>
|
||||
</div>
|
||||
@foreach (var group in orderedSkillGroups)
|
||||
{
|
||||
var groupSkills = SelectedCharacterSkills.Where(skill => skill.SkillGroupId == group.Id).ToList();
|
||||
<div class="skill-group-block">
|
||||
<div class="skill-group-head">
|
||||
<strong>@group.Name</strong>
|
||||
<button
|
||||
type="button"
|
||||
class="chip-button"
|
||||
title="Rename skill group"
|
||||
disabled="@(IsMutating || !CanEditCharacter(SelectedCharacter))"
|
||||
@onclick="() => OpenEditSkillGroupModal(group)">
|
||||
<span aria-hidden="true">✎</span>
|
||||
<span class="sr-only">Rename @group.Name</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@if (groupSkills.Count == 0)
|
||||
{
|
||||
<p class="empty">No skills in this group yet.</p>
|
||||
}
|
||||
<div class="skill-list">
|
||||
@foreach (var skill in groupSkills)
|
||||
{
|
||||
<div class="skill-item">
|
||||
<div class="skill-details">
|
||||
<strong>@skill.Name</strong>
|
||||
<span>@SkillDefinitionLabel(skill)</span>
|
||||
</div>
|
||||
<div class="skill-chip-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="chip-button"
|
||||
title="Edit skill"
|
||||
disabled="@(IsMutating || !CanEditSkill(skill))"
|
||||
@onclick="() => OpenEditSkillModal(skill)">
|
||||
<span aria-hidden="true">✎</span>
|
||||
<span class="sr-only">Edit @skill.Name</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="chip-button"
|
||||
title="Roll skill"
|
||||
disabled="@(IsMutating || !CanRollSkill(skill))"
|
||||
@onclick="() => RollSkillAsync(skill.Id)">
|
||||
<span aria-hidden="true">⚄</span>
|
||||
<span class="sr-only">Roll @skill.Name</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (ungroupedSkills.Count > 0)
|
||||
{
|
||||
<div class="skill-group-block">
|
||||
<div class="skill-group-head">
|
||||
<strong>Ungrouped</strong>
|
||||
</div>
|
||||
<div class="skill-list">
|
||||
@foreach (var skill in ungroupedSkills)
|
||||
{
|
||||
<div class="skill-item">
|
||||
<div class="skill-details">
|
||||
<strong>@skill.Name</strong>
|
||||
<span>@SkillDefinitionLabel(skill)</span>
|
||||
</div>
|
||||
<div class="skill-chip-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="chip-button"
|
||||
title="Edit skill"
|
||||
disabled="@(IsMutating || !CanEditSkill(skill))"
|
||||
@onclick="() => OpenEditSkillModal(skill)">
|
||||
<span aria-hidden="true">✎</span>
|
||||
<span class="sr-only">Edit @skill.Name</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="chip-button"
|
||||
title="Roll skill"
|
||||
disabled="@(IsMutating || !CanRollSkill(skill))"
|
||||
@onclick="() => RollSkillAsync(skill.Id)">
|
||||
<span aria-hidden="true">⚄</span>
|
||||
<span class="sr-only">Roll @skill.Name</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div class="skill-list">
|
||||
<button
|
||||
type="button"
|
||||
class="skill-item create-skill-item"
|
||||
@@ -101,6 +176,35 @@
|
||||
}
|
||||
</section>
|
||||
|
||||
@if (ShowCreateSkillGroupModal || ShowEditSkillGroupModal)
|
||||
{
|
||||
<div class="modal-overlay" role="presentation">
|
||||
<section class="modal-card" role="dialog" aria-modal="true" aria-label="Skill Group">
|
||||
<h2>@(ShowEditSkillGroupModal ? "Rename Skill Group" : "Create Skill Group")</h2>
|
||||
@if (!string.IsNullOrWhiteSpace(SkillGroupState.ErrorMessage))
|
||||
{
|
||||
<p class="form-error">@SkillGroupState.ErrorMessage</p>
|
||||
}
|
||||
<form class="form-grid" @onsubmit="@(ShowEditSkillGroupModal ? SubmitUpdateSkillGroupAsync : SubmitCreateSkillGroupAsync)" @onsubmit:preventDefault>
|
||||
<label for="skill-group-name">Group name</label>
|
||||
<input id="skill-group-name" @bind="SkillGroupState.Model.Name" @bind:event="oninput"/>
|
||||
@if (SkillGroupState.Errors.TryGetValue("name", out var groupNameError))
|
||||
{
|
||||
<p class="field-error">@groupNameError</p>
|
||||
}
|
||||
@if (SkillGroupState.Errors.TryGetValue("character", out var characterError))
|
||||
{
|
||||
<p class="field-error">@characterError</p>
|
||||
}
|
||||
<div class="inline-actions">
|
||||
<button type="submit" disabled="@(IsMutating || IsSubmittingSkillGroup)">@(ShowEditSkillGroupModal ? "Save Group" : "Create Group")</button>
|
||||
<button type="button" class="ghost" disabled="@(IsMutating || IsSubmittingSkillGroup)" @onclick="CloseSkillGroupModals">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
|
||||
<SkillFormModal
|
||||
Visible="ShowCreateSkillModal"
|
||||
IsD6="IsD6"
|
||||
@@ -108,12 +212,14 @@
|
||||
SubmitLabel="Create Skill"
|
||||
NameInputId="skill-create-name"
|
||||
ExpressionInputId="skill-create-expression"
|
||||
SkillGroupInputId="skill-create-group"
|
||||
WildDiceInputId="skill-create-wild-dice"
|
||||
AllowFumbleInputId="skill-create-allow-fumble"
|
||||
InitialModel="CreateSkillInitialModel"
|
||||
FormVersion="CreateSkillFormVersion"
|
||||
SelectedCharacterId="SelectedCharacterId"
|
||||
EditingSkillId="null"
|
||||
AvailableSkillGroups="SelectedCharacterSkillGroups"
|
||||
IsMutating="IsMutating"
|
||||
SkillSaved="OnSkillCreatedAsync"
|
||||
CancelRequested="CloseSkillModals"/>
|
||||
@@ -125,12 +231,14 @@
|
||||
SubmitLabel="Save Skill"
|
||||
NameInputId="skill-edit-name"
|
||||
ExpressionInputId="skill-edit-expression"
|
||||
SkillGroupInputId="skill-edit-group"
|
||||
WildDiceInputId="skill-edit-wild-dice"
|
||||
AllowFumbleInputId="skill-edit-allow-fumble"
|
||||
InitialModel="EditSkillInitialModel"
|
||||
FormVersion="EditSkillFormVersion"
|
||||
SelectedCharacterId="SelectedCharacterId"
|
||||
EditingSkillId="EditingSkillId"
|
||||
AvailableSkillGroups="SelectedCharacterSkillGroups"
|
||||
IsMutating="IsMutating"
|
||||
SkillSaved="OnSkillUpdatedAsync"
|
||||
CancelRequested="CloseSkillModals"/>
|
||||
|
||||
@@ -13,6 +13,7 @@ public partial class CharacterPanel
|
||||
{
|
||||
Name = string.Empty,
|
||||
DiceRollDefinition = string.Empty,
|
||||
SkillGroupId = string.Empty,
|
||||
WildDice = IsD6 ? 1 : 0,
|
||||
AllowFumble = IsD6
|
||||
};
|
||||
@@ -28,6 +29,7 @@ public partial class CharacterPanel
|
||||
{
|
||||
Name = skill.Name,
|
||||
DiceRollDefinition = skill.DiceRollDefinition,
|
||||
SkillGroupId = skill.SkillGroupId?.ToString() ?? string.Empty,
|
||||
WildDice = skill.WildDice,
|
||||
AllowFumble = skill.AllowFumble
|
||||
};
|
||||
@@ -66,6 +68,97 @@ public partial class CharacterPanel
|
||||
await RollRequested.InvokeAsync(skillId);
|
||||
}
|
||||
|
||||
private void OpenCreateSkillGroupModal()
|
||||
{
|
||||
SkillGroupState.Model.Name = string.Empty;
|
||||
SkillGroupState.ResetValidation();
|
||||
ShowCreateSkillGroupModal = true;
|
||||
}
|
||||
|
||||
private void OpenEditSkillGroupModal(SkillGroupSummary skillGroup)
|
||||
{
|
||||
EditingSkillGroupId = skillGroup.Id;
|
||||
SkillGroupState.Model.Name = skillGroup.Name;
|
||||
SkillGroupState.ResetValidation();
|
||||
ShowEditSkillGroupModal = true;
|
||||
}
|
||||
|
||||
private void CloseSkillGroupModals()
|
||||
{
|
||||
ShowCreateSkillGroupModal = false;
|
||||
ShowEditSkillGroupModal = false;
|
||||
EditingSkillGroupId = null;
|
||||
SkillGroupState.ResetValidation();
|
||||
}
|
||||
|
||||
private async Task SubmitCreateSkillGroupAsync()
|
||||
{
|
||||
SkillGroupState.ResetValidation();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(SkillGroupState.Model.Name))
|
||||
SkillGroupState.Errors["name"] = "Skill group name is required.";
|
||||
|
||||
if (!SelectedCharacterId.HasValue)
|
||||
SkillGroupState.Errors["character"] = "Select a character first.";
|
||||
|
||||
if (SkillGroupState.Errors.Count > 0)
|
||||
{
|
||||
SkillGroupState.ErrorMessage = "Resolve validation issues before submitting.";
|
||||
return;
|
||||
}
|
||||
|
||||
IsSubmittingSkillGroup = true;
|
||||
try
|
||||
{
|
||||
var selectedCharacterId = SelectedCharacterId!.Value;
|
||||
var createdGroup = await ApiClient.RequestAsync<SkillGroupSummary>("POST", $"/api/characters/{selectedCharacterId}/skill-groups", new CreateSkillGroupRequest(SkillGroupState.Model.Name.Trim()));
|
||||
CloseSkillGroupModals();
|
||||
await SkillGroupCreated.InvokeAsync(createdGroup.Id);
|
||||
}
|
||||
catch (ApiRequestException ex)
|
||||
{
|
||||
SkillGroupState.ErrorMessage = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsSubmittingSkillGroup = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SubmitUpdateSkillGroupAsync()
|
||||
{
|
||||
SkillGroupState.ResetValidation();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(SkillGroupState.Model.Name))
|
||||
SkillGroupState.Errors["name"] = "Skill group name is required.";
|
||||
|
||||
if (!EditingSkillGroupId.HasValue)
|
||||
SkillGroupState.Errors["group"] = "Select a skill group first.";
|
||||
|
||||
if (SkillGroupState.Errors.Count > 0)
|
||||
{
|
||||
SkillGroupState.ErrorMessage = "Resolve validation issues before submitting.";
|
||||
return;
|
||||
}
|
||||
|
||||
IsSubmittingSkillGroup = true;
|
||||
try
|
||||
{
|
||||
var editingSkillGroupId = EditingSkillGroupId!.Value;
|
||||
var updatedGroup = await ApiClient.RequestAsync<SkillGroupSummary>("PUT", $"/api/skill-groups/{editingSkillGroupId}", new UpdateSkillGroupRequest(SkillGroupState.Model.Name.Trim()));
|
||||
CloseSkillGroupModals();
|
||||
await SkillGroupUpdated.InvokeAsync(updatedGroup.Id);
|
||||
}
|
||||
catch (ApiRequestException ex)
|
||||
{
|
||||
SkillGroupState.ErrorMessage = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsSubmittingSkillGroup = false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string InitialsFor(string value)
|
||||
{
|
||||
var words = value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
@@ -80,11 +173,19 @@ public partial class CharacterPanel
|
||||
|
||||
private bool ShowCreateSkillModal { get; set; }
|
||||
private bool ShowEditSkillModal { get; set; }
|
||||
private bool ShowCreateSkillGroupModal { get; set; }
|
||||
private bool ShowEditSkillGroupModal { get; set; }
|
||||
private Guid? EditingSkillId { get; set; }
|
||||
private Guid? EditingSkillGroupId { get; set; }
|
||||
private SkillFormModel CreateSkillInitialModel { get; set; } = new();
|
||||
private SkillFormModel EditSkillInitialModel { get; set; } = new();
|
||||
private FormState<SkillGroupFormModel> SkillGroupState { get; } = new();
|
||||
private int CreateSkillFormVersion { get; set; }
|
||||
private int EditSkillFormVersion { get; set; }
|
||||
private bool IsSubmittingSkillGroup { get; set; }
|
||||
|
||||
[Inject]
|
||||
private RpgRollerApiClient ApiClient { get; set; } = null!;
|
||||
|
||||
[Parameter]
|
||||
public bool IsCampaignDataLoading { get; set; }
|
||||
@@ -104,6 +205,9 @@ public partial class CharacterPanel
|
||||
[Parameter]
|
||||
public IReadOnlyList<SkillSummary> SelectedCharacterSkills { get; set; } = [];
|
||||
|
||||
[Parameter]
|
||||
public IReadOnlyList<SkillGroupSummary> SelectedCharacterSkillGroups { get; set; } = [];
|
||||
|
||||
[Parameter]
|
||||
public bool IsD6 { get; set; }
|
||||
|
||||
@@ -140,6 +244,12 @@ public partial class CharacterPanel
|
||||
[Parameter]
|
||||
public EventCallback<Guid> SkillUpdated { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<Guid> SkillGroupCreated { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<Guid> SkillGroupUpdated { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<Guid> RollRequested { get; set; }
|
||||
}
|
||||
|
||||
@@ -20,6 +20,18 @@
|
||||
{
|
||||
<p class="field-error">@expressionError</p>
|
||||
}
|
||||
<label for="@SkillGroupInputId">Group</label>
|
||||
<select id="@SkillGroupInputId" @bind="FormState.Model.SkillGroupId">
|
||||
<option value="">No group</option>
|
||||
@foreach (var group in AvailableSkillGroups)
|
||||
{
|
||||
<option value="@group.Id">@group.Name</option>
|
||||
}
|
||||
</select>
|
||||
@if (FormState.Errors.TryGetValue("skillGroupId", out var skillGroupError))
|
||||
{
|
||||
<p class="field-error">@skillGroupError</p>
|
||||
}
|
||||
@if (IsD6)
|
||||
{
|
||||
<label for="@WildDiceInputId">Wild dice</label>
|
||||
|
||||
@@ -14,6 +14,7 @@ public partial class SkillFormModal
|
||||
|
||||
FormState.Model.Name = InitialModel.Name;
|
||||
FormState.Model.DiceRollDefinition = InitialModel.DiceRollDefinition;
|
||||
FormState.Model.SkillGroupId = InitialModel.SkillGroupId;
|
||||
FormState.Model.WildDice = InitialModel.WildDice;
|
||||
FormState.Model.AllowFumble = InitialModel.AllowFumble;
|
||||
FormState.ResetValidation();
|
||||
@@ -33,6 +34,15 @@ public partial class SkillFormModal
|
||||
if (IsD6 && FormState.Model.WildDice < 1)
|
||||
FormState.Errors["wildDice"] = "D6 skills require at least one wild die.";
|
||||
|
||||
Guid? skillGroupId = null;
|
||||
if (!string.IsNullOrWhiteSpace(FormState.Model.SkillGroupId))
|
||||
{
|
||||
if (!Guid.TryParse(FormState.Model.SkillGroupId, out var parsedSkillGroupId))
|
||||
FormState.Errors["skillGroupId"] = "Skill group is invalid.";
|
||||
else
|
||||
skillGroupId = parsedSkillGroupId;
|
||||
}
|
||||
|
||||
if (FormState.Errors.Count > 0)
|
||||
{
|
||||
FormState.ErrorMessage = "Resolve validation issues before submitting.";
|
||||
@@ -45,7 +55,7 @@ public partial class SkillFormModal
|
||||
SkillSummary skill;
|
||||
if (EditingSkillId.HasValue)
|
||||
{
|
||||
skill = await ApiClient.RequestAsync<SkillSummary>("PUT", $"/api/skills/{EditingSkillId.Value}", new UpdateSkillRequest(FormState.Model.Name.Trim(), FormState.Model.DiceRollDefinition.Trim(), FormState.Model.WildDice, FormState.Model.AllowFumble));
|
||||
skill = await ApiClient.RequestAsync<SkillSummary>("PUT", $"/api/skills/{EditingSkillId.Value}", new UpdateSkillRequest(FormState.Model.Name.Trim(), FormState.Model.DiceRollDefinition.Trim(), FormState.Model.WildDice, FormState.Model.AllowFumble, skillGroupId));
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -55,7 +65,7 @@ public partial class SkillFormModal
|
||||
return;
|
||||
}
|
||||
|
||||
skill = await ApiClient.RequestAsync<SkillSummary>("POST", $"/api/characters/{SelectedCharacterId.Value}/skills", new CreateSkillRequest(FormState.Model.Name.Trim(), FormState.Model.DiceRollDefinition.Trim(), FormState.Model.WildDice, FormState.Model.AllowFumble));
|
||||
skill = await ApiClient.RequestAsync<SkillSummary>("POST", $"/api/characters/{SelectedCharacterId.Value}/skills", new CreateSkillRequest(FormState.Model.Name.Trim(), FormState.Model.DiceRollDefinition.Trim(), FormState.Model.WildDice, FormState.Model.AllowFumble, skillGroupId));
|
||||
}
|
||||
|
||||
await SkillSaved.InvokeAsync(skill.Id);
|
||||
@@ -95,6 +105,9 @@ public partial class SkillFormModal
|
||||
[Parameter]
|
||||
public string ExpressionInputId { get; set; } = "skill-expression";
|
||||
|
||||
[Parameter]
|
||||
public string SkillGroupInputId { get; set; } = "skill-group";
|
||||
|
||||
[Parameter]
|
||||
public string WildDiceInputId { get; set; } = "skill-wild";
|
||||
|
||||
@@ -113,6 +126,9 @@ public partial class SkillFormModal
|
||||
[Parameter]
|
||||
public Guid? EditingSkillId { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public IReadOnlyList<SkillGroupSummary> AvailableSkillGroups { get; set; } = [];
|
||||
|
||||
[Parameter]
|
||||
public bool IsMutating { get; set; }
|
||||
|
||||
@@ -121,4 +137,4 @@ public partial class SkillFormModal
|
||||
|
||||
[Parameter]
|
||||
public EventCallback CancelRequested { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
SelectedCharacter="SelectedCharacter"
|
||||
IsMutating="IsMutating"
|
||||
SelectedCharacterSkills="SelectedCharacterSkills"
|
||||
SelectedCharacterSkillGroups="SelectedCharacterSkillGroups"
|
||||
IsD6="IsSelectedCampaignD6"
|
||||
RollVisibility="RollVisibility"
|
||||
RollVisibilityChanged="OnRollVisibilityChanged"
|
||||
@@ -82,6 +83,8 @@
|
||||
EditCharacterRequested="OpenEditCharacterModal"
|
||||
SkillCreated="OnSkillCreatedAsync"
|
||||
SkillUpdated="OnSkillUpdatedAsync"
|
||||
SkillGroupCreated="OnSkillGroupCreatedAsync"
|
||||
SkillGroupUpdated="OnSkillGroupUpdatedAsync"
|
||||
RollRequested="RollSkillAsync"/>
|
||||
|
||||
<CampaignLogPanel
|
||||
@@ -140,11 +143,13 @@
|
||||
SubmitLabel="Create Character"
|
||||
NameInputId="character-create-name"
|
||||
CampaignInputId="character-create-campaign"
|
||||
OwnerUsernameInputId="character-create-owner"
|
||||
InitialModel="CreateCharacterInitialModel"
|
||||
FormVersion="CreateCharacterFormVersion"
|
||||
EditingCharacterId="null"
|
||||
Campaigns="Campaigns"
|
||||
IsMutating="IsMutating"
|
||||
AllowOwnerEdit="false"
|
||||
CharacterSaved="OnCharacterCreatedAsync"
|
||||
CancelRequested="CloseCharacterModals"/>
|
||||
|
||||
@@ -154,10 +159,12 @@
|
||||
SubmitLabel="Save Character"
|
||||
NameInputId="character-edit-name"
|
||||
CampaignInputId="character-edit-campaign"
|
||||
OwnerUsernameInputId="character-edit-owner"
|
||||
InitialModel="EditCharacterInitialModel"
|
||||
FormVersion="EditCharacterFormVersion"
|
||||
EditingCharacterId="EditingCharacterId"
|
||||
Campaigns="Campaigns"
|
||||
IsMutating="IsMutating"
|
||||
AllowOwnerEdit="CanEditCharacterOwner"
|
||||
CharacterSaved="OnCharacterUpdatedAsync"
|
||||
CancelRequested="CloseCharacterModals"/>
|
||||
|
||||
@@ -282,10 +282,12 @@ public partial class Workspace : IAsyncDisposable
|
||||
CreateCharacterInitialModel = new()
|
||||
{
|
||||
Name = string.Empty,
|
||||
CampaignId = SelectedCampaignId?.ToString() ?? string.Empty
|
||||
CampaignId = SelectedCampaignId?.ToString() ?? string.Empty,
|
||||
OwnerUsername = string.Empty
|
||||
};
|
||||
|
||||
CreateCharacterFormVersion++;
|
||||
CanEditCharacterOwner = false;
|
||||
ShowCreateCharacterModal = true;
|
||||
}
|
||||
|
||||
@@ -295,10 +297,12 @@ public partial class Workspace : IAsyncDisposable
|
||||
EditCharacterInitialModel = new()
|
||||
{
|
||||
Name = character.Name,
|
||||
CampaignId = character.CampaignId.ToString()
|
||||
CampaignId = character.CampaignId.ToString(),
|
||||
OwnerUsername = string.Empty
|
||||
};
|
||||
|
||||
EditCharacterFormVersion++;
|
||||
CanEditCharacterOwner = IsCurrentUserGm;
|
||||
ShowEditCharacterModal = true;
|
||||
}
|
||||
|
||||
@@ -306,6 +310,7 @@ public partial class Workspace : IAsyncDisposable
|
||||
{
|
||||
ShowCreateCharacterModal = false;
|
||||
ShowEditCharacterModal = false;
|
||||
CanEditCharacterOwner = false;
|
||||
EditingCharacterId = null;
|
||||
}
|
||||
|
||||
@@ -375,6 +380,18 @@ public partial class Workspace : IAsyncDisposable
|
||||
SetStatus("Skill updated.", false);
|
||||
}
|
||||
|
||||
private async Task OnSkillGroupCreatedAsync(Guid _)
|
||||
{
|
||||
await RefreshCampaignScopeAsync();
|
||||
SetStatus("Skill group created.", false);
|
||||
}
|
||||
|
||||
private async Task OnSkillGroupUpdatedAsync(Guid _)
|
||||
{
|
||||
await RefreshCampaignScopeAsync();
|
||||
SetStatus("Skill group updated.", false);
|
||||
}
|
||||
|
||||
private async Task RollSkillAsync(Guid skillId)
|
||||
{
|
||||
if (SelectedCampaign is null)
|
||||
@@ -626,6 +643,7 @@ public partial class Workspace : IAsyncDisposable
|
||||
LastRoll = null;
|
||||
ShowCreateCharacterModal = false;
|
||||
ShowEditCharacterModal = false;
|
||||
CanEditCharacterOwner = false;
|
||||
CreateCharacterInitialModel = new();
|
||||
EditCharacterInitialModel = new();
|
||||
CreateCharacterFormVersion = 0;
|
||||
@@ -702,6 +720,7 @@ public partial class Workspace : IAsyncDisposable
|
||||
|
||||
private bool ShowCreateCharacterModal { get; set; }
|
||||
private bool ShowEditCharacterModal { get; set; }
|
||||
private bool CanEditCharacterOwner { get; set; }
|
||||
private Guid? EditingCharacterId { get; set; }
|
||||
private CharacterFormModel CreateCharacterInitialModel { get; set; } = new();
|
||||
private CharacterFormModel EditCharacterInitialModel { get; set; } = new();
|
||||
@@ -728,6 +747,9 @@ public partial class Workspace : IAsyncDisposable
|
||||
private List<SkillSummary> SelectedCharacterSkills =>
|
||||
SelectedCampaign is null || !SelectedCharacterId.HasValue ? [] : SelectedCampaign.Skills.Where(skill => skill.CharacterId == SelectedCharacterId.Value).OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
|
||||
private List<SkillGroupSummary> SelectedCharacterSkillGroups =>
|
||||
SelectedCampaign is null || !SelectedCharacterId.HasValue ? [] : SelectedCampaign.SkillGroups.Where(group => group.CharacterId == SelectedCharacterId.Value).OrderBy(group => group.Name, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
|
||||
private bool IsPlayScreen => string.Equals(CurrentScreen, "play", StringComparison.OrdinalIgnoreCase);
|
||||
private bool IsManagementScreen => !IsPlayScreen;
|
||||
private string CurrentScreenLabel => IsPlayScreen ? "Play" : "Campaign Management";
|
||||
|
||||
@@ -18,19 +18,25 @@ public sealed record CreateCampaignRequest(string Name, string RulesetId);
|
||||
|
||||
public sealed record CampaignSummary(Guid Id, string Name, string RulesetId, Guid GmUserId);
|
||||
|
||||
public sealed record CampaignDetails(Guid Id, string Name, string RulesetId, UserSummary Gm, IReadOnlyList<CharacterSummary> Characters, IReadOnlyList<SkillSummary> Skills);
|
||||
public sealed record CampaignDetails(Guid Id, string Name, string RulesetId, UserSummary Gm, IReadOnlyList<CharacterSummary> Characters, IReadOnlyList<SkillGroupSummary> SkillGroups, IReadOnlyList<SkillSummary> Skills);
|
||||
|
||||
public sealed record CreateCharacterRequest(string Name, Guid CampaignId);
|
||||
|
||||
public sealed record UpdateCharacterRequest(string Name, Guid CampaignId);
|
||||
public sealed record UpdateCharacterRequest(string Name, Guid CampaignId, string? OwnerUsername = null);
|
||||
|
||||
public sealed record CharacterSummary(Guid Id, string Name, Guid OwnerUserId, Guid CampaignId);
|
||||
|
||||
public sealed record CreateSkillRequest(string Name, string DiceRollDefinition, int WildDice, bool AllowFumble);
|
||||
public sealed record CreateSkillRequest(string Name, string DiceRollDefinition, int WildDice, bool AllowFumble, Guid? SkillGroupId = null);
|
||||
|
||||
public sealed record UpdateSkillRequest(string Name, string DiceRollDefinition, int WildDice, bool AllowFumble);
|
||||
public sealed record UpdateSkillRequest(string Name, string DiceRollDefinition, int WildDice, bool AllowFumble, Guid? SkillGroupId = null);
|
||||
|
||||
public sealed record SkillSummary(Guid Id, Guid CharacterId, string Name, string DiceRollDefinition, int WildDice, bool AllowFumble);
|
||||
public sealed record SkillGroupSummary(Guid Id, Guid CharacterId, string Name);
|
||||
|
||||
public sealed record CreateSkillGroupRequest(string Name);
|
||||
|
||||
public sealed record UpdateSkillGroupRequest(string Name);
|
||||
|
||||
public sealed record SkillSummary(Guid Id, Guid CharacterId, Guid? SkillGroupId, string Name, string DiceRollDefinition, int WildDice, bool AllowFumble);
|
||||
|
||||
public sealed record RollSkillRequest(string Visibility);
|
||||
|
||||
@@ -38,4 +44,4 @@ public sealed record RollDieResult(int Roll, bool Crit, bool Fumble, bool Wild,
|
||||
|
||||
public sealed record RollResult(Guid RollId, Guid CampaignId, Guid CharacterId, Guid SkillId, Guid RollerUserId, string Visibility, int Result, string Breakdown, IReadOnlyList<RollDieResult> Dice, DateTimeOffset TimestampUtc);
|
||||
|
||||
public sealed record CampaignLogEntry(Guid RollId, Guid CampaignId, Guid CharacterId, Guid SkillId, Guid RollerUserId, string Visibility, int Result, string Breakdown, IReadOnlyList<RollDieResult> Dice, DateTimeOffset TimestampUtc);
|
||||
public sealed record CampaignLogEntry(Guid RollId, Guid CampaignId, Guid CharacterId, Guid SkillId, Guid RollerUserId, string Visibility, int Result, string Breakdown, IReadOnlyList<RollDieResult> Dice, DateTimeOffset TimestampUtc);
|
||||
|
||||
@@ -54,6 +54,14 @@ public sealed class RpgRollerDbContext : DbContext
|
||||
entity.Property(x => x.WildDice).IsRequired();
|
||||
entity.Property(x => x.AllowFumble).IsRequired();
|
||||
entity.HasIndex(x => x.CharacterId);
|
||||
entity.HasIndex(x => x.SkillGroupId);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<SkillGroup>(entity =>
|
||||
{
|
||||
entity.HasKey(x => x.Id);
|
||||
entity.Property(x => x.Name).IsRequired().HasMaxLength(128);
|
||||
entity.HasIndex(x => x.CharacterId);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<RollLogEntry>(entity =>
|
||||
@@ -75,5 +83,6 @@ public sealed class RpgRollerDbContext : DbContext
|
||||
public DbSet<Campaign> Campaigns => Set<Campaign>();
|
||||
public DbSet<Character> Characters => Set<Character>();
|
||||
public DbSet<Skill> Skills => Set<Skill>();
|
||||
public DbSet<SkillGroup> SkillGroups => Set<SkillGroup>();
|
||||
public DbSet<RollLogEntry> RollLogEntries => Set<RollLogEntry>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,15 +41,23 @@ public sealed class Campaign
|
||||
public sealed class Character
|
||||
{
|
||||
public required Guid Id { get; init; }
|
||||
public required Guid OwnerUserId { get; init; }
|
||||
public required Guid OwnerUserId { get; set; }
|
||||
public required Guid CampaignId { get; set; }
|
||||
public required string Name { get; set; }
|
||||
}
|
||||
|
||||
public sealed class SkillGroup
|
||||
{
|
||||
public required Guid Id { get; init; }
|
||||
public required Guid CharacterId { get; set; }
|
||||
public required string Name { get; set; }
|
||||
}
|
||||
|
||||
public sealed class Skill
|
||||
{
|
||||
public required Guid Id { get; init; }
|
||||
public required Guid CharacterId { get; set; }
|
||||
public Guid? SkillGroupId { get; set; }
|
||||
public required string Name { get; set; }
|
||||
public required string DiceRollDefinition { get; set; }
|
||||
public required int WildDice { get; set; }
|
||||
@@ -70,4 +78,4 @@ public sealed class RollLogEntry
|
||||
public required DateTimeOffset TimestampUtc { get; init; }
|
||||
}
|
||||
|
||||
public sealed record DiceExpression(int DiceCount, int Sides, int Modifier, string Canonical);
|
||||
public sealed record DiceExpression(int DiceCount, int Sides, int Modifier, string Canonical);
|
||||
|
||||
242
RpgRoller/Migrations/20260226124941_AddSkillGroupsAndCharacterOwnerTransfer.Designer.cs
generated
Normal file
242
RpgRoller/Migrations/20260226124941_AddSkillGroupsAndCharacterOwnerTransfer.Designer.cs
generated
Normal file
@@ -0,0 +1,242 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using RpgRoller.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace RpgRoller.Migrations
|
||||
{
|
||||
[DbContext(typeof(RpgRollerDbContext))]
|
||||
[Migration("20260226124941_AddSkillGroupsAndCharacterOwnerTransfer")]
|
||||
partial class AddSkillGroupsAndCharacterOwnerTransfer
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.2");
|
||||
|
||||
modelBuilder.Entity("RpgRoller.Domain.Campaign", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("GmUserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Ruleset")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("Version")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("GmUserId");
|
||||
|
||||
b.ToTable("Campaigns");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("RpgRoller.Domain.Character", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("CampaignId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("OwnerUserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CampaignId");
|
||||
|
||||
b.HasIndex("OwnerUserId");
|
||||
|
||||
b.ToTable("Characters");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("RpgRoller.Domain.RollLogEntry", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Breakdown")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("CampaignId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("CharacterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Dice")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Result")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("RollerUserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("SkillId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("TimestampUtc")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Visibility")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CampaignId");
|
||||
|
||||
b.HasIndex("CharacterId");
|
||||
|
||||
b.HasIndex("RollerUserId");
|
||||
|
||||
b.HasIndex("SkillId");
|
||||
|
||||
b.ToTable("RollLogEntries");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("RpgRoller.Domain.Skill", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("AllowFumble")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("CharacterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DiceRollDefinition")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("SkillGroupId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("WildDice")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CharacterId");
|
||||
|
||||
b.HasIndex("SkillGroupId");
|
||||
|
||||
b.ToTable("Skills");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("RpgRoller.Domain.SkillGroup", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("CharacterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CharacterId");
|
||||
|
||||
b.ToTable("SkillGroups");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("RpgRoller.Domain.UserAccount", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("ActiveCharacterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("UsernameNormalized")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UsernameNormalized")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Users");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("RpgRoller.Domain.UserSession", b =>
|
||||
{
|
||||
b.Property<string>("Token")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Token");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("Sessions");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace RpgRoller.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddSkillGroupsAndCharacterOwnerTransfer : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "SkillGroupId",
|
||||
table: "Skills",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "SkillGroups",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
CharacterId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
Name = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_SkillGroups", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Skills_SkillGroupId",
|
||||
table: "Skills",
|
||||
column: "SkillGroupId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_SkillGroups_CharacterId",
|
||||
table: "SkillGroups",
|
||||
column: "CharacterId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "SkillGroups");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Skills_SkillGroupId",
|
||||
table: "Skills");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SkillGroupId",
|
||||
table: "Skills");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -143,6 +143,9 @@ namespace RpgRoller.Migrations
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("SkillGroupId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("WildDice")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
@@ -150,9 +153,32 @@ namespace RpgRoller.Migrations
|
||||
|
||||
b.HasIndex("CharacterId");
|
||||
|
||||
b.HasIndex("SkillGroupId");
|
||||
|
||||
b.ToTable("Skills");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("RpgRoller.Domain.SkillGroup", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("CharacterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CharacterId");
|
||||
|
||||
b.ToTable("SkillGroups");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("RpgRoller.Domain.UserAccount", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
|
||||
@@ -191,9 +191,10 @@ public sealed class GameService : IGameService
|
||||
|
||||
var visibleCharacterIds = characters.Select(c => c.Id).ToHashSet();
|
||||
|
||||
var skillGroups = m_SkillGroupsById.Values.Where(g => visibleCharacterIds.Contains(g.CharacterId)).OrderBy(g => g.Name, StringComparer.OrdinalIgnoreCase).Select(ToSkillGroupSummary).ToArray();
|
||||
var skills = m_SkillsById.Values.Where(s => visibleCharacterIds.Contains(s.CharacterId)).OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase).Select(ToSkillSummary).ToArray();
|
||||
|
||||
return ServiceResult<CampaignDetails>.Success(new(campaign.Id, campaign.Name, DiceRules.ToRulesetId(campaign.Ruleset), ToUserSummary(gm), characters, skills));
|
||||
return ServiceResult<CampaignDetails>.Success(new(campaign.Id, campaign.Name, DiceRules.ToRulesetId(campaign.Ruleset), ToUserSummary(gm), characters, skillGroups, skills));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,7 +228,7 @@ public sealed class GameService : IGameService
|
||||
}
|
||||
}
|
||||
|
||||
public ServiceResult<CharacterSummary> UpdateCharacter(string sessionToken, Guid characterId, string name, Guid campaignId)
|
||||
public ServiceResult<CharacterSummary> UpdateCharacter(string sessionToken, Guid characterId, string name, Guid campaignId, string? ownerUsername = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return ServiceResult<CharacterSummary>.Failure("invalid_character_name", "Character name is required.");
|
||||
@@ -252,9 +253,29 @@ public sealed class GameService : IGameService
|
||||
return ServiceResult<CharacterSummary>.Failure("forbidden", "Only the owner or GM can edit this character.");
|
||||
|
||||
var sourceCampaignId = character.CampaignId;
|
||||
var previousOwnerUserId = character.OwnerUserId;
|
||||
character.Name = name.Trim();
|
||||
character.CampaignId = campaignId;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(ownerUsername))
|
||||
{
|
||||
var trimmedOwnerUsername = ownerUsername.Trim();
|
||||
var normalizedOwnerUsername = NormalizeUsername(trimmedOwnerUsername);
|
||||
if (!m_UserIdsByUsername.TryGetValue(normalizedOwnerUsername, out var targetOwnerUserId))
|
||||
return ServiceResult<CharacterSummary>.Failure("owner_not_found", "Owner username was not found.");
|
||||
|
||||
if (targetOwnerUserId != character.OwnerUserId && !isSourceGm && !isTargetGm)
|
||||
return ServiceResult<CharacterSummary>.Failure("forbidden", "Only the GM can change character owner.");
|
||||
|
||||
character.OwnerUserId = targetOwnerUserId;
|
||||
if (character.OwnerUserId != previousOwnerUserId &&
|
||||
m_UsersById.TryGetValue(previousOwnerUserId, out var previousOwner) &&
|
||||
previousOwner.ActiveCharacterId == character.Id)
|
||||
{
|
||||
previousOwner.ActiveCharacterId = null;
|
||||
}
|
||||
}
|
||||
|
||||
TouchCampaignLocked(sourceCampaignId);
|
||||
if (sourceCampaignId != character.CampaignId)
|
||||
TouchCampaignLocked(character.CampaignId);
|
||||
@@ -301,7 +322,67 @@ public sealed class GameService : IGameService
|
||||
}
|
||||
}
|
||||
|
||||
public ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble)
|
||||
public ServiceResult<SkillGroupSummary> CreateSkillGroup(string sessionToken, Guid characterId, string name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return ServiceResult<SkillGroupSummary>.Failure("invalid_skill_group_name", "Skill group name is required.");
|
||||
|
||||
lock (m_Gate)
|
||||
{
|
||||
var user = ResolveUserLocked(sessionToken);
|
||||
if (user is null)
|
||||
return ServiceResult<SkillGroupSummary>.Failure("unauthorized", "You must be logged in.");
|
||||
|
||||
if (!m_CharactersById.TryGetValue(characterId, out var character))
|
||||
return ServiceResult<SkillGroupSummary>.Failure("character_not_found", "Character was not found.");
|
||||
|
||||
var campaign = m_CampaignsById[character.CampaignId];
|
||||
if (!CanEditCharacterLocked(user.Id, character, campaign))
|
||||
return ServiceResult<SkillGroupSummary>.Failure("forbidden", "Only the owner or GM can manage skill groups.");
|
||||
|
||||
var group = new SkillGroup
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CharacterId = character.Id,
|
||||
Name = name.Trim()
|
||||
};
|
||||
|
||||
m_SkillGroupsById[group.Id] = group;
|
||||
TouchCampaignLocked(campaign.Id);
|
||||
|
||||
PersistStateLocked();
|
||||
return ServiceResult<SkillGroupSummary>.Success(ToSkillGroupSummary(group));
|
||||
}
|
||||
}
|
||||
|
||||
public ServiceResult<SkillGroupSummary> UpdateSkillGroup(string sessionToken, Guid skillGroupId, string name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return ServiceResult<SkillGroupSummary>.Failure("invalid_skill_group_name", "Skill group name is required.");
|
||||
|
||||
lock (m_Gate)
|
||||
{
|
||||
var user = ResolveUserLocked(sessionToken);
|
||||
if (user is null)
|
||||
return ServiceResult<SkillGroupSummary>.Failure("unauthorized", "You must be logged in.");
|
||||
|
||||
if (!m_SkillGroupsById.TryGetValue(skillGroupId, out var group))
|
||||
return ServiceResult<SkillGroupSummary>.Failure("skill_group_not_found", "Skill group was not found.");
|
||||
|
||||
var character = m_CharactersById[group.CharacterId];
|
||||
var campaign = m_CampaignsById[character.CampaignId];
|
||||
if (!CanEditCharacterLocked(user.Id, character, campaign))
|
||||
return ServiceResult<SkillGroupSummary>.Failure("forbidden", "Only the owner or GM can manage skill groups.");
|
||||
|
||||
group.Name = name.Trim();
|
||||
TouchCampaignLocked(campaign.Id);
|
||||
|
||||
PersistStateLocked();
|
||||
return ServiceResult<SkillGroupSummary>.Success(ToSkillGroupSummary(group));
|
||||
}
|
||||
}
|
||||
|
||||
public ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return ServiceResult<SkillSummary>.Failure("invalid_skill_name", "Skill name is required.");
|
||||
@@ -327,10 +408,15 @@ public sealed class GameService : IGameService
|
||||
if (!optionsValidation.Succeeded)
|
||||
return ServiceResult<SkillSummary>.Failure(optionsValidation.Error!.Code, optionsValidation.Error.Message);
|
||||
|
||||
var resolvedSkillGroupId = ResolveSkillGroupForSkillChangeLocked(skillGroupId, character.Id);
|
||||
if (!resolvedSkillGroupId.Succeeded)
|
||||
return ServiceResult<SkillSummary>.Failure(resolvedSkillGroupId.Error!.Code, resolvedSkillGroupId.Error.Message);
|
||||
|
||||
var skill = new Skill
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CharacterId = character.Id,
|
||||
SkillGroupId = resolvedSkillGroupId.Value,
|
||||
Name = name.Trim(),
|
||||
DiceRollDefinition = expressionValidation.Value!.Canonical,
|
||||
WildDice = optionsValidation.Value!.WildDice,
|
||||
@@ -345,7 +431,7 @@ public sealed class GameService : IGameService
|
||||
}
|
||||
}
|
||||
|
||||
public ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble)
|
||||
public ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return ServiceResult<SkillSummary>.Failure("invalid_skill_name", "Skill name is required.");
|
||||
@@ -372,10 +458,15 @@ public sealed class GameService : IGameService
|
||||
if (!optionsValidation.Succeeded)
|
||||
return ServiceResult<SkillSummary>.Failure(optionsValidation.Error!.Code, optionsValidation.Error.Message);
|
||||
|
||||
var resolvedSkillGroupId = ResolveSkillGroupForSkillChangeLocked(skillGroupId, character.Id);
|
||||
if (!resolvedSkillGroupId.Succeeded)
|
||||
return ServiceResult<SkillSummary>.Failure(resolvedSkillGroupId.Error!.Code, resolvedSkillGroupId.Error.Message);
|
||||
|
||||
skill.Name = name.Trim();
|
||||
skill.DiceRollDefinition = expressionValidation.Value!.Canonical;
|
||||
skill.WildDice = optionsValidation.Value!.WildDice;
|
||||
skill.AllowFumble = optionsValidation.Value.AllowFumble;
|
||||
skill.SkillGroupId = resolvedSkillGroupId.Value;
|
||||
TouchCampaignLocked(campaign.Id);
|
||||
|
||||
PersistStateLocked();
|
||||
@@ -584,6 +675,20 @@ public sealed class GameService : IGameService
|
||||
return $"{dicePart}{modifierPart}={total}";
|
||||
}
|
||||
|
||||
private ServiceResult<Guid?> ResolveSkillGroupForSkillChangeLocked(Guid? requestedSkillGroupId, Guid characterId)
|
||||
{
|
||||
if (!requestedSkillGroupId.HasValue)
|
||||
return ServiceResult<Guid?>.Success(null);
|
||||
|
||||
if (!m_SkillGroupsById.TryGetValue(requestedSkillGroupId.Value, out var skillGroup))
|
||||
return ServiceResult<Guid?>.Failure("skill_group_not_found", "Skill group was not found.");
|
||||
|
||||
if (skillGroup.CharacterId != characterId)
|
||||
return ServiceResult<Guid?>.Failure("invalid_skill_group", "Skill group must belong to the same character.");
|
||||
|
||||
return ServiceResult<Guid?>.Success(skillGroup.Id);
|
||||
}
|
||||
|
||||
private ServiceResult<RollVisibility> ParseVisibility(string visibility)
|
||||
{
|
||||
if (string.Equals(visibility, "public", StringComparison.OrdinalIgnoreCase))
|
||||
@@ -625,9 +730,14 @@ public sealed class GameService : IGameService
|
||||
return new(character.Id, character.Name, character.OwnerUserId, character.CampaignId);
|
||||
}
|
||||
|
||||
private static SkillGroupSummary ToSkillGroupSummary(SkillGroup skillGroup)
|
||||
{
|
||||
return new(skillGroup.Id, skillGroup.CharacterId, skillGroup.Name);
|
||||
}
|
||||
|
||||
private static SkillSummary ToSkillSummary(Skill skill)
|
||||
{
|
||||
return new(skill.Id, skill.CharacterId, skill.Name, skill.DiceRollDefinition, skill.WildDice, skill.AllowFumble);
|
||||
return new(skill.Id, skill.CharacterId, skill.SkillGroupId, skill.Name, skill.DiceRollDefinition, skill.WildDice, skill.AllowFumble);
|
||||
}
|
||||
|
||||
private static RollResult ToRollResult(RollLogEntry entry, IReadOnlyList<RollDieResult> dice)
|
||||
@@ -731,6 +841,7 @@ public sealed class GameService : IGameService
|
||||
var sessions = db.Sessions.AsNoTracking().ToList();
|
||||
var campaigns = db.Campaigns.AsNoTracking().ToList();
|
||||
var characters = db.Characters.AsNoTracking().ToList();
|
||||
var skillGroups = db.SkillGroups.AsNoTracking().ToList();
|
||||
var skills = db.Skills.AsNoTracking().ToList();
|
||||
var logEntries = db.RollLogEntries.AsNoTracking().ToList().OrderBy(x => x.TimestampUtc).ThenBy(x => x.Id).ToList();
|
||||
|
||||
@@ -741,6 +852,7 @@ public sealed class GameService : IGameService
|
||||
m_SessionsByToken.Clear();
|
||||
m_CampaignsById.Clear();
|
||||
m_CharactersById.Clear();
|
||||
m_SkillGroupsById.Clear();
|
||||
m_SkillsById.Clear();
|
||||
m_RollLog.Clear();
|
||||
|
||||
@@ -774,6 +886,9 @@ public sealed class GameService : IGameService
|
||||
foreach (var character in characters)
|
||||
m_CharactersById[character.Id] = CloneCharacter(character);
|
||||
|
||||
foreach (var skillGroup in skillGroups)
|
||||
m_SkillGroupsById[skillGroup.Id] = CloneSkillGroup(skillGroup);
|
||||
|
||||
foreach (var skill in skills)
|
||||
m_SkillsById[skill.Id] = CloneSkill(skill);
|
||||
|
||||
@@ -788,6 +903,7 @@ public sealed class GameService : IGameService
|
||||
|
||||
db.RollLogEntries.ExecuteDelete();
|
||||
db.Skills.ExecuteDelete();
|
||||
db.SkillGroups.ExecuteDelete();
|
||||
db.Characters.ExecuteDelete();
|
||||
db.Campaigns.ExecuteDelete();
|
||||
db.Sessions.ExecuteDelete();
|
||||
@@ -797,6 +913,7 @@ public sealed class GameService : IGameService
|
||||
db.Sessions.AddRange(m_SessionsByToken.Values.Select(CloneSession));
|
||||
db.Campaigns.AddRange(m_CampaignsById.Values.Select(CloneCampaign));
|
||||
db.Characters.AddRange(m_CharactersById.Values.Select(CloneCharacter));
|
||||
db.SkillGroups.AddRange(m_SkillGroupsById.Values.Select(CloneSkillGroup));
|
||||
db.Skills.AddRange(m_SkillsById.Values.Select(CloneSkill));
|
||||
db.RollLogEntries.AddRange(m_RollLog.Select(CloneRollLogEntry));
|
||||
|
||||
@@ -861,6 +978,7 @@ public sealed class GameService : IGameService
|
||||
{
|
||||
Id = skill.Id,
|
||||
CharacterId = skill.CharacterId,
|
||||
SkillGroupId = skill.SkillGroupId,
|
||||
Name = skill.Name,
|
||||
DiceRollDefinition = skill.DiceRollDefinition,
|
||||
WildDice = skill.WildDice,
|
||||
@@ -868,6 +986,16 @@ public sealed class GameService : IGameService
|
||||
};
|
||||
}
|
||||
|
||||
private static SkillGroup CloneSkillGroup(SkillGroup skillGroup)
|
||||
{
|
||||
return new()
|
||||
{
|
||||
Id = skillGroup.Id,
|
||||
CharacterId = skillGroup.CharacterId,
|
||||
Name = skillGroup.Name
|
||||
};
|
||||
}
|
||||
|
||||
private static RollLogEntry CloneRollLogEntry(RollLogEntry entry)
|
||||
{
|
||||
return new()
|
||||
@@ -894,7 +1022,8 @@ public sealed class GameService : IGameService
|
||||
private readonly IPasswordHasher<UserAccount> m_PasswordHasher;
|
||||
private readonly List<RollLogEntry> m_RollLog = [];
|
||||
private readonly Dictionary<string, UserSession> m_SessionsByToken = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<Guid, SkillGroup> m_SkillGroupsById = [];
|
||||
private readonly Dictionary<Guid, Skill> m_SkillsById = [];
|
||||
private readonly Dictionary<string, Guid> m_UserIdsByUsername = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<Guid, UserAccount> m_UsersById = [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,15 +17,17 @@ public interface IGameService
|
||||
ServiceResult<CampaignDetails> GetCampaign(string sessionToken, Guid campaignId);
|
||||
|
||||
ServiceResult<CharacterSummary> CreateCharacter(string sessionToken, string name, Guid campaignId);
|
||||
ServiceResult<CharacterSummary> UpdateCharacter(string sessionToken, Guid characterId, string name, Guid campaignId);
|
||||
ServiceResult<CharacterSummary> UpdateCharacter(string sessionToken, Guid characterId, string name, Guid campaignId, string? ownerUsername = null);
|
||||
ServiceResult<bool> ActivateCharacter(string sessionToken, Guid characterId);
|
||||
ServiceResult<IReadOnlyList<CharacterSummary>> GetCurrentCampaignCharacters(string sessionToken);
|
||||
|
||||
ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble);
|
||||
ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble);
|
||||
ServiceResult<SkillGroupSummary> CreateSkillGroup(string sessionToken, Guid characterId, string name);
|
||||
ServiceResult<SkillGroupSummary> UpdateSkillGroup(string sessionToken, Guid skillGroupId, string name);
|
||||
ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null);
|
||||
ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null);
|
||||
|
||||
ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility);
|
||||
ServiceResult<IReadOnlyList<CampaignLogEntry>> GetCampaignLog(string sessionToken, Guid campaignId);
|
||||
|
||||
ServiceResult<long> GetCampaignVersion(string sessionToken, Guid campaignId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -330,6 +330,18 @@ select:focus-visible {
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.skill-group-block {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.skill-group-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.skill-item {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
|
||||
180
TECH.md
180
TECH.md
@@ -1,180 +0,0 @@
|
||||
# TECH - Kickoff Blueprint
|
||||
|
||||
## 0) Current scaffold status
|
||||
|
||||
- Root solution: `RpgRoller.sln`
|
||||
- Backend/full-stack project: `RpgRoller` (Minimal API + Blazor frontend host)
|
||||
- Frontend source: `RpgRoller/Components/*` + `RpgRoller/wwwroot/*`
|
||||
- Test project: `RpgRoller.Tests` (xUnit + `WebApplicationFactory` integration tests)
|
||||
- Test file split: concern-based API tests (`RpgRoller.Tests/Api/*`), service tests (`RpgRoller.Tests/Services/*`), and shared helpers (`RpgRoller.Tests/Support/*`)
|
||||
- Persistence: EF Core + SQLite (`RpgRoller/Data/RpgRollerDbContext.cs`) with in-memory runtime cache in `GameService`
|
||||
- OpenAPI source: `openapi/RpgRoller.json`
|
||||
- Local CI parity entrypoint: `scripts/ci-local.ps1`
|
||||
- API endpoint modules: `RpgRoller/Api/*Endpoints.cs` + shared session/auth helpers
|
||||
- Service boundary model: API request DTOs are mapped to explicit service method parameters before workflow execution
|
||||
- Current backend features: auth/session, campaign/character/skill management (including d6 wild-dice/fumble skill options), ruleset-aware rolls, filtered campaign logs, and SSE state updates.
|
||||
- Current frontend features: Blazor-based authenticated campaign workspace with live log updates, full roll workflow controls, and die-state visualization for roll outcomes.
|
||||
|
||||
## 1) Stack and baseline choices
|
||||
|
||||
- ASP.NET Core Minimal API on .NET 10.
|
||||
- EF Core with SQLite file persistence in current project (single-node deployment).
|
||||
- Game state is hydrated once on startup and then served from in-memory state; writes are persisted back to SQLite after successful mutations.
|
||||
- Cookie authentication (`HttpOnly`, `SameSite=Strict`, secure in production).
|
||||
- Blazor frontend host with Razor components and minimal JS interop for browser APIs.
|
||||
- OpenAPI generated from backend as contract documentation.
|
||||
- xUnit integration-heavy test suite with isolated SQLite test databases and coverage gates.
|
||||
|
||||
## 2) Architecture patterns to keep
|
||||
|
||||
### 2.1 API shape and layering
|
||||
|
||||
- Route mapping in thin endpoint modules (`MapXEndpoints` per feature area).
|
||||
- Domain logic in workflow services (`*WorkflowService`) instead of endpoint lambdas.
|
||||
- Service responses normalized via `ServiceResult<T>` + `ServiceError`, then mapped to HTTP at the edge.
|
||||
- Consistent `ProblemDetails` payloads with `error` extension for machine-usable errors.
|
||||
- Endpoint-level concerns handled by endpoint filters (`AdminOnlyFilter`, `PhaseRequirementFilter`, `PhaseOrJokerFilter`).
|
||||
|
||||
Keep this split:
|
||||
- Endpoint adapters: auth, deserialization, HTTP mapping only.
|
||||
- Workflow services: validation, query/update rules, transactions.
|
||||
- Helpers: shared utility and security-sensitive routines.
|
||||
|
||||
### 2.2 Middleware pipeline discipline
|
||||
|
||||
- Security and behavior depend on middleware ordering; keep explicit ordering.
|
||||
- Important current order:
|
||||
1. Forwarded headers
|
||||
2. Rate limiter
|
||||
3. HSTS + HTTPS redirect (prod)
|
||||
4. Security headers writer
|
||||
5. Base path
|
||||
6. Global exception handling
|
||||
7. Authentication
|
||||
8. Ensure player still exists
|
||||
9. CSRF origin/referer checks
|
||||
10. Authorization
|
||||
11. State change notifier middleware
|
||||
12. Static files
|
||||
13. Endpoint mapping
|
||||
|
||||
### 2.3 State synchronization
|
||||
|
||||
- Event-driven invalidation with SSE (`/api/events/state`) plus heartbeats.
|
||||
- Conditional reads for state (`ETag` + `If-None-Match`) to return `304`.
|
||||
- In-process notifier (`StateChangeNotifier`) with monotonic version and etag stamp.
|
||||
- Mutation middleware (`StateChangeNotificationMiddleware`) emits invalidation only for successful mutating API calls.
|
||||
|
||||
This pattern is a strong baseline for low to medium scale and should be the default in the new app.
|
||||
|
||||
### 2.4 Security baseline
|
||||
|
||||
- Cookie auth with short/medium session sliding expiration plus absolute lifetime cap.
|
||||
- Explicit same-origin CSRF checks for authenticated mutating API calls.
|
||||
- Rate limiting on auth-sensitive and admin-sensitive surfaces with custom `429` payload.
|
||||
- Security headers on all responses (`CSP`, `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, `Permissions-Policy`).
|
||||
- Forwarded headers restricted to configured trusted proxies/networks only.
|
||||
- Owner/admin protection rules enforced in business logic and DB constraints.
|
||||
- Destructive admin operations require password re-confirmation.
|
||||
- Password hashing is versioned and supports transparent upgrade on successful auth.
|
||||
- Current hash defaults to Argon2id, with forward compatibility retained via versioning.
|
||||
|
||||
### 2.5 Data and invariants
|
||||
|
||||
- Strong DB models backed by SQLite
|
||||
- DB-level guardrails (trigger) to complement app-level checks
|
||||
- EF patterns:
|
||||
- `AsNoTracking()` for read-only queries
|
||||
- `ExecuteUpdateAsync` / `ExecuteDeleteAsync` for efficient bulk operations
|
||||
- Explicit transactions for multi-step destructive/admin operations
|
||||
- Conflict handling around unique constraints
|
||||
|
||||
### 2.6 Frontend architecture
|
||||
|
||||
- Blazor component tree rooted in `Components/App.razor` and `Components/Pages/Home.razor`.
|
||||
- Razor components are split into markup-first `.razor` files with behavior/state in paired `.razor.cs` code-behind classes.
|
||||
- `Home.razor` + `Home.razor.cs` are intentionally minimal and only manage loading/auth/workspace view-mode switching.
|
||||
- Authenticated workspace UI plus workspace state/behavior are centralized in `Components/Pages/Workspace.razor`.
|
||||
- Form UX state uses reusable `FormState<TModel>` containers in leaf controls (`HomeControls/*`) rather than parallel form/error/message property sets in `Home`.
|
||||
- Concern controls execute their own auth/campaign/character/skill mutation workflows and notify the workspace host only for shared-state refresh/orchestration.
|
||||
- Skill management workflows are owned by `CharacterPanel` to keep character-skill behavior cohesive.
|
||||
- Shared browser API interop is centralized in `RpgRollerApiClient` and reused by `Home`, `Workspace`, and concern controls.
|
||||
- Browser API calls and SSE are handled via `wwwroot/js/rpgroller-api.js` interop.
|
||||
- UI state is maintained server-side per circuit with session/tab persistence for campaign + screen selection.
|
||||
- SSE-driven campaign refresh with reconnect backoff and explicit offline/manual-refresh fallback.
|
||||
|
||||
### 2.7 Testing strategy patterns
|
||||
|
||||
- Full-stack integration tests via `WebApplicationFactory`.
|
||||
- Real migrations applied to in-memory Database during test host startup.
|
||||
- HTTP side effects mocked deterministically (`StubHttpMessageHandler` and `IHttpClientFactory` replacement).
|
||||
- Coverage-focused tests for:
|
||||
- auth/security rules
|
||||
- middleware behavior
|
||||
- filter behavior
|
||||
- link/vote/result edge cases
|
||||
- OpenAPI operation id stability
|
||||
- CI-local parity script (`scripts/ci-local.ps1`) mirrors pipeline flow.
|
||||
|
||||
### 2.8 Tooling and contract discipline
|
||||
|
||||
- OpenAPI generated during build (`openapi/RpgRoller.json`).
|
||||
- Separate build + tests + coverage threshold checks.
|
||||
- Build configured with warnings as errors in CI/local script.
|
||||
|
||||
## 3) Concrete feature set
|
||||
|
||||
Use this as a reusable "starter scope menu" for the new app:
|
||||
|
||||
- Auth:
|
||||
- register/login/logout
|
||||
- owner bootstrap via admin key
|
||||
- auth options endpoint for registration UX
|
||||
- Identity/session:
|
||||
- cookie claim identity with admin claim
|
||||
- stale/deleted-account cookie invalidation
|
||||
- absolute session lifetime enforcement
|
||||
- State:
|
||||
- `/api/state`, `/api/me`
|
||||
- SSE state invalidation
|
||||
- etag conditional state reads
|
||||
|
||||
## 4) New-project starter checklist
|
||||
|
||||
- Security:
|
||||
- cookie or token strategy finalized with CSRF model
|
||||
- rate limiting partitions and thresholds defined
|
||||
- strict CSP and security headers in first commit
|
||||
- versioned password hashing with migration strategy
|
||||
- trusted proxy/host settings explicit
|
||||
- Contract:
|
||||
- OpenAPI generation enabled in build
|
||||
- operation-id stability tested
|
||||
- Data integrity:
|
||||
- enforce critical invariants both app-side and DB-side
|
||||
- transaction boundaries for multi-entity admin actions
|
||||
- Frontend:
|
||||
- module boundaries and state refresh model defined
|
||||
- escaping/url-safe helpers mandatory
|
||||
- i18n structure and fallback behavior in place
|
||||
- Testing:
|
||||
- integration test host with real migrations
|
||||
- deterministic stubs for network dependencies
|
||||
- coverage gate enforced in local + CI scripts
|
||||
|
||||
## 7) Keep/avoid quick reference
|
||||
|
||||
Keep:
|
||||
- Thin endpoints + workflow services.
|
||||
- Shared service result abstraction.
|
||||
- Explicit middleware order.
|
||||
- SSE + ETag state sync.
|
||||
- DB-enforced invariants.
|
||||
- Regression tests for security-sensitive UI rendering.
|
||||
|
||||
Avoid:
|
||||
- Hard-coded workflow transitions scattered in backend/frontend.
|
||||
- Boolean-only role model for long-term products.
|
||||
- Unbounded in-memory caches.
|
||||
- Synchronous external network checks on hot write paths.
|
||||
- Manual API contract duplication between docs/frontend/backend.
|
||||
425
UX.md
425
UX.md
@@ -1,425 +0,0 @@
|
||||
# UX Design Specification - Frontend
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
This document defines the target frontend UX for RpgRoller based on `REQUIREMENTS.md` and the latest review edits.
|
||||
It is the design baseline we will iterate on before implementing new frontend code.
|
||||
|
||||
Primary goals:
|
||||
|
||||
- Make campaign play flow fast: select character, roll, see results.
|
||||
- Keep role rules clear: player, GM, and visibility boundaries.
|
||||
- Preserve trust: deterministic behavior, clear validation, append-only log feel.
|
||||
- Keep the interface simple for live-session use on desktop, tablet, and mobile.
|
||||
|
||||
## 2. Scope
|
||||
|
||||
In scope for this UX:
|
||||
|
||||
- Authentication (register/login/logout)
|
||||
- Two authenticated screens:
|
||||
- `Play` screen for character sheet and log
|
||||
- `Campaign Management` screen for campaign details and management actions
|
||||
- Character create/edit with picker-selected active context
|
||||
- Skill create/edit
|
||||
- Skill rolling with public/private visibility
|
||||
- Campaign log with live updates
|
||||
|
||||
Out of scope for v1 UX:
|
||||
|
||||
- Observer role management UI (future role)
|
||||
- Advanced profile settings
|
||||
- Campaign invitations or approvals
|
||||
- Log filtering/search/export
|
||||
- First-time onboarding tours
|
||||
|
||||
## 3. Personas and Permissions
|
||||
|
||||
### Player
|
||||
|
||||
- Can create and edit own characters.
|
||||
- Can create and edit skills on own characters.
|
||||
- Can roll own or authorized character skills.
|
||||
- Can see campaign log entries allowed by visibility rules.
|
||||
|
||||
### GM
|
||||
|
||||
- All player capabilities for owned or authorized characters.
|
||||
- Can create campaigns and choose rulesets.
|
||||
- Can edit characters and skills in campaigns they GM.
|
||||
- Can view all rolls in their campaigns, including private rolls.
|
||||
|
||||
### Future Observer (non-v1)
|
||||
|
||||
- Read-only campaign visibility, no mutation actions.
|
||||
|
||||
## 4. UX Principles
|
||||
|
||||
1. Context first: always show current campaign and active character status at top.
|
||||
2. One action, one confirmation: each mutation gives immediate success/failure feedback.
|
||||
3. Permission-aware UI: hide or disable actions the user cannot perform.
|
||||
4. Real-time clarity: live updates should feel stable, not jittery.
|
||||
5. Progressive disclosure: deep management stays outside the play screen.
|
||||
|
||||
## 5. Product Decisions from Review
|
||||
|
||||
The following decisions are now locked for v1:
|
||||
|
||||
1. Campaign switching persistence: per tab/session, not global account-level persistence.
|
||||
2. Edit UX: every edit uses an explicit edit button that opens a popup modal with a dedicated form and submit action.
|
||||
3. Private roll presentation: visually differentiate private entries depending on viewer perspective (GM vs roller).
|
||||
4. Log filtering: deferred, no filtering controls in v1.
|
||||
5. Onboarding: no onboarding hints in v1.
|
||||
|
||||
## 6. Information Architecture
|
||||
|
||||
The app has two top-level states:
|
||||
|
||||
- Unauthenticated
|
||||
- Authenticated
|
||||
|
||||
Authenticated state has two screens:
|
||||
|
||||
1. `Play` (default after login)
|
||||
2. `Campaign Management`
|
||||
|
||||
Global authenticated navigation:
|
||||
|
||||
- Header includes app identity, current user chip, campaign context, connection status, and screen switch control.
|
||||
- Screen switch is always available: `Play` <-> `Campaign Management`.
|
||||
|
||||
## 7. Global Layout
|
||||
|
||||
### 7.1 Play Screen - Desktop (>= 1024px)
|
||||
|
||||
- Top sticky header.
|
||||
- Main body is exactly two columns:
|
||||
- Left column (2/3 width): character-focused area.
|
||||
- Right column (1/3 width): live campaign log.
|
||||
|
||||
Left column contains:
|
||||
|
||||
- Character picker (icon tab strip)
|
||||
- One character sheet (selected character only)
|
||||
- Skills list for selected character
|
||||
- Roll controls
|
||||
- Last roll summary
|
||||
|
||||
Right column contains:
|
||||
|
||||
- Campaign log feed only
|
||||
|
||||
Campaign details and management controls are excluded from this screen.
|
||||
|
||||
### 7.2 Play Screen - Tablet and Mobile (< 1024px)
|
||||
|
||||
- Single active panel area.
|
||||
- Bottom navigation bar toggles between two views:
|
||||
- `Character`
|
||||
- `Log`
|
||||
- Character and log are not shown side-by-side on tablet/mobile.
|
||||
- Header remains visible with context and connection status.
|
||||
|
||||
### 7.3 Campaign Management Screen (all breakpoints)
|
||||
|
||||
- Focused on campaign setup and administration.
|
||||
- Contains campaign details, campaign creation, campaign selection, and management operations.
|
||||
- Character sheet and rolling controls are not primary in this screen.
|
||||
|
||||
## 8. Key User Flows
|
||||
|
||||
### 8.1 First-time GM flow
|
||||
|
||||
1. Register
|
||||
2. Login
|
||||
3. Open `Campaign Management`
|
||||
4. Create campaign with ruleset
|
||||
5. Create or move character into campaign
|
||||
6. Select character in picker (selection becomes active context)
|
||||
7. Switch to `Play`
|
||||
8. Create skill
|
||||
9. Roll skill (public/private)
|
||||
10. Confirm result appears in log
|
||||
|
||||
### 8.2 Player participation flow
|
||||
|
||||
1. Register/login
|
||||
2. Open `Campaign Management`
|
||||
3. Select accessible campaign
|
||||
4. Create character in that campaign
|
||||
5. Select character in picker (selection becomes active context)
|
||||
6. Switch to `Play`
|
||||
7. Add or edit skills
|
||||
8. Roll and view log with visibility rules
|
||||
|
||||
### 8.3 GM oversight flow
|
||||
|
||||
1. Open `Play`
|
||||
2. Switch between characters with icon tabs
|
||||
3. Observe roll events in live log
|
||||
4. Move to `Campaign Management` when campaign setup or details edits are needed
|
||||
|
||||
## 9. Screen Specifications
|
||||
|
||||
## 9.1 App Load and Session Restore
|
||||
|
||||
States:
|
||||
|
||||
- Initial: "Connecting..."
|
||||
- Healthy + anonymous: show auth view
|
||||
- Healthy + logged in: show `Play` screen
|
||||
- API unhealthy: show non-blocking banner with retry CTA
|
||||
|
||||
Behavior:
|
||||
|
||||
- Call health endpoint first, then rulesets and session context.
|
||||
- If session invalid or expired, clear local authenticated state and show auth view.
|
||||
|
||||
## 9.2 Authentication View
|
||||
|
||||
Elements:
|
||||
|
||||
- Register card:
|
||||
- Username
|
||||
- Display name
|
||||
- Password
|
||||
- Submit
|
||||
- Login card:
|
||||
- Username
|
||||
- Password
|
||||
- Submit
|
||||
|
||||
Validation:
|
||||
|
||||
- Username required
|
||||
- Display name required (register only)
|
||||
- Password required, min length 8 for register
|
||||
|
||||
Feedback:
|
||||
|
||||
- Inline field errors pre-submit
|
||||
- Top-level form error for server rejects (duplicate username, invalid credentials)
|
||||
- Success message after register
|
||||
|
||||
## 9.3 Shared Header (Authenticated)
|
||||
|
||||
Shows:
|
||||
|
||||
- Logged-in display name + username
|
||||
- Current campaign name (or "No campaign selected")
|
||||
- Active character summary (or "None selected")
|
||||
- Live state indicator:
|
||||
- Connected
|
||||
- Reconnecting
|
||||
- Offline fallback
|
||||
|
||||
Actions:
|
||||
|
||||
- Switch screen (`Play` / `Campaign Management`)
|
||||
- Manual refresh
|
||||
- Logout
|
||||
|
||||
## 9.4 Play Screen - Character Column (2/3)
|
||||
|
||||
### Character Picker
|
||||
|
||||
- Horizontal icon tab control.
|
||||
- One icon per available character in the current campaign scope.
|
||||
- Active character has persistent visual highlight.
|
||||
- Selecting a tab changes the character sheet context.
|
||||
|
||||
### Character Sheet
|
||||
|
||||
Shows selected character details:
|
||||
|
||||
- Character name
|
||||
- Owner
|
||||
- Campaign affiliation
|
||||
- Active badge shown for the currently selected picker character
|
||||
|
||||
Actions:
|
||||
|
||||
- `Edit Character` button -> opens modal form
|
||||
|
||||
### Skills and Roll Commands
|
||||
|
||||
Shows selected character skills list and selected skill details.
|
||||
|
||||
Actions:
|
||||
|
||||
- `Create Skill` button -> modal form
|
||||
- `Edit Skill` button -> modal form
|
||||
- d6 skill forms include:
|
||||
- `Wild dice` numeric field
|
||||
- `Allow fumble` toggle
|
||||
- Roll command panel:
|
||||
- Visibility selector (`public`, `private`)
|
||||
- `Roll Skill` primary action
|
||||
|
||||
Last roll card:
|
||||
|
||||
- Result total
|
||||
- Breakdown
|
||||
- Die-by-die visualization with states: `critical`, `fumble`, `wild`, `removed`, `added`
|
||||
- Visibility
|
||||
- Timestamp
|
||||
|
||||
## 9.5 Play Screen - Log Column (1/3)
|
||||
|
||||
Shows chronological roll entries with:
|
||||
|
||||
- Roller identity
|
||||
- Character + skill
|
||||
- Result and breakdown
|
||||
- Die-by-die visualization with states: `critical`, `fumble`, `wild`, `removed`, `added`
|
||||
- Visibility badge
|
||||
- Timestamp
|
||||
|
||||
Visibility rules:
|
||||
|
||||
- Public rolls visible to all participants.
|
||||
- Private rolls visible only to roller and GM.
|
||||
- No placeholders for hidden private rolls.
|
||||
|
||||
Perspective styling for private rolls:
|
||||
|
||||
- Roller view: private badge style A (for "you rolled private")
|
||||
- GM view: private badge style B (for "GM-visible private")
|
||||
|
||||
## 9.6 Campaign Management Screen
|
||||
|
||||
This screen contains all campaign details and management options.
|
||||
|
||||
Sections:
|
||||
|
||||
1. Campaign selector and campaign summary
|
||||
2. Create campaign form (name + ruleset)
|
||||
3. Campaign details card (name, ruleset, GM)
|
||||
4. Character management list and actions
|
||||
|
||||
Edit pattern:
|
||||
|
||||
- All edits use explicit edit buttons.
|
||||
- Edit buttons open popup modals with dedicated forms.
|
||||
- No inline row editing.
|
||||
|
||||
## 9.7 Tablet/Mobile Bottom Bar Behavior
|
||||
|
||||
Applies to both tablet and mobile in `Play` screen.
|
||||
|
||||
Bottom bar items:
|
||||
|
||||
- `Character`
|
||||
- `Log`
|
||||
|
||||
Rules:
|
||||
|
||||
- Exactly one panel active at a time.
|
||||
- Active tab state is preserved while user remains on current page session.
|
||||
- Roll action remains accessible in `Character` panel.
|
||||
- Connection status remains visible in header regardless of active panel.
|
||||
|
||||
## 10. Validation and Error UX
|
||||
|
||||
Pre-submit client validation:
|
||||
|
||||
- Required field checks for all forms
|
||||
- Password minimum length on register
|
||||
- Local non-empty checks for names and expressions
|
||||
|
||||
Server error mapping:
|
||||
|
||||
| Backend condition | UX behavior |
|
||||
|---|---|
|
||||
| `invalid_username`, `invalid_display_name`, `invalid_password` | Show field-level + summary errors in auth forms |
|
||||
| `duplicate_username` | Register username field error with suggestion to choose another |
|
||||
| `invalid_credentials` | Login summary error only (no account existence hints) |
|
||||
| `invalid_campaign_name`, `invalid_character_name`, `invalid_skill_name` | Field-level inline error in modal form |
|
||||
| `invalid_ruleset`, expression validation failures, `invalid_visibility` | Field-level error and helper text with valid examples |
|
||||
| `campaign_not_found`, `character_not_found`, `skill_not_found` | Non-blocking panel alert + auto-refresh relevant list |
|
||||
| `forbidden` | Explain permission rule and remove unavailable actions |
|
||||
| `unauthorized` | Clear session and return to auth view with message |
|
||||
| `no_active_character` | Prompt user to choose a character in picker and auto-sync active context |
|
||||
|
||||
## 11. Empty, Loading, and Disabled States
|
||||
|
||||
Required empty states:
|
||||
|
||||
- No campaigns yet
|
||||
- No characters in selected campaign
|
||||
- No skills for selected character
|
||||
- No log entries yet
|
||||
- No active character
|
||||
|
||||
Loading patterns:
|
||||
|
||||
- Skeleton rows for lists
|
||||
- Disabled form actions with spinner when mutation is in flight
|
||||
- Maintain prior data view during refresh to reduce layout jumps
|
||||
|
||||
## 12. Real-Time and Sync Rules
|
||||
|
||||
- `Play` screen log is campaign-scoped.
|
||||
- Subscribe to SSE only when:
|
||||
- user is authenticated
|
||||
- campaign is selected
|
||||
- On state event:
|
||||
- refresh current character context data
|
||||
- refresh campaign log
|
||||
- On SSE failure:
|
||||
- exponential backoff reconnect
|
||||
- status indicator switches to "Reconnecting"
|
||||
- manual refresh remains available
|
||||
|
||||
## 13. Accessibility Requirements
|
||||
|
||||
- Full keyboard operation for all controls.
|
||||
- Visible focus ring and logical tab order.
|
||||
- Labels for every input (placeholder is not label).
|
||||
- Contrast target >= WCAG AA.
|
||||
- Screen reader announcements for:
|
||||
- global success/error toasts
|
||||
- roll result updates
|
||||
- reconnect state changes
|
||||
- Icon-tab character picker must include readable text labels for assistive tech.
|
||||
|
||||
## 14. Content and Copy Guidance
|
||||
|
||||
- Use plain language and short action labels.
|
||||
- Keep critical actions explicit:
|
||||
- "Create Campaign"
|
||||
- "Roll Skill"
|
||||
- Error tone: corrective and direct.
|
||||
- Date/time: local display with ISO tooltip.
|
||||
|
||||
## 15. Visual Direction (for implementation phase)
|
||||
|
||||
- Theme: tabletop utility UI with clear hierarchy, not a generic admin dashboard.
|
||||
- Emphasize readability for dense gameplay sessions.
|
||||
- Character picker should use icon tabs with strong selected-state affordance.
|
||||
- Use color semantics:
|
||||
- success for roll completion
|
||||
- warning for validation needs
|
||||
- neutral role/visibility badges
|
||||
- Keep motion purposeful:
|
||||
- subtle log insert transitions
|
||||
- connection state transitions
|
||||
|
||||
## 16. Requirements Traceability
|
||||
|
||||
| Requirement area | UX coverage |
|
||||
|---|---|
|
||||
| User management | Auth view with register/login/logout, secure failure messaging |
|
||||
| Campaign management | Dedicated `Campaign Management` screen with ruleset assignment and details |
|
||||
| Character management | Character picker + modal edit/create flows with selected-character active context |
|
||||
| Active character context | Persistent header context and activation CTA |
|
||||
| Skill management | Skill create/edit modals + ruleset-aware validation feedback |
|
||||
| Dice rolling | Visibility toggle + deterministic result display in `Play` screen |
|
||||
| Campaign log | Dedicated log column (desktop) and log tab (tablet/mobile) with permission filtering |
|
||||
| Edge constraints | Disabled/forbidden/unauthorized handling and recovery UX |
|
||||
|
||||
## 17. Next Iteration Targets
|
||||
|
||||
1. Define low-fidelity wireframes for `Play` and `Campaign Management`.
|
||||
2. Define exact component contracts (props/state/events) for the frontend module split.
|
||||
3. Define visual tokens (spacing, type scale, color roles) for implementation.
|
||||
Reference in New Issue
Block a user