Implement d6 wild dice/fumble skills and die-state rolls
This commit is contained in:
9
FAQ.md
9
FAQ.md
@@ -41,3 +41,12 @@ Coverage now includes the entire backend project (`RpgRoller`), including API/ho
|
|||||||
## Why do backend services avoid API request DTO dependencies?
|
## 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.
|
Service workflows accept explicit parameters (for example, `CreateCampaign(sessionToken, name, rulesetId)`) instead of API request DTOs. This keeps the service layer independent from HTTP transport contracts while avoiding extra service-only wrapper command types.
|
||||||
|
|
||||||
|
## How do d6 wild dice and fumbles work now?
|
||||||
|
|
||||||
|
d6 skills now store two explicit options:
|
||||||
|
|
||||||
|
- `wildDice`: number of wild dice for the skill
|
||||||
|
- `allowFumble`: whether wild dice rolling `1` can trigger fumble removal
|
||||||
|
|
||||||
|
Roll responses also include per-die state flags (`crit`, `fumble`, `wild`, `removed`, `added`) so the frontend can render the full die-by-die outcome, not just the final total.
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ Tracking against `UX.md` tasks and decisions.
|
|||||||
| 9.1 App load + session restore | Implemented | Health check on load, rulesets/session load, unauthorized session reset, API unhealthy retry banner. |
|
| 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.2 Authentication view | Implemented | Register/login cards, required validation, register password length check, server-error display. |
|
||||||
| 9.3 Shared authenticated header | Implemented | User chip, campaign/active context, connection state, screen switch, refresh, logout. |
|
| 9.3 Shared authenticated header | Implemented | User chip, campaign/active context, connection state, screen switch, refresh, logout. |
|
||||||
| 9.4 Play screen character column | Implemented | Character icon tabs, sheet, modal edit/create flows, activate action, skill list, roll controls, last roll card. |
|
| 9.4 Play screen character column | Implemented | Character icon tabs, sheet, modal edit/create flows, activate action, skill list, d6 skill options (wild/fumble), roll controls, and die-state visualized last roll card. |
|
||||||
| 9.5 Play screen log column | Implemented | Chronological feed, private/public badges, private perspective styles (roller vs GM), local time + ISO tooltip. |
|
| 9.5 Play screen log column | Implemented | Chronological feed, private/public badges, private perspective styles (roller vs GM), local time + ISO tooltip. |
|
||||||
| 9.6 Campaign management screen | Implemented | Campaign selector/summary, create form, details card, character management actions with modal edit pattern. |
|
| 9.6 Campaign management screen | Implemented | Campaign selector/summary, create form, details card, character management actions with modal edit pattern. |
|
||||||
| 9.7 Tablet/mobile bottom bar | Implemented | `Character` / `Log` panel switch in play screen and per-tab session persistence. |
|
| 9.7 Tablet/mobile bottom bar | Implemented | `Character` / `Log` panel switch in play screen and per-tab session persistence. |
|
||||||
|
|||||||
@@ -83,8 +83,8 @@ To use a custom SQLite database path, set `ConnectionStrings__RpgRoller`.
|
|||||||
- Rulesets: d6 and dnd5e validation rules
|
- Rulesets: d6 and dnd5e validation rules
|
||||||
- Campaigns: create/list/read
|
- Campaigns: create/list/read
|
||||||
- Characters: create/update/activate/current-campaign list
|
- Characters: create/update/activate/current-campaign list
|
||||||
- Skills: create/update with ruleset-aware dice expression validation
|
- 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
|
- 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
|
- State stream: SSE endpoint for campaign version updates
|
||||||
|
|
||||||
## Implemented Frontend Scope
|
## Implemented Frontend Scope
|
||||||
@@ -94,8 +94,9 @@ To use a custom SQLite database path, set `ConnectionStrings__RpgRoller`.
|
|||||||
- play screen and campaign management screen switch
|
- play screen and campaign management screen switch
|
||||||
- campaign creation and selection
|
- campaign creation and selection
|
||||||
- character create/edit/activate via modal forms
|
- character create/edit/activate via modal forms
|
||||||
- skill create/edit via modal forms
|
- skill create/edit via modal forms including d6 wild dice + allow-fumble controls
|
||||||
- public/private rolling and campaign log viewing
|
- public/private rolling and campaign log viewing
|
||||||
|
- die-state visualization in Last Roll (critical, fumble, wild, removed, added)
|
||||||
- responsive play UX:
|
- responsive play UX:
|
||||||
- desktop two-column (character + log)
|
- desktop two-column (character + log)
|
||||||
- tablet/mobile panel switching with bottom tab bar (`Character` / `Log`)
|
- tablet/mobile panel switching with bottom tab bar (`Character` / `Log`)
|
||||||
|
|||||||
@@ -98,6 +98,8 @@
|
|||||||
|
|
||||||
* name
|
* name
|
||||||
* diceRollDefinition (ruleset-compliant expression, e.g. `5D+4`, `2d12+2`)
|
* 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:
|
* Behavior:
|
||||||
|
|
||||||
* Can be rolled
|
* Can be rolled
|
||||||
@@ -198,6 +200,7 @@
|
|||||||
* System must:
|
* System must:
|
||||||
|
|
||||||
* Validate dice expressions against the campaign ruleset
|
* Validate dice expressions against the campaign ruleset
|
||||||
|
* Validate d6 skill options (`wildDice`, `allowFumble`) as part of skill create/edit
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -213,7 +216,9 @@
|
|||||||
* System must:
|
* System must:
|
||||||
|
|
||||||
* Evaluate dice expressions deterministically and fairly
|
* Evaluate dice expressions deterministically and fairly
|
||||||
|
* For d6 skills, apply wild-die explosions and fumble-removal logic
|
||||||
* Record all rolls in the campaign log
|
* Record all rolls in the campaign log
|
||||||
|
* Return die-by-die roll states so the frontend can visualize critical/fumble/wild/removed/added outcomes
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -263,6 +268,7 @@
|
|||||||
### 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 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.
|
* As a **GM**, I want to edit character skills so that I can enforce or adjust rules.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -270,6 +276,7 @@
|
|||||||
### Dice Rolling
|
### 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 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 **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.
|
* As a **GM**, I want to see all rolls (including private ones) so that I can oversee the game.
|
||||||
|
|
||||||
|
|||||||
@@ -34,19 +34,23 @@ public sealed class CampaignApiTests : ApiTestBase
|
|||||||
var createdSkill = await PostAsync<CreateSkillRequest, SkillSummary>(
|
var createdSkill = await PostAsync<CreateSkillRequest, SkillSummary>(
|
||||||
gmClient,
|
gmClient,
|
||||||
$"/api/characters/{gmCharacter.Id}/skills",
|
$"/api/characters/{gmCharacter.Id}/skills",
|
||||||
new CreateSkillRequest("Arcana", "2d12+2"));
|
new CreateSkillRequest("Arcana", "2d12+2", 0, false));
|
||||||
Assert.Equal("2d12+2", createdSkill.DiceRollDefinition);
|
Assert.Equal("2d12+2", createdSkill.DiceRollDefinition);
|
||||||
|
Assert.Equal(0, createdSkill.WildDice);
|
||||||
|
Assert.False(createdSkill.AllowFumble);
|
||||||
|
|
||||||
var updatedSkill = await PutAsync<UpdateSkillRequest, SkillSummary>(
|
var updatedSkill = await PutAsync<UpdateSkillRequest, SkillSummary>(
|
||||||
gmClient,
|
gmClient,
|
||||||
$"/api/skills/{createdSkill.Id}",
|
$"/api/skills/{createdSkill.Id}",
|
||||||
new UpdateSkillRequest("Arcana Mastery", "2d12+3"));
|
new UpdateSkillRequest("Arcana Mastery", "2d12+3", 0, false));
|
||||||
Assert.Equal("Arcana Mastery", updatedSkill.Name);
|
Assert.Equal("Arcana Mastery", updatedSkill.Name);
|
||||||
Assert.Equal("2d12+3", updatedSkill.DiceRollDefinition);
|
Assert.Equal("2d12+3", updatedSkill.DiceRollDefinition);
|
||||||
|
Assert.Equal(0, updatedSkill.WildDice);
|
||||||
|
Assert.False(updatedSkill.AllowFumble);
|
||||||
|
|
||||||
var invalidSkill = await gmClient.PostAsJsonAsync(
|
var invalidSkill = await gmClient.PostAsJsonAsync(
|
||||||
$"/api/characters/{gmCharacter.Id}/skills",
|
$"/api/characters/{gmCharacter.Id}/skills",
|
||||||
new CreateSkillRequest("Broken", "5D+4"));
|
new CreateSkillRequest("Broken", "5D+4", 0, false));
|
||||||
Assert.Equal(HttpStatusCode.BadRequest, invalidSkill.StatusCode);
|
Assert.Equal(HttpStatusCode.BadRequest, invalidSkill.StatusCode);
|
||||||
|
|
||||||
var details = await GetAsync<CampaignDetails>(gmClient, $"/api/campaigns/{campaign.Id}");
|
var details = await GetAsync<CampaignDetails>(gmClient, $"/api/campaigns/{campaign.Id}");
|
||||||
|
|||||||
@@ -35,7 +35,9 @@ public sealed class RollVisibilityApiTests : ApiTestBase
|
|||||||
var skill = await PostAsync<CreateSkillRequest, SkillSummary>(
|
var skill = await PostAsync<CreateSkillRequest, SkillSummary>(
|
||||||
playerClient,
|
playerClient,
|
||||||
$"/api/characters/{playerCharacter.Id}/skills",
|
$"/api/characters/{playerCharacter.Id}/skills",
|
||||||
new CreateSkillRequest("Stealth", "2D+1"));
|
new CreateSkillRequest("Stealth", "2D+1", 1, true));
|
||||||
|
Assert.Equal(1, skill.WildDice);
|
||||||
|
Assert.True(skill.AllowFumble);
|
||||||
|
|
||||||
await RegisterAsync(observerClient, "observer", "Password123", "Observer");
|
await RegisterAsync(observerClient, "observer", "Password123", "Observer");
|
||||||
await LoginAsync(observerClient, "observer", "Password123");
|
await LoginAsync(observerClient, "observer", "Password123");
|
||||||
|
|||||||
@@ -88,8 +88,8 @@ public sealed class ServiceCampaignTests
|
|||||||
var ownerCharacter = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Owner Character", campaign.Id));
|
var ownerCharacter = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Owner Character", campaign.Id));
|
||||||
var otherCharacter = ServiceTestSupport.GetValue(service.CreateCharacter(otherSession, "Other Character", campaign.Id));
|
var otherCharacter = ServiceTestSupport.GetValue(service.CreateCharacter(otherSession, "Other Character", campaign.Id));
|
||||||
|
|
||||||
var ownerSkill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, ownerCharacter.Id, "Stealth", "2D+1"));
|
var ownerSkill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, ownerCharacter.Id, "Stealth", "2D+1", 1, true));
|
||||||
_ = ServiceTestSupport.GetValue(service.CreateSkill(otherSession, otherCharacter.Id, "Perception", "1D+2"));
|
_ = ServiceTestSupport.GetValue(service.CreateSkill(otherSession, otherCharacter.Id, "Perception", "1D+2", 1, true));
|
||||||
|
|
||||||
var ownerView = ServiceTestSupport.GetValue(service.GetCampaign(ownerSession, campaign.Id));
|
var ownerView = ServiceTestSupport.GetValue(service.GetCampaign(ownerSession, campaign.Id));
|
||||||
Assert.Single(ownerView.Characters);
|
Assert.Single(ownerView.Characters);
|
||||||
|
|||||||
80
RpgRoller.Tests/Services/ServiceD6RollTests.cs
Normal file
80
RpgRoller.Tests/Services/ServiceD6RollTests.cs
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
namespace RpgRoller.Tests;
|
||||||
|
|
||||||
|
public sealed class ServiceD6RollTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void RollSkill_D6WildCritical_AddsExtraDieAndTracksFlags()
|
||||||
|
{
|
||||||
|
using var harness = ServiceTestSupport.CreateHarness(6, 4, 2);
|
||||||
|
var service = harness.Service;
|
||||||
|
|
||||||
|
service.Register("gm", "Password123", "GM");
|
||||||
|
var session = ServiceTestSupport.GetValue(service.Login("gm", "Password123")).SessionToken;
|
||||||
|
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(session, "Main", "d6"));
|
||||||
|
var character = ServiceTestSupport.GetValue(service.CreateCharacter(session, "Hero", campaign.Id));
|
||||||
|
var skill = ServiceTestSupport.GetValue(service.CreateSkill(session, character.Id, "Blaster", "2D+1", 1, true));
|
||||||
|
|
||||||
|
var roll = ServiceTestSupport.GetValue(service.RollSkill(session, skill.Id, "public"));
|
||||||
|
|
||||||
|
Assert.Equal(13, roll.Result);
|
||||||
|
Assert.Equal("6+4+2+1=13", roll.Breakdown);
|
||||||
|
Assert.Equal(3, roll.Dice.Count);
|
||||||
|
Assert.True(roll.Dice[0].Wild);
|
||||||
|
Assert.True(roll.Dice[0].Crit);
|
||||||
|
Assert.True(roll.Dice[2].Added);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RollSkill_D6Fumble_RemovesHighestDieAndPreservesFumbleDie()
|
||||||
|
{
|
||||||
|
using var harness = ServiceTestSupport.CreateHarness(1, 3, 6, 1);
|
||||||
|
var service = harness.Service;
|
||||||
|
|
||||||
|
service.Register("gm", "Password123", "GM");
|
||||||
|
var session = ServiceTestSupport.GetValue(service.Login("gm", "Password123")).SessionToken;
|
||||||
|
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(session, "Main", "d6"));
|
||||||
|
var character = ServiceTestSupport.GetValue(service.CreateCharacter(session, "Hero", campaign.Id));
|
||||||
|
var skill = ServiceTestSupport.GetValue(service.CreateSkill(session, character.Id, "Brawl", "3D", 1, true));
|
||||||
|
|
||||||
|
var roll = ServiceTestSupport.GetValue(service.RollSkill(session, skill.Id, "public"));
|
||||||
|
|
||||||
|
Assert.Equal(4, roll.Result);
|
||||||
|
Assert.Equal("1+3=4", roll.Breakdown);
|
||||||
|
Assert.Equal(3, roll.Dice.Count);
|
||||||
|
Assert.True(roll.Dice[0].Fumble);
|
||||||
|
Assert.True(roll.Dice[0].Wild);
|
||||||
|
Assert.True(roll.Dice[2].Removed);
|
||||||
|
Assert.False(roll.Dice[2].Crit);
|
||||||
|
Assert.False(roll.Dice[2].Fumble);
|
||||||
|
|
||||||
|
var noFumbleSkill = ServiceTestSupport.GetValue(service.CreateSkill(session, character.Id, "Calm", "1D", 1, false));
|
||||||
|
var noFumbleRoll = ServiceTestSupport.GetValue(service.RollSkill(session, noFumbleSkill.Id, "public"));
|
||||||
|
Assert.False(noFumbleRoll.Dice[0].Fumble);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SkillOptions_AreValidatedForD6AndIgnoredForDnd5e()
|
||||||
|
{
|
||||||
|
using var harness = ServiceTestSupport.CreateHarness(2, 2);
|
||||||
|
var service = harness.Service;
|
||||||
|
|
||||||
|
service.Register("gm", "Password123", "GM");
|
||||||
|
var session = ServiceTestSupport.GetValue(service.Login("gm", "Password123")).SessionToken;
|
||||||
|
|
||||||
|
var d6Campaign = ServiceTestSupport.GetValue(service.CreateCampaign(session, "D6", "d6"));
|
||||||
|
var d6Character = ServiceTestSupport.GetValue(service.CreateCharacter(session, "D6 Hero", d6Campaign.Id));
|
||||||
|
|
||||||
|
var missingWildDice = service.CreateSkill(session, d6Character.Id, "Broken", "2D+1", 0, true);
|
||||||
|
Assert.False(missingWildDice.Succeeded);
|
||||||
|
|
||||||
|
var tooManyWildDice = service.CreateSkill(session, d6Character.Id, "Broken 2", "2D+1", 51, true);
|
||||||
|
Assert.False(tooManyWildDice.Succeeded);
|
||||||
|
|
||||||
|
var dndCampaign = ServiceTestSupport.GetValue(service.CreateCampaign(session, "DND", "dnd5e"));
|
||||||
|
var dndCharacter = ServiceTestSupport.GetValue(service.CreateCharacter(session, "Mage", dndCampaign.Id));
|
||||||
|
var dndSkill = ServiceTestSupport.GetValue(service.CreateSkill(session, dndCharacter.Id, "Arcana", "1d20+2", 5, true));
|
||||||
|
|
||||||
|
Assert.Equal(0, dndSkill.WildDice);
|
||||||
|
Assert.False(dndSkill.AllowFumble);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,9 +34,9 @@ public sealed class ServicePersistenceTests
|
|||||||
Assert.False(service.ActivateCharacter(string.Empty, ownerCharacter.Id).Succeeded);
|
Assert.False(service.ActivateCharacter(string.Empty, ownerCharacter.Id).Succeeded);
|
||||||
Assert.False(service.ActivateCharacter(gmSession, ownerCharacter.Id).Succeeded);
|
Assert.False(service.ActivateCharacter(gmSession, ownerCharacter.Id).Succeeded);
|
||||||
Assert.False(service.GetCurrentCampaignCharacters(string.Empty).Succeeded);
|
Assert.False(service.GetCurrentCampaignCharacters(string.Empty).Succeeded);
|
||||||
Assert.False(service.CreateSkill(string.Empty, ownerCharacter.Id, "Stealth", "2D+1").Succeeded);
|
Assert.False(service.CreateSkill(string.Empty, ownerCharacter.Id, "Stealth", "2D+1", 1, true).Succeeded);
|
||||||
Assert.False(service.CreateSkill(ownerSession, Guid.NewGuid(), "Stealth", "2D+1").Succeeded);
|
Assert.False(service.CreateSkill(ownerSession, Guid.NewGuid(), "Stealth", "2D+1", 1, true).Succeeded);
|
||||||
Assert.False(service.CreateSkill(otherSession, ownerCharacter.Id, "Stealth", "2D+1").Succeeded);
|
Assert.False(service.CreateSkill(otherSession, ownerCharacter.Id, "Stealth", "2D+1", 1, true).Succeeded);
|
||||||
|
|
||||||
using (var db = harness.CreateDbContext())
|
using (var db = harness.CreateDbContext())
|
||||||
{
|
{
|
||||||
@@ -74,10 +74,10 @@ public sealed class ServicePersistenceTests
|
|||||||
Assert.Null(db.Users.Single(u => u.UsernameNormalized == "OWNER").ActiveCharacterId);
|
Assert.Null(db.Users.Single(u => u.UsernameNormalized == "OWNER").ActiveCharacterId);
|
||||||
}
|
}
|
||||||
|
|
||||||
var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, ownerCharacter.Id, "Stealth", "2D+1"));
|
var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, ownerCharacter.Id, "Stealth", "2D+1", 1, true));
|
||||||
Assert.False(service.UpdateSkill(ownerSession, skill.Id, "", "2D+1").Succeeded);
|
Assert.False(service.UpdateSkill(ownerSession, skill.Id, "", "2D+1", 1, true).Succeeded);
|
||||||
Assert.False(service.UpdateSkill(string.Empty, skill.Id, "Stealth", "2D+1").Succeeded);
|
Assert.False(service.UpdateSkill(string.Empty, skill.Id, "Stealth", "2D+1", 1, true).Succeeded);
|
||||||
Assert.False(service.UpdateSkill(ownerSession, skill.Id, "Stealth", "bad").Succeeded);
|
Assert.False(service.UpdateSkill(ownerSession, skill.Id, "Stealth", "bad", 1, true).Succeeded);
|
||||||
Assert.False(service.RollSkill(string.Empty, skill.Id, "public").Succeeded);
|
Assert.False(service.RollSkill(string.Empty, skill.Id, "public").Succeeded);
|
||||||
|
|
||||||
using (var db = harness.CreateDbContext())
|
using (var db = harness.CreateDbContext())
|
||||||
|
|||||||
@@ -27,21 +27,21 @@ public sealed class ServiceSkillRollTests
|
|||||||
var missingTargetCampaign = service.UpdateCharacter(ownerSession, character.Id, "Renamed", Guid.NewGuid());
|
var missingTargetCampaign = service.UpdateCharacter(ownerSession, character.Id, "Renamed", Guid.NewGuid());
|
||||||
Assert.False(missingTargetCampaign.Succeeded);
|
Assert.False(missingTargetCampaign.Succeeded);
|
||||||
|
|
||||||
var noSkillName = service.CreateSkill(ownerSession, character.Id, "", "1d20");
|
var noSkillName = service.CreateSkill(ownerSession, character.Id, "", "1d20", 0, false);
|
||||||
Assert.False(noSkillName.Succeeded);
|
Assert.False(noSkillName.Succeeded);
|
||||||
|
|
||||||
var invalidExpression = service.CreateSkill(ownerSession, character.Id, "Skill", "5D+4");
|
var invalidExpression = service.CreateSkill(ownerSession, character.Id, "Skill", "5D+4", 0, false);
|
||||||
Assert.False(invalidExpression.Succeeded);
|
Assert.False(invalidExpression.Succeeded);
|
||||||
|
|
||||||
var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, character.Id, "Skill", "1d20+2"));
|
var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, character.Id, "Skill", "1d20+2", 0, false));
|
||||||
|
|
||||||
var missingSkillUpdate = service.UpdateSkill(ownerSession, Guid.NewGuid(), "X", "1d20");
|
var missingSkillUpdate = service.UpdateSkill(ownerSession, Guid.NewGuid(), "X", "1d20", 0, false);
|
||||||
Assert.False(missingSkillUpdate.Succeeded);
|
Assert.False(missingSkillUpdate.Succeeded);
|
||||||
|
|
||||||
var forbiddenSkillUpdate = service.UpdateSkill(otherSession, skill.Id, "X", "1d20");
|
var forbiddenSkillUpdate = service.UpdateSkill(otherSession, skill.Id, "X", "1d20", 0, false);
|
||||||
Assert.False(forbiddenSkillUpdate.Succeeded);
|
Assert.False(forbiddenSkillUpdate.Succeeded);
|
||||||
|
|
||||||
var gmSkillUpdate = service.UpdateSkill(gmSession, skill.Id, "GM Edit", "2d6+1");
|
var gmSkillUpdate = service.UpdateSkill(gmSession, skill.Id, "GM Edit", "2d6+1", 0, false);
|
||||||
Assert.True(gmSkillUpdate.Succeeded);
|
Assert.True(gmSkillUpdate.Succeeded);
|
||||||
|
|
||||||
var missingRoll = service.RollSkill(ownerSession, Guid.NewGuid(), "public");
|
var missingRoll = service.RollSkill(ownerSession, Guid.NewGuid(), "public");
|
||||||
|
|||||||
@@ -9,13 +9,13 @@ internal static class SkillEndpoints
|
|||||||
{
|
{
|
||||||
group.MapPost("/characters/{characterId:guid}/skills", (Guid characterId, CreateSkillRequest request, HttpContext context, IGameService game) =>
|
group.MapPost("/characters/{characterId:guid}/skills", (Guid characterId, CreateSkillRequest request, HttpContext context, IGameService game) =>
|
||||||
{
|
{
|
||||||
var result = game.CreateSkill(context.GetRequiredSessionToken(), characterId, request.Name, request.DiceRollDefinition);
|
var result = game.CreateSkill(context.GetRequiredSessionToken(), characterId, request.Name, request.DiceRollDefinition, request.WildDice, request.AllowFumble);
|
||||||
return ApiResultMapper.ToApiResult(result);
|
return ApiResultMapper.ToApiResult(result);
|
||||||
});
|
});
|
||||||
|
|
||||||
group.MapPut("/skills/{skillId:guid}", (Guid skillId, UpdateSkillRequest request, HttpContext context, IGameService game) =>
|
group.MapPut("/skills/{skillId:guid}", (Guid skillId, UpdateSkillRequest request, HttpContext context, IGameService game) =>
|
||||||
{
|
{
|
||||||
var result = game.UpdateSkill(context.GetRequiredSessionToken(), skillId, request.Name, request.DiceRollDefinition);
|
var result = game.UpdateSkill(context.GetRequiredSessionToken(), skillId, request.Name, request.DiceRollDefinition, request.WildDice, request.AllowFumble);
|
||||||
return ApiResultMapper.ToApiResult(result);
|
return ApiResultMapper.ToApiResult(result);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -182,7 +182,7 @@
|
|||||||
{
|
{
|
||||||
var isSelectedSkill = SelectedSkillId == skill.Id;
|
var isSelectedSkill = SelectedSkillId == skill.Id;
|
||||||
<button type="button" class="skill-item @(isSelectedSkill ? "active" : string.Empty)" @onclick="() => SelectSkill(skill.Id)">
|
<button type="button" class="skill-item @(isSelectedSkill ? "active" : string.Empty)" @onclick="() => SelectSkill(skill.Id)">
|
||||||
<strong>@skill.Name</strong><span>@skill.DiceRollDefinition</span>
|
<strong>@skill.Name</strong><span>@SkillDefinitionLabel(skill)</span>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -207,6 +207,15 @@
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
<p class="roll-total">@LastRoll.Result</p>
|
<p class="roll-total">@LastRoll.Result</p>
|
||||||
|
@if (LastRoll.Dice.Count > 0)
|
||||||
|
{
|
||||||
|
<div class="roll-dice-strip" aria-label="Rolled dice">
|
||||||
|
@foreach (var die in LastRoll.Dice)
|
||||||
|
{
|
||||||
|
<span class="@RollDieCssClass(die)" title="@RollDieTitle(die)">@RollDieGlyph(die.Roll)</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
<p>@LastRoll.Breakdown</p>
|
<p>@LastRoll.Breakdown</p>
|
||||||
<p><span class="badge @(LastRoll.Visibility == "private" ? "private-self" : "public")">@LastRoll.Visibility</span> <time title="@LastRoll.TimestampUtc.ToString("O")">@LastRoll.TimestampUtc.ToLocalTime().ToString("g")</time></p>
|
<p><span class="badge @(LastRoll.Visibility == "private" ? "private-self" : "public")">@LastRoll.Visibility</span> <time title="@LastRoll.TimestampUtc.ToString("O")">@LastRoll.TimestampUtc.ToLocalTime().ToString("g")</time></p>
|
||||||
}
|
}
|
||||||
@@ -430,6 +439,17 @@
|
|||||||
{
|
{
|
||||||
<p class="field-error">@createSkillExpressionError</p>
|
<p class="field-error">@createSkillExpressionError</p>
|
||||||
}
|
}
|
||||||
|
@if (IsSelectedCampaignD6)
|
||||||
|
{
|
||||||
|
<label for="skill-create-wild-dice">Wild dice</label>
|
||||||
|
<input id="skill-create-wild-dice" type="number" min="1" step="1" @bind="SkillForm.WildDice" />
|
||||||
|
@if (SkillErrors.TryGetValue("wildDice", out var createSkillWildDiceError))
|
||||||
|
{
|
||||||
|
<p class="field-error">@createSkillWildDiceError</p>
|
||||||
|
}
|
||||||
|
<label for="skill-create-allow-fumble">Allow fumble</label>
|
||||||
|
<input id="skill-create-allow-fumble" type="checkbox" @bind="SkillForm.AllowFumble" />
|
||||||
|
}
|
||||||
<div class="inline-actions">
|
<div class="inline-actions">
|
||||||
<button type="submit" disabled="@IsMutating">Create Skill</button>
|
<button type="submit" disabled="@IsMutating">Create Skill</button>
|
||||||
<button type="button" class="ghost" @onclick="CloseSkillModals">Cancel</button>
|
<button type="button" class="ghost" @onclick="CloseSkillModals">Cancel</button>
|
||||||
@@ -461,6 +481,17 @@
|
|||||||
{
|
{
|
||||||
<p class="field-error">@editSkillExpressionError</p>
|
<p class="field-error">@editSkillExpressionError</p>
|
||||||
}
|
}
|
||||||
|
@if (IsSelectedCampaignD6)
|
||||||
|
{
|
||||||
|
<label for="skill-edit-wild-dice">Wild dice</label>
|
||||||
|
<input id="skill-edit-wild-dice" type="number" min="1" step="1" @bind="EditSkillForm.WildDice" />
|
||||||
|
@if (EditSkillErrors.TryGetValue("wildDice", out var editSkillWildDiceError))
|
||||||
|
{
|
||||||
|
<p class="field-error">@editSkillWildDiceError</p>
|
||||||
|
}
|
||||||
|
<label for="skill-edit-allow-fumble">Allow fumble</label>
|
||||||
|
<input id="skill-edit-allow-fumble" type="checkbox" @bind="EditSkillForm.AllowFumble" />
|
||||||
|
}
|
||||||
<div class="inline-actions">
|
<div class="inline-actions">
|
||||||
<button type="submit" disabled="@IsMutating">Save Skill</button>
|
<button type="submit" disabled="@IsMutating">Save Skill</button>
|
||||||
<button type="button" class="ghost" @onclick="CloseSkillModals">Cancel</button>
|
<button type="button" class="ghost" @onclick="CloseSkillModals">Cancel</button>
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ public partial class Home
|
|||||||
private SkillSummary? SelectedSkill => SelectedCampaign?.Skills.FirstOrDefault(s => s.Id == SelectedSkillId);
|
private SkillSummary? SelectedSkill => SelectedCampaign?.Skills.FirstOrDefault(s => s.Id == SelectedSkillId);
|
||||||
private string? ActiveCharacterName => SelectedCampaign?.Characters.FirstOrDefault(c => c.Id == ActiveCharacterId)?.Name;
|
private string? ActiveCharacterName => SelectedCampaign?.Characters.FirstOrDefault(c => c.Id == ActiveCharacterId)?.Name;
|
||||||
private bool IsCurrentUserGm => SelectedCampaign is not null && User is not null && SelectedCampaign.Gm.Id == User.Id;
|
private bool IsCurrentUserGm => SelectedCampaign is not null && User is not null && SelectedCampaign.Gm.Id == User.Id;
|
||||||
|
private bool IsSelectedCampaignD6 => string.Equals(SelectedCampaign?.RulesetId, "d6", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
private List<SkillSummary> SelectedCharacterSkills =>
|
private List<SkillSummary> SelectedCharacterSkills =>
|
||||||
SelectedCampaign is null || !SelectedCharacterId.HasValue
|
SelectedCampaign is null || !SelectedCharacterId.HasValue
|
||||||
@@ -640,6 +641,8 @@ public partial class Home
|
|||||||
{
|
{
|
||||||
SkillForm.Name = string.Empty;
|
SkillForm.Name = string.Empty;
|
||||||
SkillForm.DiceRollDefinition = string.Empty;
|
SkillForm.DiceRollDefinition = string.Empty;
|
||||||
|
SkillForm.WildDice = IsSelectedCampaignD6 ? 1 : 0;
|
||||||
|
SkillForm.AllowFumble = IsSelectedCampaignD6;
|
||||||
SkillErrors.Clear();
|
SkillErrors.Clear();
|
||||||
SkillFormError = null;
|
SkillFormError = null;
|
||||||
ShowCreateSkillModal = true;
|
ShowCreateSkillModal = true;
|
||||||
@@ -655,6 +658,8 @@ public partial class Home
|
|||||||
EditingSkillId = SelectedSkill.Id;
|
EditingSkillId = SelectedSkill.Id;
|
||||||
EditSkillForm.Name = SelectedSkill.Name;
|
EditSkillForm.Name = SelectedSkill.Name;
|
||||||
EditSkillForm.DiceRollDefinition = SelectedSkill.DiceRollDefinition;
|
EditSkillForm.DiceRollDefinition = SelectedSkill.DiceRollDefinition;
|
||||||
|
EditSkillForm.WildDice = SelectedSkill.WildDice;
|
||||||
|
EditSkillForm.AllowFumble = SelectedSkill.AllowFumble;
|
||||||
EditSkillErrors.Clear();
|
EditSkillErrors.Clear();
|
||||||
EditSkillFormError = null;
|
EditSkillFormError = null;
|
||||||
ShowEditSkillModal = true;
|
ShowEditSkillModal = true;
|
||||||
@@ -688,6 +693,11 @@ public partial class Home
|
|||||||
SkillErrors["diceRollDefinition"] = "Expression is required.";
|
SkillErrors["diceRollDefinition"] = "Expression is required.";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (IsSelectedCampaignD6 && SkillForm.WildDice < 1)
|
||||||
|
{
|
||||||
|
SkillErrors["wildDice"] = "D6 skills require at least one wild die.";
|
||||||
|
}
|
||||||
|
|
||||||
if (SkillErrors.Count > 0)
|
if (SkillErrors.Count > 0)
|
||||||
{
|
{
|
||||||
SkillFormError = "Resolve validation issues before submitting.";
|
SkillFormError = "Resolve validation issues before submitting.";
|
||||||
@@ -697,7 +707,10 @@ public partial class Home
|
|||||||
IsMutating = true;
|
IsMutating = true;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_ = await RequestAsync<SkillSummary>("POST", $"/api/characters/{SelectedCharacter.Id}/skills", new CreateSkillRequest(SkillForm.Name.Trim(), SkillForm.DiceRollDefinition.Trim()));
|
_ = await RequestAsync<SkillSummary>(
|
||||||
|
"POST",
|
||||||
|
$"/api/characters/{SelectedCharacter.Id}/skills",
|
||||||
|
new CreateSkillRequest(SkillForm.Name.Trim(), SkillForm.DiceRollDefinition.Trim(), SkillForm.WildDice, SkillForm.AllowFumble));
|
||||||
CloseSkillModals();
|
CloseSkillModals();
|
||||||
await RefreshCampaignScopeAsync();
|
await RefreshCampaignScopeAsync();
|
||||||
SetStatus("Skill created.", false);
|
SetStatus("Skill created.", false);
|
||||||
@@ -733,6 +746,11 @@ public partial class Home
|
|||||||
EditSkillErrors["diceRollDefinition"] = "Expression is required.";
|
EditSkillErrors["diceRollDefinition"] = "Expression is required.";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (IsSelectedCampaignD6 && EditSkillForm.WildDice < 1)
|
||||||
|
{
|
||||||
|
EditSkillErrors["wildDice"] = "D6 skills require at least one wild die.";
|
||||||
|
}
|
||||||
|
|
||||||
if (EditSkillErrors.Count > 0)
|
if (EditSkillErrors.Count > 0)
|
||||||
{
|
{
|
||||||
EditSkillFormError = "Resolve validation issues before submitting.";
|
EditSkillFormError = "Resolve validation issues before submitting.";
|
||||||
@@ -742,7 +760,10 @@ public partial class Home
|
|||||||
IsMutating = true;
|
IsMutating = true;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var updatedSkill = await RequestAsync<SkillSummary>("PUT", $"/api/skills/{EditingSkillId.Value}", new UpdateSkillRequest(EditSkillForm.Name.Trim(), EditSkillForm.DiceRollDefinition.Trim()));
|
var updatedSkill = await RequestAsync<SkillSummary>(
|
||||||
|
"PUT",
|
||||||
|
$"/api/skills/{EditingSkillId.Value}",
|
||||||
|
new UpdateSkillRequest(EditSkillForm.Name.Trim(), EditSkillForm.DiceRollDefinition.Trim(), EditSkillForm.WildDice, EditSkillForm.AllowFumble));
|
||||||
SelectedSkillId = updatedSkill.Id;
|
SelectedSkillId = updatedSkill.Id;
|
||||||
CloseSkillModals();
|
CloseSkillModals();
|
||||||
await RefreshCampaignScopeAsync();
|
await RefreshCampaignScopeAsync();
|
||||||
@@ -985,6 +1006,93 @@ public partial class Home
|
|||||||
return SelectedCampaign?.Skills.FirstOrDefault(s => s.Id == skillId)?.Name ?? "Hidden skill";
|
return SelectedCampaign?.Skills.FirstOrDefault(s => s.Id == skillId)?.Name ?? "Hidden skill";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private string SkillDefinitionLabel(SkillSummary skill)
|
||||||
|
{
|
||||||
|
if (!string.Equals(SelectedCampaign?.RulesetId, "d6", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return skill.DiceRollDefinition;
|
||||||
|
}
|
||||||
|
|
||||||
|
var fumble = skill.AllowFumble ? "fumble on" : "fumble off";
|
||||||
|
return $"{skill.DiceRollDefinition} | wild {skill.WildDice}, {fumble}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string RollDieGlyph(int roll)
|
||||||
|
{
|
||||||
|
return roll switch
|
||||||
|
{
|
||||||
|
1 => "\u2680",
|
||||||
|
2 => "\u2681",
|
||||||
|
3 => "\u2682",
|
||||||
|
4 => "\u2683",
|
||||||
|
5 => "\u2684",
|
||||||
|
6 => "\u2685",
|
||||||
|
_ => roll.ToString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string RollDieCssClass(RollDieResult die)
|
||||||
|
{
|
||||||
|
var classes = new List<string> { "die-chip" };
|
||||||
|
if (die.Wild)
|
||||||
|
{
|
||||||
|
classes.Add("wild");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (die.Crit)
|
||||||
|
{
|
||||||
|
classes.Add("crit");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (die.Fumble)
|
||||||
|
{
|
||||||
|
classes.Add("fumble");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (die.Removed)
|
||||||
|
{
|
||||||
|
classes.Add("removed");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (die.Added)
|
||||||
|
{
|
||||||
|
classes.Add("added");
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Join(" ", classes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string RollDieTitle(RollDieResult die)
|
||||||
|
{
|
||||||
|
var labels = new List<string> { $"Roll {die.Roll}" };
|
||||||
|
if (die.Wild)
|
||||||
|
{
|
||||||
|
labels.Add("wild");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (die.Crit)
|
||||||
|
{
|
||||||
|
labels.Add("critical");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (die.Fumble)
|
||||||
|
{
|
||||||
|
labels.Add("fumble");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (die.Removed)
|
||||||
|
{
|
||||||
|
labels.Add("removed");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (die.Added)
|
||||||
|
{
|
||||||
|
labels.Add("added");
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Join(", ", labels);
|
||||||
|
}
|
||||||
|
|
||||||
private string RollerLabel(CampaignLogEntry entry)
|
private string RollerLabel(CampaignLogEntry entry)
|
||||||
{
|
{
|
||||||
if (User is not null && entry.RollerUserId == User.Id)
|
if (User is not null && entry.RollerUserId == User.Id)
|
||||||
@@ -1134,6 +1242,8 @@ public partial class Home
|
|||||||
{
|
{
|
||||||
public string Name { get; set; } = string.Empty;
|
public string Name { get; set; } = string.Empty;
|
||||||
public string DiceRollDefinition { get; set; } = string.Empty;
|
public string DiceRollDefinition { get; set; } = string.Empty;
|
||||||
|
public int WildDice { get; set; }
|
||||||
|
public bool AllowFumble { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class JsApiResponse
|
private sealed class JsApiResponse
|
||||||
|
|||||||
@@ -25,11 +25,12 @@ public sealed record CreateCharacterRequest(string Name, Guid CampaignId);
|
|||||||
public sealed record UpdateCharacterRequest(string Name, Guid CampaignId);
|
public sealed record UpdateCharacterRequest(string Name, Guid CampaignId);
|
||||||
public sealed record CharacterSummary(Guid Id, string Name, Guid OwnerUserId, Guid CampaignId);
|
public sealed record CharacterSummary(Guid Id, string Name, Guid OwnerUserId, Guid CampaignId);
|
||||||
|
|
||||||
public sealed record CreateSkillRequest(string Name, string DiceRollDefinition);
|
public sealed record CreateSkillRequest(string Name, string DiceRollDefinition, int WildDice, bool AllowFumble);
|
||||||
public sealed record UpdateSkillRequest(string Name, string DiceRollDefinition);
|
public sealed record UpdateSkillRequest(string Name, string DiceRollDefinition, int WildDice, bool AllowFumble);
|
||||||
public sealed record SkillSummary(Guid Id, Guid CharacterId, string Name, string DiceRollDefinition);
|
public sealed record SkillSummary(Guid Id, Guid CharacterId, string Name, string DiceRollDefinition, int WildDice, bool AllowFumble);
|
||||||
|
|
||||||
public sealed record RollSkillRequest(string Visibility);
|
public sealed record RollSkillRequest(string Visibility);
|
||||||
|
public sealed record RollDieResult(int Roll, bool Crit, bool Fumble, bool Wild, bool Removed, bool Added);
|
||||||
public sealed record RollResult(
|
public sealed record RollResult(
|
||||||
Guid RollId,
|
Guid RollId,
|
||||||
Guid CampaignId,
|
Guid CampaignId,
|
||||||
@@ -39,6 +40,7 @@ public sealed record RollResult(
|
|||||||
string Visibility,
|
string Visibility,
|
||||||
int Result,
|
int Result,
|
||||||
string Breakdown,
|
string Breakdown,
|
||||||
|
IReadOnlyList<RollDieResult> Dice,
|
||||||
DateTimeOffset TimestampUtc);
|
DateTimeOffset TimestampUtc);
|
||||||
|
|
||||||
public sealed record CampaignLogEntry(
|
public sealed record CampaignLogEntry(
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ public sealed class RpgRollerDbContext : DbContext
|
|||||||
entity.HasKey(x => x.Id);
|
entity.HasKey(x => x.Id);
|
||||||
entity.Property(x => x.Name).IsRequired().HasMaxLength(128);
|
entity.Property(x => x.Name).IsRequired().HasMaxLength(128);
|
||||||
entity.Property(x => x.DiceRollDefinition).IsRequired().HasMaxLength(128);
|
entity.Property(x => x.DiceRollDefinition).IsRequired().HasMaxLength(128);
|
||||||
|
entity.Property(x => x.WildDice).IsRequired();
|
||||||
|
entity.Property(x => x.AllowFumble).IsRequired();
|
||||||
entity.HasIndex(x => x.CharacterId);
|
entity.HasIndex(x => x.CharacterId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ public sealed class Skill
|
|||||||
public required Guid CharacterId { get; set; }
|
public required Guid CharacterId { get; set; }
|
||||||
public required string Name { get; set; }
|
public required string Name { get; set; }
|
||||||
public required string DiceRollDefinition { get; set; }
|
public required string DiceRollDefinition { get; set; }
|
||||||
|
public required int WildDice { get; set; }
|
||||||
|
public required bool AllowFumble { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class RollLogEntry
|
public sealed class RollLogEntry
|
||||||
|
|||||||
@@ -399,7 +399,7 @@ public sealed class GameService : IGameService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition)
|
public ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(name))
|
if (string.IsNullOrWhiteSpace(name))
|
||||||
{
|
{
|
||||||
@@ -431,12 +431,20 @@ public sealed class GameService : IGameService
|
|||||||
return ServiceResult<SkillSummary>.Failure(expressionValidation.Error!.Code, expressionValidation.Error.Message);
|
return ServiceResult<SkillSummary>.Failure(expressionValidation.Error!.Code, expressionValidation.Error.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var optionsValidation = ValidateSkillOptions(campaign.Ruleset, wildDice, allowFumble);
|
||||||
|
if (!optionsValidation.Succeeded)
|
||||||
|
{
|
||||||
|
return ServiceResult<SkillSummary>.Failure(optionsValidation.Error!.Code, optionsValidation.Error.Message);
|
||||||
|
}
|
||||||
|
|
||||||
var skill = new Skill
|
var skill = new Skill
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
CharacterId = character.Id,
|
CharacterId = character.Id,
|
||||||
Name = name.Trim(),
|
Name = name.Trim(),
|
||||||
DiceRollDefinition = expressionValidation.Value!.Canonical
|
DiceRollDefinition = expressionValidation.Value!.Canonical,
|
||||||
|
WildDice = optionsValidation.Value!.WildDice,
|
||||||
|
AllowFumble = optionsValidation.Value.AllowFumble
|
||||||
};
|
};
|
||||||
|
|
||||||
m_SkillsById[skill.Id] = skill;
|
m_SkillsById[skill.Id] = skill;
|
||||||
@@ -447,7 +455,7 @@ public sealed class GameService : IGameService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition)
|
public ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(name))
|
if (string.IsNullOrWhiteSpace(name))
|
||||||
{
|
{
|
||||||
@@ -480,8 +488,16 @@ public sealed class GameService : IGameService
|
|||||||
return ServiceResult<SkillSummary>.Failure(expressionValidation.Error!.Code, expressionValidation.Error.Message);
|
return ServiceResult<SkillSummary>.Failure(expressionValidation.Error!.Code, expressionValidation.Error.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var optionsValidation = ValidateSkillOptions(campaign.Ruleset, wildDice, allowFumble);
|
||||||
|
if (!optionsValidation.Succeeded)
|
||||||
|
{
|
||||||
|
return ServiceResult<SkillSummary>.Failure(optionsValidation.Error!.Code, optionsValidation.Error.Message);
|
||||||
|
}
|
||||||
|
|
||||||
skill.Name = name.Trim();
|
skill.Name = name.Trim();
|
||||||
skill.DiceRollDefinition = expressionValidation.Value!.Canonical;
|
skill.DiceRollDefinition = expressionValidation.Value!.Canonical;
|
||||||
|
skill.WildDice = optionsValidation.Value!.WildDice;
|
||||||
|
skill.AllowFumble = optionsValidation.Value.AllowFumble;
|
||||||
TouchCampaignLocked(campaign.Id);
|
TouchCampaignLocked(campaign.Id);
|
||||||
|
|
||||||
PersistStateLocked();
|
PersistStateLocked();
|
||||||
@@ -523,7 +539,7 @@ public sealed class GameService : IGameService
|
|||||||
return ServiceResult<RollResult>.Failure(parsedVisibility.Error!.Code, parsedVisibility.Error.Message);
|
return ServiceResult<RollResult>.Failure(parsedVisibility.Error!.Code, parsedVisibility.Error.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
var roll = ComputeRoll(parsedExpression.Value!);
|
var roll = ComputeRoll(campaign.Ruleset, parsedExpression.Value!, skill);
|
||||||
var entry = new RollLogEntry
|
var entry = new RollLogEntry
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
@@ -541,7 +557,7 @@ public sealed class GameService : IGameService
|
|||||||
TouchCampaignLocked(campaign.Id);
|
TouchCampaignLocked(campaign.Id);
|
||||||
|
|
||||||
PersistStateLocked();
|
PersistStateLocked();
|
||||||
return ServiceResult<RollResult>.Success(ToRollResult(entry));
|
return ServiceResult<RollResult>.Success(ToRollResult(entry, roll.Dice));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -582,20 +598,143 @@ public sealed class GameService : IGameService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private (int Total, string Breakdown) ComputeRoll(DiceExpression expression)
|
private static ServiceResult<(int WildDice, bool AllowFumble)> ValidateSkillOptions(RulesetKind ruleset, int wildDice, bool allowFumble)
|
||||||
|
{
|
||||||
|
if (wildDice < 0 || wildDice > 50)
|
||||||
|
{
|
||||||
|
return ServiceResult<(int WildDice, bool AllowFumble)>.Failure("invalid_wild_dice", "Wild dice must be between 0 and 50.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ruleset == RulesetKind.D6)
|
||||||
|
{
|
||||||
|
if (wildDice < 1)
|
||||||
|
{
|
||||||
|
return ServiceResult<(int WildDice, bool AllowFumble)>.Failure("invalid_wild_dice", "D6 skills require at least one wild die.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return ServiceResult<(int WildDice, bool AllowFumble)>.Success((wildDice, allowFumble));
|
||||||
|
}
|
||||||
|
|
||||||
|
return ServiceResult<(int WildDice, bool AllowFumble)>.Success((0, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
private (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) ComputeRoll(RulesetKind ruleset, DiceExpression expression, Skill skill)
|
||||||
|
{
|
||||||
|
return ruleset == RulesetKind.D6
|
||||||
|
? ComputeD6Roll(expression, skill.WildDice, skill.AllowFumble)
|
||||||
|
: ComputeStandardRoll(expression);
|
||||||
|
}
|
||||||
|
|
||||||
|
private (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) ComputeStandardRoll(DiceExpression expression)
|
||||||
{
|
{
|
||||||
var diceValues = new int[expression.DiceCount];
|
var diceValues = new int[expression.DiceCount];
|
||||||
|
var dice = new RollDieResult[expression.DiceCount];
|
||||||
var total = expression.Modifier;
|
var total = expression.Modifier;
|
||||||
for (var i = 0; i < expression.DiceCount; i += 1)
|
for (var i = 0; i < expression.DiceCount; i += 1)
|
||||||
{
|
{
|
||||||
var value = m_DiceRoller.Roll(expression.Sides);
|
var value = m_DiceRoller.Roll(expression.Sides);
|
||||||
diceValues[i] = value;
|
diceValues[i] = value;
|
||||||
|
dice[i] = new RollDieResult(value, false, false, false, false, false);
|
||||||
total += value;
|
total += value;
|
||||||
}
|
}
|
||||||
|
|
||||||
var modifierPart = expression.Modifier > 0 ? $"+{expression.Modifier}" : string.Empty;
|
return (total, BuildBreakdown(diceValues, expression.Modifier, total), dice);
|
||||||
var breakdown = $"{string.Join("+", diceValues)}{modifierPart}={total}";
|
}
|
||||||
return (total, breakdown);
|
|
||||||
|
private (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) ComputeD6Roll(DiceExpression expression, int wildDice, bool allowFumble)
|
||||||
|
{
|
||||||
|
var initialDice = expression.DiceCount;
|
||||||
|
var currentDice = initialDice;
|
||||||
|
var pendingExplodingDice = 0;
|
||||||
|
var pendingFumbles = 0;
|
||||||
|
var dieResults = new List<RollDieResult>(initialDice);
|
||||||
|
|
||||||
|
for (var i = 0; i < currentDice; i += 1)
|
||||||
|
{
|
||||||
|
var roll = m_DiceRoller.Roll(expression.Sides);
|
||||||
|
var isWild = i < wildDice;
|
||||||
|
var isCrit = false;
|
||||||
|
var isFumble = false;
|
||||||
|
var isAdded = false;
|
||||||
|
|
||||||
|
if (isWild)
|
||||||
|
{
|
||||||
|
if (roll == expression.Sides)
|
||||||
|
{
|
||||||
|
pendingExplodingDice += 1;
|
||||||
|
currentDice += 1;
|
||||||
|
isCrit = true;
|
||||||
|
}
|
||||||
|
else if (allowFumble && roll == 1)
|
||||||
|
{
|
||||||
|
pendingFumbles += 1;
|
||||||
|
isFumble = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingExplodingDice > 0 && i >= initialDice)
|
||||||
|
{
|
||||||
|
pendingExplodingDice -= 1;
|
||||||
|
isAdded = true;
|
||||||
|
if (roll == expression.Sides)
|
||||||
|
{
|
||||||
|
pendingExplodingDice += 1;
|
||||||
|
currentDice += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dieResults.Add(new RollDieResult(roll, isCrit, isFumble, isWild, false, isAdded));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var roll = expression.Sides; roll >= 1 && pendingFumbles > 0; roll -= 1)
|
||||||
|
{
|
||||||
|
for (var i = currentDice - 1; i >= 0 && pendingFumbles > 0; i -= 1)
|
||||||
|
{
|
||||||
|
if (dieResults[i].Roll != roll)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
dieResults[i] = dieResults[i] with
|
||||||
|
{
|
||||||
|
Removed = true,
|
||||||
|
Added = false,
|
||||||
|
Crit = false,
|
||||||
|
Fumble = false
|
||||||
|
};
|
||||||
|
pendingFumbles -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var total = expression.Modifier;
|
||||||
|
var includedDice = new List<int>(dieResults.Count);
|
||||||
|
foreach (var die in dieResults)
|
||||||
|
{
|
||||||
|
if (die.Fumble)
|
||||||
|
{
|
||||||
|
total += 1;
|
||||||
|
includedDice.Add(1);
|
||||||
|
}
|
||||||
|
else if (!die.Removed)
|
||||||
|
{
|
||||||
|
total += die.Roll;
|
||||||
|
includedDice.Add(die.Roll);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (total, BuildBreakdown(includedDice, expression.Modifier, total), dieResults);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildBreakdown(IEnumerable<int> diceValues, int modifier, int total)
|
||||||
|
{
|
||||||
|
var dicePart = string.Join("+", diceValues);
|
||||||
|
if (string.IsNullOrWhiteSpace(dicePart))
|
||||||
|
{
|
||||||
|
dicePart = "0";
|
||||||
|
}
|
||||||
|
|
||||||
|
var modifierPart = modifier > 0 ? $"+{modifier}" : string.Empty;
|
||||||
|
return $"{dicePart}{modifierPart}={total}";
|
||||||
}
|
}
|
||||||
|
|
||||||
private ServiceResult<RollVisibility> ParseVisibility(string visibility)
|
private ServiceResult<RollVisibility> ParseVisibility(string visibility)
|
||||||
@@ -651,10 +790,10 @@ public sealed class GameService : IGameService
|
|||||||
|
|
||||||
private static SkillSummary ToSkillSummary(Skill skill)
|
private static SkillSummary ToSkillSummary(Skill skill)
|
||||||
{
|
{
|
||||||
return new SkillSummary(skill.Id, skill.CharacterId, skill.Name, skill.DiceRollDefinition);
|
return new SkillSummary(skill.Id, skill.CharacterId, skill.Name, skill.DiceRollDefinition, skill.WildDice, skill.AllowFumble);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static RollResult ToRollResult(RollLogEntry entry)
|
private static RollResult ToRollResult(RollLogEntry entry, IReadOnlyList<RollDieResult> dice)
|
||||||
{
|
{
|
||||||
return new RollResult(
|
return new RollResult(
|
||||||
entry.Id,
|
entry.Id,
|
||||||
@@ -665,6 +804,7 @@ public sealed class GameService : IGameService
|
|||||||
entry.Visibility == RollVisibility.Public ? "public" : "private",
|
entry.Visibility == RollVisibility.Public ? "public" : "private",
|
||||||
entry.Result,
|
entry.Result,
|
||||||
entry.Breakdown,
|
entry.Breakdown,
|
||||||
|
dice,
|
||||||
entry.TimestampUtc);
|
entry.TimestampUtc);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -905,7 +1045,9 @@ public sealed class GameService : IGameService
|
|||||||
Id = skill.Id,
|
Id = skill.Id,
|
||||||
CharacterId = skill.CharacterId,
|
CharacterId = skill.CharacterId,
|
||||||
Name = skill.Name,
|
Name = skill.Name,
|
||||||
DiceRollDefinition = skill.DiceRollDefinition
|
DiceRollDefinition = skill.DiceRollDefinition,
|
||||||
|
WildDice = skill.WildDice,
|
||||||
|
AllowFumble = skill.AllowFumble
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ public interface IGameService
|
|||||||
ServiceResult<bool> ActivateCharacter(string sessionToken, Guid characterId);
|
ServiceResult<bool> ActivateCharacter(string sessionToken, Guid characterId);
|
||||||
ServiceResult<IReadOnlyList<CharacterSummary>> GetCurrentCampaignCharacters(string sessionToken);
|
ServiceResult<IReadOnlyList<CharacterSummary>> GetCurrentCampaignCharacters(string sessionToken);
|
||||||
|
|
||||||
ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition);
|
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);
|
ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble);
|
||||||
|
|
||||||
ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility);
|
ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility);
|
||||||
ServiceResult<IReadOnlyList<CampaignLogEntry>> GetCampaignLog(string sessionToken, Guid campaignId);
|
ServiceResult<IReadOnlyList<CampaignLogEntry>> GetCampaignLog(string sessionToken, Guid campaignId);
|
||||||
|
|||||||
@@ -297,6 +297,54 @@ select:focus-visible {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.roll-dice-strip {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.die-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2.1rem;
|
||||||
|
height: 2.1rem;
|
||||||
|
border: 2px solid #2a2418;
|
||||||
|
border-radius: 0.45rem;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #1f1a13;
|
||||||
|
font-size: 1.45rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.die-chip.wild {
|
||||||
|
border-width: 3px;
|
||||||
|
border-color: #c79913;
|
||||||
|
}
|
||||||
|
|
||||||
|
.die-chip.crit {
|
||||||
|
background: #d8ffc2;
|
||||||
|
color: #18490f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.die-chip.fumble {
|
||||||
|
background: #ffb5a8;
|
||||||
|
color: #661110;
|
||||||
|
}
|
||||||
|
|
||||||
|
.die-chip.added {
|
||||||
|
background: #dbffdf;
|
||||||
|
color: #206029;
|
||||||
|
}
|
||||||
|
|
||||||
|
.die-chip.removed {
|
||||||
|
background: #fde0dd;
|
||||||
|
color: #7f5f55;
|
||||||
|
border-style: dashed;
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
.empty,
|
.empty,
|
||||||
.muted {
|
.muted {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
|
|||||||
4
TECH.md
4
TECH.md
@@ -12,8 +12,8 @@
|
|||||||
- Local CI parity entrypoint: `scripts/ci-local.ps1`
|
- Local CI parity entrypoint: `scripts/ci-local.ps1`
|
||||||
- API endpoint modules: `RpgRoller/Api/*Endpoints.cs` + shared session/auth helpers
|
- 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
|
- 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, ruleset-aware rolls, filtered campaign logs, and SSE state updates.
|
- 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 and full roll workflow controls.
|
- 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
|
## 1) Stack and baseline choices
|
||||||
|
|
||||||
|
|||||||
4
UX.md
4
UX.md
@@ -250,6 +250,9 @@ Actions:
|
|||||||
|
|
||||||
- `Create Skill` button -> modal form
|
- `Create Skill` button -> modal form
|
||||||
- `Edit Skill` button -> modal form
|
- `Edit Skill` button -> modal form
|
||||||
|
- d6 skill forms include:
|
||||||
|
- `Wild dice` numeric field
|
||||||
|
- `Allow fumble` toggle
|
||||||
- Roll command panel:
|
- Roll command panel:
|
||||||
- Visibility selector (`public`, `private`)
|
- Visibility selector (`public`, `private`)
|
||||||
- `Roll Skill` primary action
|
- `Roll Skill` primary action
|
||||||
@@ -258,6 +261,7 @@ Last roll card:
|
|||||||
|
|
||||||
- Result total
|
- Result total
|
||||||
- Breakdown
|
- Breakdown
|
||||||
|
- Die-by-die visualization with states: `critical`, `fumble`, `wild`, `removed`, `added`
|
||||||
- Visibility
|
- Visibility
|
||||||
- Timestamp
|
- Timestamp
|
||||||
|
|
||||||
|
|||||||
@@ -893,11 +893,20 @@
|
|||||||
},
|
},
|
||||||
"diceRollDefinition": {
|
"diceRollDefinition": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
},
|
||||||
|
"wildDice": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
|
"allowFumble": {
|
||||||
|
"type": "boolean"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"name",
|
"name",
|
||||||
"diceRollDefinition"
|
"diceRollDefinition",
|
||||||
|
"wildDice",
|
||||||
|
"allowFumble"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"UpdateSkillRequest": {
|
"UpdateSkillRequest": {
|
||||||
@@ -908,11 +917,20 @@
|
|||||||
},
|
},
|
||||||
"diceRollDefinition": {
|
"diceRollDefinition": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
},
|
||||||
|
"wildDice": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
|
"allowFumble": {
|
||||||
|
"type": "boolean"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"name",
|
"name",
|
||||||
"diceRollDefinition"
|
"diceRollDefinition",
|
||||||
|
"wildDice",
|
||||||
|
"allowFumble"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"SkillSummary": {
|
"SkillSummary": {
|
||||||
@@ -931,13 +949,22 @@
|
|||||||
},
|
},
|
||||||
"diceRollDefinition": {
|
"diceRollDefinition": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
},
|
||||||
|
"wildDice": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
|
"allowFumble": {
|
||||||
|
"type": "boolean"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"id",
|
"id",
|
||||||
"characterId",
|
"characterId",
|
||||||
"name",
|
"name",
|
||||||
"diceRollDefinition"
|
"diceRollDefinition",
|
||||||
|
"wildDice",
|
||||||
|
"allowFumble"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"RollSkillRequest": {
|
"RollSkillRequest": {
|
||||||
@@ -951,6 +978,38 @@
|
|||||||
"visibility"
|
"visibility"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"RollDieResult": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"roll": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
|
"crit": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"fumble": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"wild": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"removed": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"added": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"roll",
|
||||||
|
"crit",
|
||||||
|
"fumble",
|
||||||
|
"wild",
|
||||||
|
"removed",
|
||||||
|
"added"
|
||||||
|
]
|
||||||
|
},
|
||||||
"RollResult": {
|
"RollResult": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -984,6 +1043,12 @@
|
|||||||
"breakdown": {
|
"breakdown": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"dice": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/RollDieResult"
|
||||||
|
}
|
||||||
|
},
|
||||||
"timestampUtc": {
|
"timestampUtc": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "date-time"
|
"format": "date-time"
|
||||||
@@ -998,6 +1063,7 @@
|
|||||||
"visibility",
|
"visibility",
|
||||||
"result",
|
"result",
|
||||||
"breakdown",
|
"breakdown",
|
||||||
|
"dice",
|
||||||
"timestampUtc"
|
"timestampUtc"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user