From 11ab7c959b6f0406805400715ba2ad75a9c3c0de Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Thu, 26 Feb 2026 08:26:12 +0100 Subject: [PATCH] Implement d6 wild dice/fumble skills and die-state rolls --- FAQ.md | 9 + FRONTEND_PROGRESS.md | 2 +- README.md | 7 +- REQUIREMENTS.md | 7 + RpgRoller.Tests/Api/CampaignApiTests.cs | 10 +- RpgRoller.Tests/Api/RollVisibilityApiTests.cs | 4 +- .../Services/ServiceCampaignTests.cs | 4 +- .../Services/ServiceD6RollTests.cs | 80 +++++++++ .../Services/ServicePersistenceTests.cs | 14 +- .../Services/ServiceSkillRollTests.cs | 12 +- RpgRoller/Api/SkillEndpoints.cs | 4 +- RpgRoller/Components/Pages/Home.razor | 33 +++- RpgRoller/Components/Pages/Home.razor.cs | 114 +++++++++++- RpgRoller/Contracts/ApiContracts.cs | 8 +- RpgRoller/Data/RpgRollerDbContext.cs | 2 + RpgRoller/Domain/GameModels.cs | 2 + RpgRoller/Services/GameService.cs | 166 ++++++++++++++++-- RpgRoller/Services/IGameService.cs | 4 +- RpgRoller/wwwroot/styles.css | 48 +++++ TECH.md | 4 +- UX.md | 4 + openapi/RpgRoller.json | 72 +++++++- 22 files changed, 560 insertions(+), 50 deletions(-) create mode 100644 RpgRoller.Tests/Services/ServiceD6RollTests.cs diff --git a/FAQ.md b/FAQ.md index 24eaaec..54eaa2e 100644 --- a/FAQ.md +++ b/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? 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. diff --git a/FRONTEND_PROGRESS.md b/FRONTEND_PROGRESS.md index 62c8f4b..238a73a 100644 --- a/FRONTEND_PROGRESS.md +++ b/FRONTEND_PROGRESS.md @@ -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. | diff --git a/README.md b/README.md index 59639cd..384ecf5 100644 --- a/README.md +++ b/README.md @@ -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`) diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index 6bb5ba2..6c89e1c 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -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. diff --git a/RpgRoller.Tests/Api/CampaignApiTests.cs b/RpgRoller.Tests/Api/CampaignApiTests.cs index 4ac4a0e..c962357 100644 --- a/RpgRoller.Tests/Api/CampaignApiTests.cs +++ b/RpgRoller.Tests/Api/CampaignApiTests.cs @@ -34,19 +34,23 @@ public sealed class CampaignApiTests : ApiTestBase var createdSkill = await PostAsync( 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( 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(gmClient, $"/api/campaigns/{campaign.Id}"); diff --git a/RpgRoller.Tests/Api/RollVisibilityApiTests.cs b/RpgRoller.Tests/Api/RollVisibilityApiTests.cs index dc5a9e8..ebb429d 100644 --- a/RpgRoller.Tests/Api/RollVisibilityApiTests.cs +++ b/RpgRoller.Tests/Api/RollVisibilityApiTests.cs @@ -35,7 +35,9 @@ public sealed class RollVisibilityApiTests : ApiTestBase var skill = await PostAsync( 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"); diff --git a/RpgRoller.Tests/Services/ServiceCampaignTests.cs b/RpgRoller.Tests/Services/ServiceCampaignTests.cs index c3bca70..1f1348b 100644 --- a/RpgRoller.Tests/Services/ServiceCampaignTests.cs +++ b/RpgRoller.Tests/Services/ServiceCampaignTests.cs @@ -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); diff --git a/RpgRoller.Tests/Services/ServiceD6RollTests.cs b/RpgRoller.Tests/Services/ServiceD6RollTests.cs new file mode 100644 index 0000000..a51aa9f --- /dev/null +++ b/RpgRoller.Tests/Services/ServiceD6RollTests.cs @@ -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); + } +} diff --git a/RpgRoller.Tests/Services/ServicePersistenceTests.cs b/RpgRoller.Tests/Services/ServicePersistenceTests.cs index 1bc4355..445556e 100644 --- a/RpgRoller.Tests/Services/ServicePersistenceTests.cs +++ b/RpgRoller.Tests/Services/ServicePersistenceTests.cs @@ -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()) diff --git a/RpgRoller.Tests/Services/ServiceSkillRollTests.cs b/RpgRoller.Tests/Services/ServiceSkillRollTests.cs index ec8296a..c878784 100644 --- a/RpgRoller.Tests/Services/ServiceSkillRollTests.cs +++ b/RpgRoller.Tests/Services/ServiceSkillRollTests.cs @@ -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"); diff --git a/RpgRoller/Api/SkillEndpoints.cs b/RpgRoller/Api/SkillEndpoints.cs index f568f12..1fc1b21 100644 --- a/RpgRoller/Api/SkillEndpoints.cs +++ b/RpgRoller/Api/SkillEndpoints.cs @@ -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); }); diff --git a/RpgRoller/Components/Pages/Home.razor b/RpgRoller/Components/Pages/Home.razor index 1cba251..4d81d69 100644 --- a/RpgRoller/Components/Pages/Home.razor +++ b/RpgRoller/Components/Pages/Home.razor @@ -182,7 +182,7 @@ { var isSelectedSkill = SelectedSkillId == skill.Id; } @@ -207,6 +207,15 @@ else {

@LastRoll.Result

+ @if (LastRoll.Dice.Count > 0) + { +
+ @foreach (var die in LastRoll.Dice) + { + @RollDieGlyph(die.Roll) + } +
+ }

@LastRoll.Breakdown

@LastRoll.Visibility

} @@ -430,6 +439,17 @@ {

@createSkillExpressionError

} + @if (IsSelectedCampaignD6) + { + + + @if (SkillErrors.TryGetValue("wildDice", out var createSkillWildDiceError)) + { +

@createSkillWildDiceError

+ } + + + }
@@ -461,6 +481,17 @@ {

@editSkillExpressionError

} + @if (IsSelectedCampaignD6) + { + + + @if (EditSkillErrors.TryGetValue("wildDice", out var editSkillWildDiceError)) + { +

@editSkillWildDiceError

+ } + + + }
diff --git a/RpgRoller/Components/Pages/Home.razor.cs b/RpgRoller/Components/Pages/Home.razor.cs index f97add5..9ee7153 100644 --- a/RpgRoller/Components/Pages/Home.razor.cs +++ b/RpgRoller/Components/Pages/Home.razor.cs @@ -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 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("POST", $"/api/characters/{SelectedCharacter.Id}/skills", new CreateSkillRequest(SkillForm.Name.Trim(), SkillForm.DiceRollDefinition.Trim())); + _ = await RequestAsync( + "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("PUT", $"/api/skills/{EditingSkillId.Value}", new UpdateSkillRequest(EditSkillForm.Name.Trim(), EditSkillForm.DiceRollDefinition.Trim())); + var updatedSkill = await RequestAsync( + "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 { "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 { $"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 diff --git a/RpgRoller/Contracts/ApiContracts.cs b/RpgRoller/Contracts/ApiContracts.cs index 77465b1..df1fe22 100644 --- a/RpgRoller/Contracts/ApiContracts.cs +++ b/RpgRoller/Contracts/ApiContracts.cs @@ -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 Dice, DateTimeOffset TimestampUtc); public sealed record CampaignLogEntry( diff --git a/RpgRoller/Data/RpgRollerDbContext.cs b/RpgRoller/Data/RpgRollerDbContext.cs index 307498c..a3f6797 100644 --- a/RpgRoller/Data/RpgRollerDbContext.cs +++ b/RpgRoller/Data/RpgRollerDbContext.cs @@ -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); }); diff --git a/RpgRoller/Domain/GameModels.cs b/RpgRoller/Domain/GameModels.cs index 2f21828..e133f6b 100644 --- a/RpgRoller/Domain/GameModels.cs +++ b/RpgRoller/Domain/GameModels.cs @@ -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 diff --git a/RpgRoller/Services/GameService.cs b/RpgRoller/Services/GameService.cs index 4ab6f7e..9e4a9d0 100644 --- a/RpgRoller/Services/GameService.cs +++ b/RpgRoller/Services/GameService.cs @@ -399,7 +399,7 @@ public sealed class GameService : IGameService } } - public ServiceResult CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition) + public ServiceResult 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.Failure(expressionValidation.Error!.Code, expressionValidation.Error.Message); } + var optionsValidation = ValidateSkillOptions(campaign.Ruleset, wildDice, allowFumble); + if (!optionsValidation.Succeeded) + { + return ServiceResult.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 UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition) + public ServiceResult 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.Failure(expressionValidation.Error!.Code, expressionValidation.Error.Message); } + var optionsValidation = ValidateSkillOptions(campaign.Ruleset, wildDice, allowFumble); + if (!optionsValidation.Succeeded) + { + return ServiceResult.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.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.Success(ToRollResult(entry)); + return ServiceResult.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 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 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 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(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(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 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 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 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 }; } diff --git a/RpgRoller/Services/IGameService.cs b/RpgRoller/Services/IGameService.cs index f4ff386..83fe07b 100644 --- a/RpgRoller/Services/IGameService.cs +++ b/RpgRoller/Services/IGameService.cs @@ -22,8 +22,8 @@ public interface IGameService ServiceResult ActivateCharacter(string sessionToken, Guid characterId); ServiceResult> GetCurrentCampaignCharacters(string sessionToken); - ServiceResult CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition); - ServiceResult UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition); + ServiceResult CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble); + ServiceResult UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble); ServiceResult RollSkill(string sessionToken, Guid skillId, string visibility); ServiceResult> GetCampaignLog(string sessionToken, Guid campaignId); diff --git a/RpgRoller/wwwroot/styles.css b/RpgRoller/wwwroot/styles.css index f63ff09..e3e6f0b 100644 --- a/RpgRoller/wwwroot/styles.css +++ b/RpgRoller/wwwroot/styles.css @@ -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); diff --git a/TECH.md b/TECH.md index ef82f59..308a9de 100644 --- a/TECH.md +++ b/TECH.md @@ -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 diff --git a/UX.md b/UX.md index 0a67250..5988228 100644 --- a/UX.md +++ b/UX.md @@ -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 diff --git a/openapi/RpgRoller.json b/openapi/RpgRoller.json index 3ce7375..7f576c4 100644 --- a/openapi/RpgRoller.json +++ b/openapi/RpgRoller.json @@ -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" ] },