Implement d6 wild dice/fumble skills and die-state rolls

This commit is contained in:
2026-02-26 08:26:12 +01:00
parent 0f44cc466b
commit 11ab7c959b
22 changed files with 560 additions and 50 deletions

9
FAQ.md
View File

@@ -41,3 +41,12 @@ Coverage now includes the entire backend project (`RpgRoller`), including API/ho
## 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 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.

View File

@@ -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.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.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.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. |

View File

@@ -83,8 +83,8 @@ To use a custom SQLite database path, set `ConnectionStrings__RpgRoller`.
- 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
- Rolls: public/private skill rolls with append-only campaign log
- 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
@@ -94,8 +94,9 @@ To use a custom SQLite database path, set `ConnectionStrings__RpgRoller`.
- play screen and campaign management screen switch
- campaign creation and selection
- 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
- die-state visualization in Last Roll (critical, fumble, wild, removed, added)
- responsive play UX:
- desktop two-column (character + log)
- tablet/mobile panel switching with bottom tab bar (`Character` / `Log`)

View File

@@ -98,6 +98,8 @@
* 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
@@ -198,6 +200,7 @@
* System must:
* 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:
* 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
---
@@ -263,6 +268,7 @@
### 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.
---
@@ -270,6 +276,7 @@
### 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.

View File

@@ -34,19 +34,23 @@ public sealed class CampaignApiTests : ApiTestBase
var createdSkill = await PostAsync<CreateSkillRequest, SkillSummary>(
gmClient,
$"/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(0, createdSkill.WildDice);
Assert.False(createdSkill.AllowFumble);
var updatedSkill = await PutAsync<UpdateSkillRequest, SkillSummary>(
gmClient,
$"/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("2d12+3", updatedSkill.DiceRollDefinition);
Assert.Equal(0, updatedSkill.WildDice);
Assert.False(updatedSkill.AllowFumble);
var invalidSkill = await gmClient.PostAsJsonAsync(
$"/api/characters/{gmCharacter.Id}/skills",
new CreateSkillRequest("Broken", "5D+4"));
new CreateSkillRequest("Broken", "5D+4", 0, false));
Assert.Equal(HttpStatusCode.BadRequest, invalidSkill.StatusCode);
var details = await GetAsync<CampaignDetails>(gmClient, $"/api/campaigns/{campaign.Id}");

View File

@@ -35,7 +35,9 @@ public sealed class RollVisibilityApiTests : ApiTestBase
var skill = await PostAsync<CreateSkillRequest, SkillSummary>(
playerClient,
$"/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 LoginAsync(observerClient, "observer", "Password123");

View File

@@ -88,8 +88,8 @@ public sealed class ServiceCampaignTests
var ownerCharacter = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Owner 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"));
_ = ServiceTestSupport.GetValue(service.CreateSkill(otherSession, otherCharacter.Id, "Perception", "1D+2"));
var ownerSkill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, ownerCharacter.Id, "Stealth", "2D+1", 1, true));
_ = ServiceTestSupport.GetValue(service.CreateSkill(otherSession, otherCharacter.Id, "Perception", "1D+2", 1, true));
var ownerView = ServiceTestSupport.GetValue(service.GetCampaign(ownerSession, campaign.Id));
Assert.Single(ownerView.Characters);

View 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);
}
}

View File

@@ -34,9 +34,9 @@ public sealed class ServicePersistenceTests
Assert.False(service.ActivateCharacter(string.Empty, ownerCharacter.Id).Succeeded);
Assert.False(service.ActivateCharacter(gmSession, ownerCharacter.Id).Succeeded);
Assert.False(service.GetCurrentCampaignCharacters(string.Empty).Succeeded);
Assert.False(service.CreateSkill(string.Empty, ownerCharacter.Id, "Stealth", "2D+1").Succeeded);
Assert.False(service.CreateSkill(ownerSession, Guid.NewGuid(), "Stealth", "2D+1").Succeeded);
Assert.False(service.CreateSkill(otherSession, 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", 1, true).Succeeded);
Assert.False(service.CreateSkill(otherSession, ownerCharacter.Id, "Stealth", "2D+1", 1, true).Succeeded);
using (var db = harness.CreateDbContext())
{
@@ -74,10 +74,10 @@ public sealed class ServicePersistenceTests
Assert.Null(db.Users.Single(u => u.UsernameNormalized == "OWNER").ActiveCharacterId);
}
var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, ownerCharacter.Id, "Stealth", "2D+1"));
Assert.False(service.UpdateSkill(ownerSession, skill.Id, "", "2D+1").Succeeded);
Assert.False(service.UpdateSkill(string.Empty, skill.Id, "Stealth", "2D+1").Succeeded);
Assert.False(service.UpdateSkill(ownerSession, skill.Id, "Stealth", "bad").Succeeded);
var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, ownerCharacter.Id, "Stealth", "2D+1", 1, true));
Assert.False(service.UpdateSkill(ownerSession, skill.Id, "", "2D+1", 1, true).Succeeded);
Assert.False(service.UpdateSkill(string.Empty, skill.Id, "Stealth", "2D+1", 1, true).Succeeded);
Assert.False(service.UpdateSkill(ownerSession, skill.Id, "Stealth", "bad", 1, true).Succeeded);
Assert.False(service.RollSkill(string.Empty, skill.Id, "public").Succeeded);
using (var db = harness.CreateDbContext())

View File

@@ -27,21 +27,21 @@ public sealed class ServiceSkillRollTests
var missingTargetCampaign = service.UpdateCharacter(ownerSession, character.Id, "Renamed", Guid.NewGuid());
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);
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);
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);
var forbiddenSkillUpdate = service.UpdateSkill(otherSession, skill.Id, "X", "1d20");
var forbiddenSkillUpdate = service.UpdateSkill(otherSession, skill.Id, "X", "1d20", 0, false);
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);
var missingRoll = service.RollSkill(ownerSession, Guid.NewGuid(), "public");

View File

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

View File

@@ -182,7 +182,7 @@
{
var isSelectedSkill = SelectedSkillId == 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>
}
</div>
@@ -207,6 +207,15 @@
else
{
<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><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>
}
@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">
<button type="submit" disabled="@IsMutating">Create Skill</button>
<button type="button" class="ghost" @onclick="CloseSkillModals">Cancel</button>
@@ -461,6 +481,17 @@
{
<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">
<button type="submit" disabled="@IsMutating">Save Skill</button>
<button type="button" class="ghost" @onclick="CloseSkillModals">Cancel</button>

View File

@@ -79,6 +79,7 @@ public partial class Home
private SkillSummary? SelectedSkill => SelectedCampaign?.Skills.FirstOrDefault(s => s.Id == SelectedSkillId);
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 IsSelectedCampaignD6 => string.Equals(SelectedCampaign?.RulesetId, "d6", StringComparison.OrdinalIgnoreCase);
private List<SkillSummary> SelectedCharacterSkills =>
SelectedCampaign is null || !SelectedCharacterId.HasValue
@@ -640,6 +641,8 @@ public partial class Home
{
SkillForm.Name = string.Empty;
SkillForm.DiceRollDefinition = string.Empty;
SkillForm.WildDice = IsSelectedCampaignD6 ? 1 : 0;
SkillForm.AllowFumble = IsSelectedCampaignD6;
SkillErrors.Clear();
SkillFormError = null;
ShowCreateSkillModal = true;
@@ -655,6 +658,8 @@ public partial class Home
EditingSkillId = SelectedSkill.Id;
EditSkillForm.Name = SelectedSkill.Name;
EditSkillForm.DiceRollDefinition = SelectedSkill.DiceRollDefinition;
EditSkillForm.WildDice = SelectedSkill.WildDice;
EditSkillForm.AllowFumble = SelectedSkill.AllowFumble;
EditSkillErrors.Clear();
EditSkillFormError = null;
ShowEditSkillModal = true;
@@ -688,6 +693,11 @@ public partial class Home
SkillErrors["diceRollDefinition"] = "Expression is required.";
}
if (IsSelectedCampaignD6 && SkillForm.WildDice < 1)
{
SkillErrors["wildDice"] = "D6 skills require at least one wild die.";
}
if (SkillErrors.Count > 0)
{
SkillFormError = "Resolve validation issues before submitting.";
@@ -697,7 +707,10 @@ public partial class Home
IsMutating = true;
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();
await RefreshCampaignScopeAsync();
SetStatus("Skill created.", false);
@@ -733,6 +746,11 @@ public partial class Home
EditSkillErrors["diceRollDefinition"] = "Expression is required.";
}
if (IsSelectedCampaignD6 && EditSkillForm.WildDice < 1)
{
EditSkillErrors["wildDice"] = "D6 skills require at least one wild die.";
}
if (EditSkillErrors.Count > 0)
{
EditSkillFormError = "Resolve validation issues before submitting.";
@@ -742,7 +760,10 @@ public partial class Home
IsMutating = true;
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;
CloseSkillModals();
await RefreshCampaignScopeAsync();
@@ -985,6 +1006,93 @@ public partial class Home
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)
{
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 DiceRollDefinition { get; set; } = string.Empty;
public int WildDice { get; set; }
public bool AllowFumble { get; set; }
}
private sealed class JsApiResponse

View File

@@ -25,11 +25,12 @@ public sealed record CreateCharacterRequest(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 CreateSkillRequest(string Name, string DiceRollDefinition);
public sealed record UpdateSkillRequest(string Name, string DiceRollDefinition);
public sealed record SkillSummary(Guid Id, Guid CharacterId, string Name, string DiceRollDefinition);
public sealed record CreateSkillRequest(string Name, string DiceRollDefinition, int WildDice, bool AllowFumble);
public sealed record UpdateSkillRequest(string Name, string DiceRollDefinition, int WildDice, bool AllowFumble);
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 RollDieResult(int Roll, bool Crit, bool Fumble, bool Wild, bool Removed, bool Added);
public sealed record RollResult(
Guid RollId,
Guid CampaignId,
@@ -39,6 +40,7 @@ public sealed record RollResult(
string Visibility,
int Result,
string Breakdown,
IReadOnlyList<RollDieResult> Dice,
DateTimeOffset TimestampUtc);
public sealed record CampaignLogEntry(

View File

@@ -59,6 +59,8 @@ public sealed class RpgRollerDbContext : DbContext
entity.HasKey(x => x.Id);
entity.Property(x => x.Name).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);
});

View File

@@ -52,6 +52,8 @@ public sealed class Skill
public required Guid CharacterId { get; set; }
public required string Name { get; set; }
public required string DiceRollDefinition { get; set; }
public required int WildDice { get; set; }
public required bool AllowFumble { get; set; }
}
public sealed class RollLogEntry

View File

@@ -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))
{
@@ -431,12 +431,20 @@ public sealed class GameService : IGameService
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
{
Id = Guid.NewGuid(),
CharacterId = character.Id,
Name = name.Trim(),
DiceRollDefinition = expressionValidation.Value!.Canonical
DiceRollDefinition = expressionValidation.Value!.Canonical,
WildDice = optionsValidation.Value!.WildDice,
AllowFumble = optionsValidation.Value.AllowFumble
};
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))
{
@@ -480,8 +488,16 @@ public sealed class GameService : IGameService
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.DiceRollDefinition = expressionValidation.Value!.Canonical;
skill.WildDice = optionsValidation.Value!.WildDice;
skill.AllowFumble = optionsValidation.Value.AllowFumble;
TouchCampaignLocked(campaign.Id);
PersistStateLocked();
@@ -523,7 +539,7 @@ public sealed class GameService : IGameService
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
{
Id = Guid.NewGuid(),
@@ -541,7 +557,7 @@ public sealed class GameService : IGameService
TouchCampaignLocked(campaign.Id);
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 dice = new RollDieResult[expression.DiceCount];
var total = expression.Modifier;
for (var i = 0; i < expression.DiceCount; i += 1)
{
var value = m_DiceRoller.Roll(expression.Sides);
diceValues[i] = value;
dice[i] = new RollDieResult(value, false, false, false, false, false);
total += value;
}
var modifierPart = expression.Modifier > 0 ? $"+{expression.Modifier}" : string.Empty;
var breakdown = $"{string.Join("+", diceValues)}{modifierPart}={total}";
return (total, breakdown);
return (total, BuildBreakdown(diceValues, expression.Modifier, total), dice);
}
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)
@@ -651,10 +790,10 @@ public sealed class GameService : IGameService
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(
entry.Id,
@@ -665,6 +804,7 @@ public sealed class GameService : IGameService
entry.Visibility == RollVisibility.Public ? "public" : "private",
entry.Result,
entry.Breakdown,
dice,
entry.TimestampUtc);
}
@@ -905,7 +1045,9 @@ public sealed class GameService : IGameService
Id = skill.Id,
CharacterId = skill.CharacterId,
Name = skill.Name,
DiceRollDefinition = skill.DiceRollDefinition
DiceRollDefinition = skill.DiceRollDefinition,
WildDice = skill.WildDice,
AllowFumble = skill.AllowFumble
};
}

View File

@@ -22,8 +22,8 @@ public interface IGameService
ServiceResult<bool> ActivateCharacter(string sessionToken, Guid characterId);
ServiceResult<IReadOnlyList<CharacterSummary>> GetCurrentCampaignCharacters(string sessionToken);
ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition);
ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, 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, int wildDice, bool allowFumble);
ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility);
ServiceResult<IReadOnlyList<CampaignLogEntry>> GetCampaignLog(string sessionToken, Guid campaignId);

View File

@@ -297,6 +297,54 @@ select:focus-visible {
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,
.muted {
color: var(--muted);

View File

@@ -12,8 +12,8 @@
- 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, 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 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

4
UX.md
View File

@@ -250,6 +250,9 @@ 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
@@ -258,6 +261,7 @@ Last roll card:
- Result total
- Breakdown
- Die-by-die visualization with states: `critical`, `fumble`, `wild`, `removed`, `added`
- Visibility
- Timestamp

View File

@@ -893,11 +893,20 @@
},
"diceRollDefinition": {
"type": "string"
},
"wildDice": {
"type": "integer",
"format": "int32"
},
"allowFumble": {
"type": "boolean"
}
},
"required": [
"name",
"diceRollDefinition"
"diceRollDefinition",
"wildDice",
"allowFumble"
]
},
"UpdateSkillRequest": {
@@ -908,11 +917,20 @@
},
"diceRollDefinition": {
"type": "string"
},
"wildDice": {
"type": "integer",
"format": "int32"
},
"allowFumble": {
"type": "boolean"
}
},
"required": [
"name",
"diceRollDefinition"
"diceRollDefinition",
"wildDice",
"allowFumble"
]
},
"SkillSummary": {
@@ -931,13 +949,22 @@
},
"diceRollDefinition": {
"type": "string"
},
"wildDice": {
"type": "integer",
"format": "int32"
},
"allowFumble": {
"type": "boolean"
}
},
"required": [
"id",
"characterId",
"name",
"diceRollDefinition"
"diceRollDefinition",
"wildDice",
"allowFumble"
]
},
"RollSkillRequest": {
@@ -951,6 +978,38 @@
"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": {
"type": "object",
"properties": {
@@ -984,6 +1043,12 @@
"breakdown": {
"type": "string"
},
"dice": {
"type": "array",
"items": {
"$ref": "#/components/schemas/RollDieResult"
}
},
"timestampUtc": {
"type": "string",
"format": "date-time"
@@ -998,6 +1063,7 @@
"visibility",
"result",
"breakdown",
"dice",
"timestampUtc"
]
},