From d38003a77c36fa1c6a25028f12686955c4e2a594 Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Tue, 14 Apr 2026 22:49:14 +0200 Subject: [PATCH] Add rolemaster auto retry skill toggle --- README.md | 1 + RpgRoller.Tests/Api/CampaignApiTests.cs | 17 +- RpgRoller.Tests/HostingCoverageTests.cs | 17 ++ .../Services/ServiceHelperExtractionTests.cs | 10 +- .../Services/ServicePersistenceTests.cs | 5 +- .../Services/ServiceSharedHelperTests.cs | 4 +- .../ServiceSkillGroupAndOwnershipTests.cs | 7 + .../Services/WorkspaceQueryServiceTests.cs | 4 +- .../Services/WorkspaceStateTests.cs | 6 +- RpgRoller/Api/SkillEndpoints.cs | 4 +- RpgRoller/Components/Pages/Home.Models.cs | 1 + .../HomeControls/CharacterPanel.razor.cs | 6 +- .../Pages/HomeControls/RulesetFormHelpers.cs | 17 +- .../Pages/HomeControls/SkillFormModal.razor | 4 + .../HomeControls/SkillFormModal.razor.cs | 12 +- RpgRoller/Components/Pages/WorkspaceState.cs | 2 +- RpgRoller/Contracts/ApiContracts.cs | 8 +- RpgRoller/Data/RpgRollerDbContext.cs | 1 + RpgRoller/Domain/GameModels.cs | 1 + ...4204309_AddRolemasterAutoRetry.Designer.cs | 269 ++++++++++++++++++ .../20260414204309_AddRolemasterAutoRetry.cs | 29 ++ .../RpgRollerDbContextModelSnapshot.cs | 5 + RpgRoller/Services/GameDtoMapper.cs | 4 +- RpgRoller/Services/GameService.cs | 8 +- RpgRoller/Services/GameSkillService.cs | 12 +- RpgRoller/Services/GameStateCloneFactory.cs | 3 +- RpgRoller/Services/IGameService.cs | 4 +- .../Services/SkillDefinitionValidator.cs | 47 +-- TASKS.md | 40 +-- 29 files changed, 468 insertions(+), 80 deletions(-) create mode 100644 RpgRoller/Migrations/20260414204309_AddRolemasterAutoRetry.Designer.cs create mode 100644 RpgRoller/Migrations/20260414204309_AddRolemasterAutoRetry.cs diff --git a/README.md b/README.md index 71f0cef..f03889c 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,7 @@ Rolemaster support: - Standard expressions such as `d10`, `15d10`, `2d10+48`, and `d100-15` - Open-ended percentile expressions such as `d100!+85` - Conditional `FumbleRange` handling for open-ended percentile skills and skill-group defaults +- Persisted and validated automatic retry toggle for open-ended percentile skills; only eligible Rolemaster skills can enable it - Open-ended high chaining and low-end subtraction with ordered die metadata in roll detail - Compact log badges and summaries for open-ended/fumble-related events diff --git a/RpgRoller.Tests/Api/CampaignApiTests.cs b/RpgRoller.Tests/Api/CampaignApiTests.cs index 107dae3..040066b 100644 --- a/RpgRoller.Tests/Api/CampaignApiTests.cs +++ b/RpgRoller.Tests/Api/CampaignApiTests.cs @@ -76,7 +76,7 @@ public sealed class CampaignApiTests(WebApplicationFactory factory) : A } [Fact] - public async Task RolemasterSkillDefinitions_RoundTripFumbleRangeThroughApi() + public async Task RolemasterSkillDefinitions_RoundTripRetryAndFumbleOptionsThroughApi() { using var factory = CreateFactory(88, 42, 17); using var gmClient = factory.CreateClient(new() { AllowAutoRedirect = false }); @@ -93,15 +93,22 @@ public sealed class CampaignApiTests(WebApplicationFactory factory) : A var group = await PostAsync(gmClient, $"/api/characters/{character.Id}/skill-groups", new("Perception", "d100!+15", 0, false, 5)); Assert.Equal(5, group.FumbleRange); - var skill = await PostAsync(gmClient, $"/api/characters/{character.Id}/skills", new("Awareness", "d100!+35", 0, false, group.Id, 3)); - Assert.Equal(3, skill.FumbleRange); + var invalidRetry = await gmClient.PostAsJsonAsync($"/api/characters/{character.Id}/skills", new CreateSkillRequest("Bad Retry", "d100+35", 0, false, group.Id, null, true)); + Assert.Equal(HttpStatusCode.BadRequest, invalidRetry.StatusCode); - var updatedSkill = await PutAsync(gmClient, $"/api/skills/{skill.Id}", new("Awareness", "d100!+45", 0, false, group.Id, 4)); + var skill = await PostAsync(gmClient, $"/api/characters/{character.Id}/skills", new("Awareness", "d100!+35", 0, false, group.Id, 3, true)); + Assert.Equal(3, skill.FumbleRange); + Assert.True(skill.RolemasterAutoRetry); + + var updatedSkill = await PutAsync(gmClient, $"/api/skills/{skill.Id}", new("Awareness", "d100!+45", 0, false, group.Id, 4, true)); Assert.Equal(4, updatedSkill.FumbleRange); + Assert.True(updatedSkill.RolemasterAutoRetry); var sheet = await GetAsync(gmClient, $"/api/characters/{character.Id}/sheet"); Assert.Equal(5, Assert.Single(sheet.SkillGroups).FumbleRange); - Assert.Equal(4, Assert.Single(sheet.Skills).FumbleRange); + var sheetSkill = Assert.Single(sheet.Skills); + Assert.Equal(4, sheetSkill.FumbleRange); + Assert.True(sheetSkill.RolemasterAutoRetry); } [Fact] diff --git a/RpgRoller.Tests/HostingCoverageTests.cs b/RpgRoller.Tests/HostingCoverageTests.cs index 6f2b3d0..754ea81 100644 --- a/RpgRoller.Tests/HostingCoverageTests.cs +++ b/RpgRoller.Tests/HostingCoverageTests.cs @@ -131,6 +131,7 @@ public sealed class HostingCoverageTests Assert.Contains("WildDice", columns); Assert.Contains("AllowFumble", columns); Assert.Contains("FumbleRange", columns); + Assert.Contains("RolemasterAutoRetry", columns); using var skillGroupsTableInfoCommand = verifyConnection.CreateCommand(); skillGroupsTableInfoCommand.CommandText = "PRAGMA table_info('SkillGroups');"; @@ -208,6 +209,11 @@ public sealed class HostingCoverageTests rolemasterHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260402222501_AddRolemasterFumbleRange';"; var rolemasterHistoryCount = Convert.ToInt32(rolemasterHistoryCommand.ExecuteScalar()); Assert.Equal(1, rolemasterHistoryCount); + + using var retryHistoryCommand = verifyConnection.CreateCommand(); + retryHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260414204309_AddRolemasterAutoRetry';"; + var retryHistoryCount = Convert.ToInt32(retryHistoryCommand.ExecuteScalar()); + Assert.Equal(1, retryHistoryCount); } [Fact] @@ -348,6 +354,11 @@ public sealed class HostingCoverageTests rolemasterHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260402222501_AddRolemasterFumbleRange';"; var rolemasterHistoryCount = Convert.ToInt32(rolemasterHistoryCommand.ExecuteScalar()); Assert.Equal(1, rolemasterHistoryCount); + + using var retryHistoryCommand = verifyConnection.CreateCommand(); + retryHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260414204309_AddRolemasterAutoRetry';"; + var retryHistoryCount = Convert.ToInt32(retryHistoryCommand.ExecuteScalar()); + Assert.Equal(1, retryHistoryCount); } [Fact] @@ -459,6 +470,7 @@ public sealed class HostingCoverageTests skillColumns.Add(skillsTableInfoReader.GetString(1)); Assert.Contains("FumbleRange", skillColumns); + Assert.Contains("RolemasterAutoRetry", skillColumns); using var skillGroupsTableInfoCommand = verifyConnection.CreateCommand(); skillGroupsTableInfoCommand.CommandText = "PRAGMA table_info('SkillGroups');"; @@ -473,5 +485,10 @@ public sealed class HostingCoverageTests authorizationRolesHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260226170000_AddAuthorizationRoles';"; var authorizationRolesHistoryCount = Convert.ToInt32(authorizationRolesHistoryCommand.ExecuteScalar()); Assert.Equal(1, authorizationRolesHistoryCount); + + using var retryHistoryCommand = verifyConnection.CreateCommand(); + retryHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260414204309_AddRolemasterAutoRetry';"; + var retryHistoryCount = Convert.ToInt32(retryHistoryCommand.ExecuteScalar()); + Assert.Equal(1, retryHistoryCount); } } \ No newline at end of file diff --git a/RpgRoller.Tests/Services/ServiceHelperExtractionTests.cs b/RpgRoller.Tests/Services/ServiceHelperExtractionTests.cs index fe39310..af43714 100644 --- a/RpgRoller.Tests/Services/ServiceHelperExtractionTests.cs +++ b/RpgRoller.Tests/Services/ServiceHelperExtractionTests.cs @@ -48,20 +48,24 @@ public sealed class ServiceHelperExtractionTests public void SkillDefinitionValidator_ValidatesRulesetSpecificOptions() { var d6 = SkillDefinitionValidator.Validate(RulesetKind.D6, "2D+1", 1, true, null); - var rolemaster = SkillDefinitionValidator.Validate(RulesetKind.Rolemaster, "d100!+15", 0, false, 5); + var rolemaster = SkillDefinitionValidator.Validate(RulesetKind.Rolemaster, "d100!+15", 0, false, 5, true); var invalidD6 = SkillDefinitionValidator.Validate(RulesetKind.D6, "2D+1", 0, true, null); var invalidRolemaster = SkillDefinitionValidator.Validate(RulesetKind.Rolemaster, "d100!+15", 0, false, null); + var invalidRetry = SkillDefinitionValidator.Validate(RulesetKind.Rolemaster, "d100+15", 0, false, null, true); Assert.True(d6.Succeeded); - Assert.Equal(("2D+1", 1, true, (int?)null), d6.Value); + Assert.Equal(("2D+1", 1, true, (int?)null, false), d6.Value); Assert.True(rolemaster.Succeeded); - Assert.Equal(("d100!+15", 0, false, (int?)5), rolemaster.Value); + Assert.Equal(("d100!+15", 0, false, (int?)5, true), rolemaster.Value); Assert.False(invalidD6.Succeeded); Assert.Equal("invalid_wild_dice", invalidD6.Error!.Code); Assert.False(invalidRolemaster.Succeeded); Assert.Equal("invalid_fumble_range", invalidRolemaster.Error!.Code); + + Assert.False(invalidRetry.Succeeded); + Assert.Equal("invalid_rolemaster_retry", invalidRetry.Error!.Code); } } \ No newline at end of file diff --git a/RpgRoller.Tests/Services/ServicePersistenceTests.cs b/RpgRoller.Tests/Services/ServicePersistenceTests.cs index 319d5b3..a0c8f3c 100644 --- a/RpgRoller.Tests/Services/ServicePersistenceTests.cs +++ b/RpgRoller.Tests/Services/ServicePersistenceTests.cs @@ -94,7 +94,7 @@ public sealed class ServicePersistenceTests } [Fact] - public void RolemasterFumbleRange_PersistsAcrossDatabaseReload() + public void RolemasterSkillOptions_PersistAcrossDatabaseReload() { using var harness = ServiceTestSupport.CreateHarness(); var service = harness.Service; @@ -108,7 +108,7 @@ public sealed class ServicePersistenceTests var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Rolemaster Persistence", "rolemaster")); var character = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Loremaster", campaign.Id)); var group = ServiceTestSupport.GetValue(service.CreateSkillGroup(ownerSession, character.Id, "Perception", "d100!+25", 0, false, 5)); - var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, character.Id, "Read Runes", "d100!+35", 0, false, group.Id, 3)); + var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, character.Id, "Read Runes", "d100!+35", 0, false, group.Id, 3, true)); using var reloadedHarness = ServiceTestSupport.CreateHarnessFromPath(harness.DbPath); var reloadedSheet = ServiceTestSupport.GetValue(reloadedHarness.Service.GetCharacterSheet(ownerSession, character.Id)); @@ -118,5 +118,6 @@ public sealed class ServicePersistenceTests var reloadedSkill = Assert.Single(reloadedSheet.Skills, current => current.Id == skill.Id); Assert.Equal(3, reloadedSkill.FumbleRange); + Assert.True(reloadedSkill.RolemasterAutoRetry); } } \ No newline at end of file diff --git a/RpgRoller.Tests/Services/ServiceSharedHelperTests.cs b/RpgRoller.Tests/Services/ServiceSharedHelperTests.cs index 53ea62b..ade2986 100644 --- a/RpgRoller.Tests/Services/ServiceSharedHelperTests.cs +++ b/RpgRoller.Tests/Services/ServiceSharedHelperTests.cs @@ -324,7 +324,8 @@ public sealed class ServiceSharedHelperTests DiceRollDefinition = "d100!+25", WildDice = 0, AllowFumble = false, - FumbleRange = 3 + FumbleRange = 3, + RolemasterAutoRetry = true }; store.RebuildCampaignStateLocked(); store.TouchRosterLocked(campaignId); @@ -371,6 +372,7 @@ public sealed class ServiceSharedHelperTests Assert.Single(sheet.Skills); Assert.Equal(5, groupSummary.FumbleRange); Assert.Equal(3, skillSummary.FumbleRange); + Assert.True(skillSummary.RolemasterAutoRetry); Assert.Equal("private", rollResult.Visibility); Assert.Equal("Owner", logDto.RollerDisplayName); Assert.Equal("private-self", logListDto.VisibilityStyle); diff --git a/RpgRoller.Tests/Services/ServiceSkillGroupAndOwnershipTests.cs b/RpgRoller.Tests/Services/ServiceSkillGroupAndOwnershipTests.cs index c371ac0..1778089 100644 --- a/RpgRoller.Tests/Services/ServiceSkillGroupAndOwnershipTests.cs +++ b/RpgRoller.Tests/Services/ServiceSkillGroupAndOwnershipTests.cs @@ -224,5 +224,12 @@ public sealed class ServiceSkillGroupAndOwnershipTests Assert.Equal(0, openEndedSkill.WildDice); Assert.False(openEndedSkill.AllowFumble); Assert.Equal(5, openEndedSkill.FumbleRange); + + var invalidRetrySkill = service.UpdateSkill(ownerSession, percentileSkill.Id, "Perception", "d100+15", 0, false, rolemasterGroup.Id, null, true); + Assert.False(invalidRetrySkill.Succeeded); + Assert.Equal("invalid_rolemaster_retry", invalidRetrySkill.Error!.Code); + + var retrySkill = ServiceTestSupport.GetValue(service.UpdateSkill(ownerSession, percentileSkill.Id, "Perception", "d100!+85", 0, false, rolemasterGroup.Id, 5, true)); + Assert.True(retrySkill.RolemasterAutoRetry); } } \ No newline at end of file diff --git a/RpgRoller.Tests/Services/WorkspaceQueryServiceTests.cs b/RpgRoller.Tests/Services/WorkspaceQueryServiceTests.cs index fe9b979..de9a8bf 100644 --- a/RpgRoller.Tests/Services/WorkspaceQueryServiceTests.cs +++ b/RpgRoller.Tests/Services/WorkspaceQueryServiceTests.cs @@ -122,12 +122,12 @@ public sealed class WorkspaceQueryServiceTests throw new NotSupportedException(); } - public ServiceResult CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null) + public ServiceResult CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null, bool rolemasterAutoRetry = false) { throw new NotSupportedException(); } - public ServiceResult UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null) + public ServiceResult UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null, bool rolemasterAutoRetry = false) { throw new NotSupportedException(); } diff --git a/RpgRoller.Tests/Services/WorkspaceStateTests.cs b/RpgRoller.Tests/Services/WorkspaceStateTests.cs index 07d6913..cd19652 100644 --- a/RpgRoller.Tests/Services/WorkspaceStateTests.cs +++ b/RpgRoller.Tests/Services/WorkspaceStateTests.cs @@ -27,13 +27,13 @@ public sealed class WorkspaceStateTests [Fact] public void SkillDefinitionLabel_FormatsD6RolemasterAndDefaultRulesets() { - var skill = new CharacterSheetSkill(Guid.NewGuid(), null, "Awareness", "d100!+15", 1, true, 5); + var skill = new CharacterSheetSkill(Guid.NewGuid(), null, "Awareness", "d100!+15", 1, true, 5, true); var state = new WorkspaceState { SelectedCampaign = new(Guid.NewGuid(), "Alpha", "d6", new(Guid.NewGuid(), "GM"), []) }; Assert.Equal("d100!+15, wild 1, fumble on", state.SkillDefinitionLabel(skill)); state.SelectedCampaign = new(Guid.NewGuid(), "Alpha", "rolemaster", new(Guid.NewGuid(), "GM"), []); - Assert.Equal("Open-ended percentile: d100!+15, fumble <= 5", state.SkillDefinitionLabel(skill)); + Assert.Equal("Open-ended percentile: d100!+15, fumble <= 5, auto retry", state.SkillDefinitionLabel(skill)); state.SelectedCampaign = new(Guid.NewGuid(), "Alpha", "dnd5e", new(Guid.NewGuid(), "GM"), []); Assert.Equal("d100!+15", state.SkillDefinitionLabel(skill)); @@ -52,7 +52,7 @@ public sealed class WorkspaceStateTests SelectedCampaign = new(Guid.NewGuid(), "Alpha", "d6", new(Guid.NewGuid(), "GM"), [ownedCharacter, secondOwnedCharacter, otherCharacter]), SelectedCharacterId = secondOwnedCharacter.Id, ActiveCharacterId = ownedCharacter.Id, - SelectedCharacterSkills = [new(Guid.NewGuid(), null, "Stealth", "2D+1", 1, true, null)], + SelectedCharacterSkills = [new(Guid.NewGuid(), null, "Stealth", "2D+1", 1, true, null, false)], SelectedCharacterSkillGroups = [new(Guid.NewGuid(), "Combat", "2D+1", 1, true, null)] }; diff --git a/RpgRoller/Api/SkillEndpoints.cs b/RpgRoller/Api/SkillEndpoints.cs index 9da8d33..7731a51 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, request.WildDice, request.AllowFumble, request.SkillGroupId, request.FumbleRange); + var result = game.CreateSkill(context.GetRequiredSessionToken(), characterId, request.Name, request.DiceRollDefinition, request.WildDice, request.AllowFumble, request.SkillGroupId, request.FumbleRange, request.RolemasterAutoRetry); return ApiResultMapper.ToApiResult(result); }); group.MapPut("/skills/{skillId:guid}", (Guid skillId, UpdateSkillRequest request, HttpContext context, IGameService game) => { - var result = game.UpdateSkill(context.GetRequiredSessionToken(), skillId, request.Name, request.DiceRollDefinition, request.WildDice, request.AllowFumble, request.SkillGroupId, request.FumbleRange); + var result = game.UpdateSkill(context.GetRequiredSessionToken(), skillId, request.Name, request.DiceRollDefinition, request.WildDice, request.AllowFumble, request.SkillGroupId, request.FumbleRange, request.RolemasterAutoRetry); return ApiResultMapper.ToApiResult(result); }); diff --git a/RpgRoller/Components/Pages/Home.Models.cs b/RpgRoller/Components/Pages/Home.Models.cs index 6d41d2f..282f085 100644 --- a/RpgRoller/Components/Pages/Home.Models.cs +++ b/RpgRoller/Components/Pages/Home.Models.cs @@ -48,6 +48,7 @@ public sealed class SkillFormModel public int WildDice { get; set; } public bool AllowFumble { get; set; } public int? FumbleRange { get; set; } + public bool RolemasterAutoRetry { get; set; } } public sealed class SkillGroupFormModel diff --git a/RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor.cs b/RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor.cs index 5288816..0c7469a 100644 --- a/RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor.cs +++ b/RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor.cs @@ -19,7 +19,8 @@ public partial class CharacterPanel SkillGroupId = selectedGroup?.Id.ToString() ?? string.Empty, WildDice = selectedGroup?.WildDice ?? (IsD6Ruleset ? 1 : 0), AllowFumble = selectedGroup?.AllowFumble ?? IsD6Ruleset, - FumbleRange = selectedGroup?.FumbleRange + FumbleRange = selectedGroup?.FumbleRange, + RolemasterAutoRetry = false }; if (IsRolemasterRuleset && string.IsNullOrWhiteSpace(CreateSkillInitialModel.DiceRollDefinition)) @@ -40,7 +41,8 @@ public partial class CharacterPanel SkillGroupId = skill.SkillGroupId?.ToString() ?? string.Empty, WildDice = skill.WildDice, AllowFumble = skill.AllowFumble, - FumbleRange = skill.FumbleRange + FumbleRange = skill.FumbleRange, + RolemasterAutoRetry = skill.RolemasterAutoRetry }; EditSkillFormVersion++; diff --git a/RpgRoller/Components/Pages/HomeControls/RulesetFormHelpers.cs b/RpgRoller/Components/Pages/HomeControls/RulesetFormHelpers.cs index a761ca8..c58134e 100644 --- a/RpgRoller/Components/Pages/HomeControls/RulesetFormHelpers.cs +++ b/RpgRoller/Components/Pages/HomeControls/RulesetFormHelpers.cs @@ -30,7 +30,7 @@ internal static class RulesetFormHelpers return parseResult.Succeeded && parseResult.Value is not null && parseResult.Value.Kind == DiceExpressionKind.RolemasterOpenEndedPercentile; } - public static string DescribeRolemasterExpression(string expression, int? fumbleRange) + public static string DescribeRolemasterExpression(string expression, int? fumbleRange, bool rolemasterAutoRetry = false) { var parseResult = TryParseRolemasterExpression(expression); if (!parseResult.Succeeded || parseResult.Value is null) @@ -38,7 +38,7 @@ internal static class RulesetFormHelpers return parseResult.Value.Kind switch { - DiceExpressionKind.RolemasterOpenEndedPercentile => fumbleRange.HasValue ? $"Open-ended percentile: {parseResult.Value.Canonical}, fumble <= {fumbleRange.Value}" : $"Open-ended percentile: {parseResult.Value.Canonical}", + DiceExpressionKind.RolemasterOpenEndedPercentile => DescribeOpenEndedExpression(parseResult.Value.Canonical, fumbleRange, rolemasterAutoRetry), _ => $"Rolemaster: {parseResult.Value.Canonical}" }; } @@ -55,4 +55,17 @@ internal static class RulesetFormHelpers return DiceRules.ParseExpression(RulesetKind.Rolemaster, expression); } + + private static string DescribeOpenEndedExpression(string canonicalExpression, int? fumbleRange, bool rolemasterAutoRetry) + { + var parts = new List { $"Open-ended percentile: {canonicalExpression}" }; + + if (fumbleRange.HasValue) + parts.Add($"fumble <= {fumbleRange.Value}"); + + if (rolemasterAutoRetry) + parts.Add("auto retry"); + + return string.Join(", ", parts); + } } \ No newline at end of file diff --git a/RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor b/RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor index 0373c20..2c70446 100644 --- a/RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor +++ b/RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor @@ -56,6 +56,10 @@ {

@fumbleRangeError

} + + + +

When later enabled in rolling, retry bands are 77-90 and 91-110.

} }
diff --git a/RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor.cs b/RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor.cs index 17d4a4d..b394f09 100644 --- a/RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor.cs +++ b/RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor.cs @@ -19,6 +19,7 @@ public partial class SkillFormModal FormState.Model.WildDice = InitialModel.WildDice; FormState.Model.AllowFumble = InitialModel.AllowFumble; FormState.Model.FumbleRange = InitialModel.FumbleRange; + FormState.Model.RolemasterAutoRetry = InitialModel.RolemasterAutoRetry; SynchronizeRulesetSpecificFields(); FormState.ResetValidation(); AppliedFormVersion = FormVersion; @@ -53,7 +54,10 @@ public partial class SkillFormModal FormState.Errors["fumbleRange"] = "Open-ended Rolemaster skills require a fumble range."; } else + { FormState.Model.FumbleRange = null; + FormState.Model.RolemasterAutoRetry = false; + } if (!IsD6Ruleset) { @@ -81,7 +85,7 @@ public partial class SkillFormModal { SkillSummary skill; if (EditingSkillId.HasValue) - skill = await ApiClient.RequestAsync("PUT", $"/api/skills/{EditingSkillId.Value}", new UpdateSkillRequest(FormState.Model.Name.Trim(), FormState.Model.DiceRollDefinition.Trim(), FormState.Model.WildDice, FormState.Model.AllowFumble, skillGroupId, FormState.Model.FumbleRange)); + skill = await ApiClient.RequestAsync("PUT", $"/api/skills/{EditingSkillId.Value}", new UpdateSkillRequest(FormState.Model.Name.Trim(), FormState.Model.DiceRollDefinition.Trim(), FormState.Model.WildDice, FormState.Model.AllowFumble, skillGroupId, FormState.Model.FumbleRange, FormState.Model.RolemasterAutoRetry)); else { if (!SelectedCharacterId.HasValue) @@ -90,7 +94,7 @@ public partial class SkillFormModal return; } - skill = await ApiClient.RequestAsync("POST", $"/api/characters/{SelectedCharacterId.Value}/skills", new CreateSkillRequest(FormState.Model.Name.Trim(), FormState.Model.DiceRollDefinition.Trim(), FormState.Model.WildDice, FormState.Model.AllowFumble, skillGroupId, FormState.Model.FumbleRange)); + skill = await ApiClient.RequestAsync("POST", $"/api/characters/{SelectedCharacterId.Value}/skills", new CreateSkillRequest(FormState.Model.Name.Trim(), FormState.Model.DiceRollDefinition.Trim(), FormState.Model.WildDice, FormState.Model.AllowFumble, skillGroupId, FormState.Model.FumbleRange, FormState.Model.RolemasterAutoRetry)); } await SkillSaved.InvokeAsync(skill.Id); @@ -115,7 +119,10 @@ public partial class SkillFormModal private void SynchronizeRulesetSpecificFields() { if (!IsRolemasterRuleset) + { + FormState.Model.RolemasterAutoRetry = false; return; + } NormalizeRolemasterFumbleRange(); } @@ -135,6 +142,7 @@ public partial class SkillFormModal } FormState.Model.FumbleRange = null; + FormState.Model.RolemasterAutoRetry = false; } private bool IsD6Ruleset => RulesetFormHelpers.IsD6(RulesetId); diff --git a/RpgRoller/Components/Pages/WorkspaceState.cs b/RpgRoller/Components/Pages/WorkspaceState.cs index 71895a2..f315c56 100644 --- a/RpgRoller/Components/Pages/WorkspaceState.cs +++ b/RpgRoller/Components/Pages/WorkspaceState.cs @@ -27,7 +27,7 @@ public sealed class WorkspaceState if (!string.Equals(SelectedCampaign?.RulesetId, "d6", StringComparison.OrdinalIgnoreCase)) { if (string.Equals(SelectedCampaign?.RulesetId, RulesetFormHelpers.RulesetIds.Rolemaster, StringComparison.OrdinalIgnoreCase)) - return RulesetFormHelpers.DescribeRolemasterExpression(skill.DiceRollDefinition, skill.FumbleRange); + return RulesetFormHelpers.DescribeRolemasterExpression(skill.DiceRollDefinition, skill.FumbleRange, skill.RolemasterAutoRetry); return skill.DiceRollDefinition; } diff --git a/RpgRoller/Contracts/ApiContracts.cs b/RpgRoller/Contracts/ApiContracts.cs index 5257387..3c761b4 100644 --- a/RpgRoller/Contracts/ApiContracts.cs +++ b/RpgRoller/Contracts/ApiContracts.cs @@ -36,9 +36,9 @@ public sealed record UpdateCharacterRequest(string Name, Guid? CampaignId, strin public sealed record CharacterSummary(Guid Id, string Name, Guid OwnerUserId, Guid? CampaignId, string OwnerDisplayName); -public sealed record CreateSkillRequest(string Name, string DiceRollDefinition, int WildDice, bool AllowFumble, Guid? SkillGroupId = null, int? FumbleRange = null); +public sealed record CreateSkillRequest(string Name, string DiceRollDefinition, int WildDice, bool AllowFumble, Guid? SkillGroupId = null, int? FumbleRange = null, bool RolemasterAutoRetry = false); -public sealed record UpdateSkillRequest(string Name, string DiceRollDefinition, int WildDice, bool AllowFumble, Guid? SkillGroupId = null, int? FumbleRange = null); +public sealed record UpdateSkillRequest(string Name, string DiceRollDefinition, int WildDice, bool AllowFumble, Guid? SkillGroupId = null, int? FumbleRange = null, bool RolemasterAutoRetry = false); public sealed record SkillGroupSummary(Guid Id, Guid CharacterId, string Name, string DiceRollDefinition, int WildDice, bool AllowFumble, int? FumbleRange); @@ -46,7 +46,7 @@ public sealed record CreateSkillGroupRequest(string Name, string DiceRollDefinit public sealed record UpdateSkillGroupRequest(string Name, string DiceRollDefinition, int WildDice, bool AllowFumble, int? FumbleRange = null); -public sealed record SkillSummary(Guid Id, Guid CharacterId, Guid? SkillGroupId, string Name, string DiceRollDefinition, int WildDice, bool AllowFumble, int? FumbleRange); +public sealed record SkillSummary(Guid Id, Guid CharacterId, Guid? SkillGroupId, string Name, string DiceRollDefinition, int WildDice, bool AllowFumble, int? FumbleRange, bool RolemasterAutoRetry); public sealed record RollSkillRequest(string Visibility); @@ -100,7 +100,7 @@ public sealed record RollResult(Guid RollId, Guid CampaignId, Guid CharacterId, public sealed record CharacterSheetSkillGroup(Guid Id, string Name, string DiceRollDefinition, int WildDice, bool AllowFumble, int? FumbleRange); -public sealed record CharacterSheetSkill(Guid Id, Guid? SkillGroupId, string Name, string DiceRollDefinition, int WildDice, bool AllowFumble, int? FumbleRange); +public sealed record CharacterSheetSkill(Guid Id, Guid? SkillGroupId, string Name, string DiceRollDefinition, int WildDice, bool AllowFumble, int? FumbleRange, bool RolemasterAutoRetry); public sealed record CharacterSheet(Guid CharacterId, CharacterSheetSkillGroup[] SkillGroups, CharacterSheetSkill[] Skills); diff --git a/RpgRoller/Data/RpgRollerDbContext.cs b/RpgRoller/Data/RpgRollerDbContext.cs index 23f7761..0181750 100644 --- a/RpgRoller/Data/RpgRollerDbContext.cs +++ b/RpgRoller/Data/RpgRollerDbContext.cs @@ -51,6 +51,7 @@ public sealed class RpgRollerDbContext(DbContextOptions opti entity.Property(x => x.WildDice).IsRequired(); entity.Property(x => x.AllowFumble).IsRequired(); entity.Property(x => x.FumbleRange).IsRequired(false); + entity.Property(x => x.RolemasterAutoRetry).IsRequired().HasDefaultValue(false); entity.HasIndex(x => x.CharacterId); entity.HasIndex(x => x.SkillGroupId); }); diff --git a/RpgRoller/Domain/GameModels.cs b/RpgRoller/Domain/GameModels.cs index 4dc9992..dbaf3a1 100644 --- a/RpgRoller/Domain/GameModels.cs +++ b/RpgRoller/Domain/GameModels.cs @@ -74,6 +74,7 @@ public sealed class Skill public required int WildDice { get; set; } public required bool AllowFumble { get; set; } public int? FumbleRange { get; set; } + public bool RolemasterAutoRetry { get; set; } } public sealed class RollLogEntry diff --git a/RpgRoller/Migrations/20260414204309_AddRolemasterAutoRetry.Designer.cs b/RpgRoller/Migrations/20260414204309_AddRolemasterAutoRetry.Designer.cs new file mode 100644 index 0000000..a8eefdb --- /dev/null +++ b/RpgRoller/Migrations/20260414204309_AddRolemasterAutoRetry.Designer.cs @@ -0,0 +1,269 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using RpgRoller.Data; + +#nullable disable + +namespace RpgRoller.Migrations +{ + [DbContext(typeof(RpgRollerDbContext))] + [Migration("20260414204309_AddRolemasterAutoRetry")] + partial class AddRolemasterAutoRetry + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.2"); + + modelBuilder.Entity("RpgRoller.Domain.Campaign", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("GmUserId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Ruleset") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GmUserId"); + + b.ToTable("Campaigns"); + }); + + modelBuilder.Entity("RpgRoller.Domain.Character", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CampaignId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CampaignId"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("Characters"); + }); + + modelBuilder.Entity("RpgRoller.Domain.RollLogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Breakdown") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("CampaignId") + .HasColumnType("TEXT"); + + b.Property("CharacterId") + .HasColumnType("TEXT"); + + b.Property("Dice") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Result") + .HasColumnType("INTEGER"); + + b.Property("RollerUserId") + .HasColumnType("TEXT"); + + b.Property("SkillId") + .HasColumnType("TEXT"); + + b.Property("TimestampUtc") + .HasColumnType("TEXT"); + + b.Property("Visibility") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CampaignId"); + + b.HasIndex("CharacterId"); + + b.HasIndex("RollerUserId"); + + b.HasIndex("SkillId"); + + b.ToTable("RollLogEntries"); + }); + + modelBuilder.Entity("RpgRoller.Domain.Skill", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AllowFumble") + .HasColumnType("INTEGER"); + + b.Property("CharacterId") + .HasColumnType("TEXT"); + + b.Property("DiceRollDefinition") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("FumbleRange") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("RolemasterAutoRetry") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("SkillGroupId") + .HasColumnType("TEXT"); + + b.Property("WildDice") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CharacterId"); + + b.HasIndex("SkillGroupId"); + + b.ToTable("Skills"); + }); + + modelBuilder.Entity("RpgRoller.Domain.SkillGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AllowFumble") + .HasColumnType("INTEGER"); + + b.Property("CharacterId") + .HasColumnType("TEXT"); + + b.Property("DiceRollDefinition") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("FumbleRange") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("WildDice") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CharacterId"); + + b.ToTable("SkillGroups"); + }); + + modelBuilder.Entity("RpgRoller.Domain.UserAccount", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ActiveCharacterId") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Roles") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UsernameNormalized") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UsernameNormalized") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("RpgRoller.Domain.UserSession", b => + { + b.Property("Token") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Token"); + + b.HasIndex("UserId"); + + b.ToTable("Sessions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/RpgRoller/Migrations/20260414204309_AddRolemasterAutoRetry.cs b/RpgRoller/Migrations/20260414204309_AddRolemasterAutoRetry.cs new file mode 100644 index 0000000..10695d8 --- /dev/null +++ b/RpgRoller/Migrations/20260414204309_AddRolemasterAutoRetry.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace RpgRoller.Migrations +{ + /// + public partial class AddRolemasterAutoRetry : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "RolemasterAutoRetry", + table: "Skills", + type: "INTEGER", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "RolemasterAutoRetry", + table: "Skills"); + } + } +} diff --git a/RpgRoller/Migrations/RpgRollerDbContextModelSnapshot.cs b/RpgRoller/Migrations/RpgRollerDbContextModelSnapshot.cs index af25f4b..a987707 100644 --- a/RpgRoller/Migrations/RpgRollerDbContextModelSnapshot.cs +++ b/RpgRoller/Migrations/RpgRollerDbContextModelSnapshot.cs @@ -146,6 +146,11 @@ namespace RpgRoller.Migrations .HasMaxLength(128) .HasColumnType("TEXT"); + b.Property("RolemasterAutoRetry") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + b.Property("SkillGroupId") .HasColumnType("TEXT"); diff --git a/RpgRoller/Services/GameDtoMapper.cs b/RpgRoller/Services/GameDtoMapper.cs index fd2b081..d2e8c14 100644 --- a/RpgRoller/Services/GameDtoMapper.cs +++ b/RpgRoller/Services/GameDtoMapper.cs @@ -55,7 +55,7 @@ public static class GameDtoMapper public static SkillSummary ToSkillSummary(Skill skill) { - return new(skill.Id, skill.CharacterId, skill.SkillGroupId, skill.Name, skill.DiceRollDefinition, skill.WildDice, skill.AllowFumble, skill.FumbleRange); + return new(skill.Id, skill.CharacterId, skill.SkillGroupId, skill.Name, skill.DiceRollDefinition, skill.WildDice, skill.AllowFumble, skill.FumbleRange, skill.RolemasterAutoRetry); } public static RollResult ToRollResult(RollLogEntry entry, IReadOnlyList dice) @@ -98,6 +98,6 @@ public static class GameDtoMapper private static CharacterSheetSkill ToCharacterSheetSkill(Skill skill) { - return new(skill.Id, skill.SkillGroupId, skill.Name, skill.DiceRollDefinition, skill.WildDice, skill.AllowFumble, skill.FumbleRange); + return new(skill.Id, skill.SkillGroupId, skill.Name, skill.DiceRollDefinition, skill.WildDice, skill.AllowFumble, skill.FumbleRange, skill.RolemasterAutoRetry); } } \ No newline at end of file diff --git a/RpgRoller/Services/GameService.cs b/RpgRoller/Services/GameService.cs index 88956e8..94cffc3 100644 --- a/RpgRoller/Services/GameService.cs +++ b/RpgRoller/Services/GameService.cs @@ -140,14 +140,14 @@ public sealed class GameService : IGameService return m_SkillService.DeleteSkillGroup(sessionToken, skillGroupId); } - public ServiceResult CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null) + public ServiceResult CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null, bool rolemasterAutoRetry = false) { - return m_SkillService.CreateSkill(sessionToken, characterId, name, diceRollDefinition, wildDice, allowFumble, skillGroupId, fumbleRange); + return m_SkillService.CreateSkill(sessionToken, characterId, name, diceRollDefinition, wildDice, allowFumble, skillGroupId, fumbleRange, rolemasterAutoRetry); } - public ServiceResult UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null) + public ServiceResult UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null, bool rolemasterAutoRetry = false) { - return m_SkillService.UpdateSkill(sessionToken, skillId, name, diceRollDefinition, wildDice, allowFumble, skillGroupId, fumbleRange); + return m_SkillService.UpdateSkill(sessionToken, skillId, name, diceRollDefinition, wildDice, allowFumble, skillGroupId, fumbleRange, rolemasterAutoRetry); } public ServiceResult DeleteSkill(string sessionToken, Guid skillId) diff --git a/RpgRoller/Services/GameSkillService.cs b/RpgRoller/Services/GameSkillService.cs index c77a8b4..9dfbf8a 100644 --- a/RpgRoller/Services/GameSkillService.cs +++ b/RpgRoller/Services/GameSkillService.cs @@ -114,7 +114,7 @@ public sealed class GameSkillService(GameStateStore stateStore, GamePersistenceS } } - public ServiceResult CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null) + public ServiceResult CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null, bool rolemasterAutoRetry = false) { if (string.IsNullOrWhiteSpace(name)) return ServiceResult.Failure("invalid_skill_name", "Skill name is required."); @@ -134,7 +134,7 @@ public sealed class GameSkillService(GameStateStore stateStore, GamePersistenceS if (!GameAuthorization.CanEditCharacter(user.Id, character, campaign)) return ServiceResult.Failure("forbidden", "Only the owner or GM can edit skills."); - var skillValidation = SkillDefinitionValidator.Validate(campaign.Ruleset, diceRollDefinition, wildDice, allowFumble, fumbleRange); + var skillValidation = SkillDefinitionValidator.Validate(campaign.Ruleset, diceRollDefinition, wildDice, allowFumble, fumbleRange, rolemasterAutoRetry); if (!skillValidation.Succeeded) return ServiceResult.Failure(skillValidation.Error!.Code, skillValidation.Error.Message); @@ -151,7 +151,8 @@ public sealed class GameSkillService(GameStateStore stateStore, GamePersistenceS DiceRollDefinition = skillValidation.Value.CanonicalExpression, WildDice = skillValidation.Value.WildDice, AllowFumble = skillValidation.Value.AllowFumble, - FumbleRange = skillValidation.Value.FumbleRange + FumbleRange = skillValidation.Value.FumbleRange, + RolemasterAutoRetry = skillValidation.Value.RolemasterAutoRetry }; stateStore.SkillsById[skill.Id] = skill; @@ -162,7 +163,7 @@ public sealed class GameSkillService(GameStateStore stateStore, GamePersistenceS } } - public ServiceResult UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null) + public ServiceResult UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null, bool rolemasterAutoRetry = false) { if (string.IsNullOrWhiteSpace(name)) return ServiceResult.Failure("invalid_skill_name", "Skill name is required."); @@ -183,7 +184,7 @@ public sealed class GameSkillService(GameStateStore stateStore, GamePersistenceS if (!GameAuthorization.CanEditCharacter(user.Id, character, campaign)) return ServiceResult.Failure("forbidden", "Only the owner or GM can edit skills."); - var skillValidation = SkillDefinitionValidator.Validate(campaign.Ruleset, diceRollDefinition, wildDice, allowFumble, fumbleRange); + var skillValidation = SkillDefinitionValidator.Validate(campaign.Ruleset, diceRollDefinition, wildDice, allowFumble, fumbleRange, rolemasterAutoRetry); if (!skillValidation.Succeeded) return ServiceResult.Failure(skillValidation.Error!.Code, skillValidation.Error.Message); @@ -196,6 +197,7 @@ public sealed class GameSkillService(GameStateStore stateStore, GamePersistenceS skill.WildDice = skillValidation.Value.WildDice; skill.AllowFumble = skillValidation.Value.AllowFumble; skill.FumbleRange = skillValidation.Value.FumbleRange; + skill.RolemasterAutoRetry = skillValidation.Value.RolemasterAutoRetry; skill.SkillGroupId = resolvedSkillGroupId.Value; stateStore.TouchCharacterLocked(campaign.Id, character.Id); diff --git a/RpgRoller/Services/GameStateCloneFactory.cs b/RpgRoller/Services/GameStateCloneFactory.cs index a2978ca..1146d55 100644 --- a/RpgRoller/Services/GameStateCloneFactory.cs +++ b/RpgRoller/Services/GameStateCloneFactory.cs @@ -62,7 +62,8 @@ public static class GameStateCloneFactory DiceRollDefinition = skill.DiceRollDefinition, WildDice = skill.WildDice, AllowFumble = skill.AllowFumble, - FumbleRange = skill.FumbleRange + FumbleRange = skill.FumbleRange, + RolemasterAutoRetry = skill.RolemasterAutoRetry }; } diff --git a/RpgRoller/Services/IGameService.cs b/RpgRoller/Services/IGameService.cs index 052021c..23454b0 100644 --- a/RpgRoller/Services/IGameService.cs +++ b/RpgRoller/Services/IGameService.cs @@ -31,8 +31,8 @@ public interface IGameService ServiceResult CreateSkillGroup(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, int? fumbleRange = null); ServiceResult UpdateSkillGroup(string sessionToken, Guid skillGroupId, string name, string diceRollDefinition, int wildDice, bool allowFumble, int? fumbleRange = null); ServiceResult DeleteSkillGroup(string sessionToken, Guid skillGroupId); - ServiceResult CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null); - ServiceResult UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null); + ServiceResult CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null, bool rolemasterAutoRetry = false); + ServiceResult UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null, bool rolemasterAutoRetry = false); ServiceResult DeleteSkill(string sessionToken, Guid skillId); ServiceResult GetCharacterSheet(string sessionToken, Guid characterId); diff --git a/RpgRoller/Services/SkillDefinitionValidator.cs b/RpgRoller/Services/SkillDefinitionValidator.cs index 66360c2..05855c5 100644 --- a/RpgRoller/Services/SkillDefinitionValidator.cs +++ b/RpgRoller/Services/SkillDefinitionValidator.cs @@ -4,63 +4,72 @@ namespace RpgRoller.Services; public static class SkillDefinitionValidator { - public static ServiceResult<(string CanonicalExpression, int WildDice, bool AllowFumble, int? FumbleRange)> Validate(RulesetKind ruleset, string diceRollDefinition, int wildDice, bool allowFumble, int? fumbleRange) + public static ServiceResult<(string CanonicalExpression, int WildDice, bool AllowFumble, int? FumbleRange, bool RolemasterAutoRetry)> Validate(RulesetKind ruleset, string diceRollDefinition, int wildDice, bool allowFumble, int? fumbleRange, bool rolemasterAutoRetry = false) { var expressionValidation = DiceRules.ParseExpression(ruleset, diceRollDefinition); if (!expressionValidation.Succeeded) - return ServiceResult<(string CanonicalExpression, int WildDice, bool AllowFumble, int? FumbleRange)>.Failure(expressionValidation.Error!.Code, expressionValidation.Error.Message); + return ServiceResult<(string CanonicalExpression, int WildDice, bool AllowFumble, int? FumbleRange, bool RolemasterAutoRetry)>.Failure(expressionValidation.Error!.Code, expressionValidation.Error.Message); - var optionsValidation = ValidateOptions(ruleset, expressionValidation.Value!, wildDice, allowFumble, fumbleRange); + var optionsValidation = ValidateOptions(ruleset, expressionValidation.Value!, wildDice, allowFumble, fumbleRange, rolemasterAutoRetry); if (!optionsValidation.Succeeded) - return ServiceResult<(string CanonicalExpression, int WildDice, bool AllowFumble, int? FumbleRange)>.Failure(optionsValidation.Error!.Code, optionsValidation.Error.Message); + return ServiceResult<(string CanonicalExpression, int WildDice, bool AllowFumble, int? FumbleRange, bool RolemasterAutoRetry)>.Failure(optionsValidation.Error!.Code, optionsValidation.Error.Message); - return ServiceResult<(string CanonicalExpression, int WildDice, bool AllowFumble, int? FumbleRange)>.Success((expressionValidation.Value!.Canonical, optionsValidation.Value.WildDice, optionsValidation.Value.AllowFumble, optionsValidation.Value.FumbleRange)); + return ServiceResult<(string CanonicalExpression, int WildDice, bool AllowFumble, int? FumbleRange, bool RolemasterAutoRetry)>.Success((expressionValidation.Value!.Canonical, optionsValidation.Value.WildDice, optionsValidation.Value.AllowFumble, optionsValidation.Value.FumbleRange, optionsValidation.Value.RolemasterAutoRetry)); } - private static ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)> ValidateOptions(RulesetKind ruleset, DiceExpression expression, int wildDice, bool allowFumble, int? fumbleRange) + private static ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange, bool RolemasterAutoRetry)> ValidateOptions(RulesetKind ruleset, DiceExpression expression, int wildDice, bool allowFumble, int? fumbleRange, bool rolemasterAutoRetry) { if (wildDice < 0 || wildDice > 50) - return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)>.Failure("invalid_wild_dice", "Wild dice must be between 0 and 50."); + return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange, bool RolemasterAutoRetry)>.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, int? FumbleRange)>.Failure("invalid_wild_dice", "D6 skills require at least one wild die."); + return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange, bool RolemasterAutoRetry)>.Failure("invalid_wild_dice", "D6 skills require at least one wild die."); if (fumbleRange.HasValue) - return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)>.Failure("invalid_fumble_range", "Fumble range is only supported for Rolemaster open-ended percentile skills."); + return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange, bool RolemasterAutoRetry)>.Failure("invalid_fumble_range", "Fumble range is only supported for Rolemaster open-ended percentile skills."); - return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)>.Success((wildDice, allowFumble, null)); + if (rolemasterAutoRetry) + return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange, bool RolemasterAutoRetry)>.Failure("invalid_rolemaster_retry", "Automatic retry is only supported for Rolemaster open-ended percentile skills."); + + return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange, bool RolemasterAutoRetry)>.Success((wildDice, allowFumble, null, false)); } if (ruleset == RulesetKind.Rolemaster) { if (wildDice != 0) - return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)>.Failure("invalid_wild_dice", "Rolemaster skills do not support wild dice."); + return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange, bool RolemasterAutoRetry)>.Failure("invalid_wild_dice", "Rolemaster skills do not support wild dice."); if (allowFumble) - return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)>.Failure("invalid_allow_fumble", "Rolemaster skills do not use the D6 allow-fumble option."); + return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange, bool RolemasterAutoRetry)>.Failure("invalid_allow_fumble", "Rolemaster skills do not use the D6 allow-fumble option."); if (expression.Kind == DiceExpressionKind.RolemasterOpenEndedPercentile) { if (!fumbleRange.HasValue) - return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)>.Failure("invalid_fumble_range", "Rolemaster open-ended percentile skills require a fumble range."); + return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange, bool RolemasterAutoRetry)>.Failure("invalid_fumble_range", "Rolemaster open-ended percentile skills require a fumble range."); if (fumbleRange < 0 || fumbleRange >= 96) - return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)>.Failure("invalid_fumble_range", "Fumble range must be between 0 and 95."); + return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange, bool RolemasterAutoRetry)>.Failure("invalid_fumble_range", "Fumble range must be between 0 and 95."); - return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)>.Success((0, false, fumbleRange)); + return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange, bool RolemasterAutoRetry)>.Success((0, false, fumbleRange, rolemasterAutoRetry)); } if (fumbleRange.HasValue) - return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)>.Failure("invalid_fumble_range", "Fumble range is only valid for Rolemaster open-ended percentile skills."); + return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange, bool RolemasterAutoRetry)>.Failure("invalid_fumble_range", "Fumble range is only valid for Rolemaster open-ended percentile skills."); - return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)>.Success((0, false, null)); + if (rolemasterAutoRetry) + return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange, bool RolemasterAutoRetry)>.Failure("invalid_rolemaster_retry", "Automatic retry is only supported for Rolemaster open-ended percentile skills."); + + return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange, bool RolemasterAutoRetry)>.Success((0, false, null, false)); } if (fumbleRange.HasValue) - return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)>.Failure("invalid_fumble_range", "Fumble range is only supported for Rolemaster open-ended percentile skills."); + return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange, bool RolemasterAutoRetry)>.Failure("invalid_fumble_range", "Fumble range is only supported for Rolemaster open-ended percentile skills."); - return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)>.Success((0, false, null)); + if (rolemasterAutoRetry) + return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange, bool RolemasterAutoRetry)>.Failure("invalid_rolemaster_retry", "Automatic retry is only supported for Rolemaster open-ended percentile skills."); + + return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange, bool RolemasterAutoRetry)>.Success((0, false, null, false)); } } \ No newline at end of file diff --git a/TASKS.md b/TASKS.md index 20b5bb9..da2188a 100644 --- a/TASKS.md +++ b/TASKS.md @@ -1,4 +1,4 @@ -# Rolemaster Automatic Skipp Retry +# Rolemaster Automatic Retry This ExecPlan is a living document. The sections `Progress`, `Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds. @@ -6,17 +6,17 @@ This ExecPlan is a living document. The sections `Progress`, `Surprises & Discov ## Purpose / Big Picture -After this change, a Rolemaster skill can opt into an automatic retry when its first result lands in specific “skipp” bands. The player will be able to toggle that behavior while creating or editing a Rolemaster open-ended skill, roll the skill, and then see the retry clearly in the campaign log card through a special badge and readable summary text. The detailed roll view will still show enough information to explain why the retry happened and what final result was recorded. +After this change, a Rolemaster skill can opt into an automatic retry when its first result lands in specific retry bands. The player will be able to toggle that behavior while creating or editing a Rolemaster open-ended skill, roll the skill, and then see the retry clearly in the campaign log card through a special badge and readable summary text. The detailed roll view will still show enough information to explain why the retry happened and what final result was recorded. -For this feature, a “skipp” means a Rolemaster open-ended percentile skill roll whose first fully evaluated result, including the skill expression modifier and any low-end subtraction chain, lands in one of the retry windows before any retry bonus is applied. This plan preserves the user-provided thresholds exactly: results `77` through `90` grant a retry with `+5`; results `91` through `110` grant a retry with `+10`. +For this feature, an eligible retry result means a Rolemaster open-ended percentile skill roll whose first fully evaluated result, including the skill expression modifier and any low-end subtraction chain, lands in one of the retry windows before any retry bonus is applied. This plan preserves the user-provided thresholds exactly: results `77` through `90` grant a retry with `+5`; results `91` through `110` grant a retry with `+10`. ## Progress - [x] (2026-04-04 23:52Z) Reviewed `PLANS.md` and the current Rolemaster roll, skill-form, API, and log-card code paths. - [x] (2026-04-04 23:52Z) Authored this ExecPlan in `TASKS.md`. -- [ ] Add a persisted per-skill toggle for Rolemaster automatic skipp retry and thread it through API contracts, DTOs, in-memory state, and EF Core migration paths. +- [x] (2026-04-14 20:45Z) Added persisted `RolemasterAutoRetry` wiring through the skill model, API contracts, DTOs, in-memory state, clone helpers, EF mapping, and the `20260414204309_AddRolemasterAutoRetry` migration. - [ ] Implement retry-aware Rolemaster roll execution, readable breakdown formatting, and compact log badge/summary output. -- [ ] Update Blazor skill create/edit flows so the toggle is shown only when it is valid and stale values are cleared when it is not. +- [x] (2026-04-14 20:45Z) Updated the Blazor skill create/edit flows so the automatic retry toggle appears only for Rolemaster open-ended skills and is cleared when the expression stops qualifying. - [ ] Add or update unit, API, persistence, payload-budget, and browser tests that prove the feature end to end. - [ ] Update `README.md`, run `pwsh ./scripts/ci-local.ps1`, and commit the finished implementation. @@ -49,13 +49,17 @@ For this feature, a “skipp” means a Rolemaster open-ended percentile skill r Rationale: Recursive retries would make the feature hard to explain, hard to test, and far removed from the user’s “automatic retry” request. Date/Author: 2026-04-04 / Codex -- Decision: The final stored roll result becomes the retried result plus the retry bonus, while the original skipp result remains visible in the breakdown and log summary. +- Decision: The final stored roll result becomes the retried result plus the retry bonus, while the original first result remains visible in the breakdown and log summary. Rationale: An automatic retry should materially change the outcome, not merely annotate the failed first attempt. Keeping the first attempt visible preserves auditability. Date/Author: 2026-04-04 / Codex +- Decision: The feature uses “retry” terminology throughout the docs and code, with the persisted Boolean named `RolemasterAutoRetry`. + Rationale: The user explicitly rejected “skipp” as unclear. `RolemasterAutoRetry` keeps the toggle readable in code, API payloads, and UI text. + Date/Author: 2026-04-14 / Codex + ## Outcomes & Retrospective -Implementation has not started yet. Success for this plan means that a user can enable the toggle on a Rolemaster open-ended skill, produce a first result in the retry band, see the final roll automatically retried with the correct bonus, and recognize that special case directly in the log card without opening the detail view. +Milestone 1 is complete. The repo now persists and validates a per-skill `RolemasterAutoRetry` toggle, exposes it in the skill create/edit UI only for Rolemaster open-ended percentile expressions, and round-trips it through service, API, and persistence tests. Roll execution, breakdown formatting, and log surfacing still need to be implemented before the feature is complete end to end. ## Context and Orientation @@ -69,11 +73,11 @@ Campaign log cards are compact list entries, not full detail records. The compac ## Plan of Work -Start by extending the skill model so the retry toggle has a place to live. Add a non-nullable Boolean property named `RolemasterRetryOnSkipp` to `Skill` in `RpgRoller/Domain/GameModels.cs`. Thread that property through the DTO surface in `RpgRoller/Contracts/ApiContracts.cs` by extending `CreateSkillRequest`, `UpdateSkillRequest`, `SkillSummary`, and `CharacterSheetSkill`. Keep skill groups unchanged. Update `RpgRoller/Services/GameDtoMapper.cs` so summaries and character sheets include the new flag. Update cloning or state-copy helpers that copy `Skill` objects, including `RpgRoller/Services/GameStateCloneFactory.cs`, so the flag persists through load/save cycles. +Start by extending the skill model so the retry toggle has a place to live. Add a non-nullable Boolean property named `RolemasterAutoRetry` to `Skill` in `RpgRoller/Domain/GameModels.cs`. Thread that property through the DTO surface in `RpgRoller/Contracts/ApiContracts.cs` by extending `CreateSkillRequest`, `UpdateSkillRequest`, `SkillSummary`, and `CharacterSheetSkill`. Keep skill groups unchanged. Update `RpgRoller/Services/GameDtoMapper.cs` so summaries and character sheets include the new flag. Update cloning or state-copy helpers that copy `Skill` objects, including `RpgRoller/Services/GameStateCloneFactory.cs`, so the flag persists through load/save cycles. -Add a database migration for the new column. Update `RpgRoller/Data/RpgRollerDbContext.cs` so EF Core maps `RolemasterRetryOnSkipp` as a required Boolean with a default value of `false`. Generate a migration under `RpgRoller/Migrations` that adds the column to `Skills` with a safe default. Do not widen this migration to unrelated schema changes. If existing migration coverage fixtures or history assertions mention the newest migration id, update them so startup migration tests remain accurate. +Add a database migration for the new column. Update `RpgRoller/Data/RpgRollerDbContext.cs` so EF Core maps `RolemasterAutoRetry` as a required Boolean with a default value of `false`. Generate a migration under `RpgRoller/Migrations` that adds the column to `Skills` with a safe default. Do not widen this migration to unrelated schema changes. If existing migration coverage fixtures or history assertions mention the newest migration id, update them so startup migration tests remain accurate. -Once the property exists, tighten validation. Extend `RpgRoller/Services/SkillDefinitionValidator.cs` so its return value includes the retry flag. Validation must accept `RolemasterRetryOnSkipp = true` only when the ruleset is Rolemaster and the parsed expression kind is `RolemasterOpenEndedPercentile`. For every other ruleset or expression kind, the backend must reject `true` with a specific validation error such as `invalid_rolemaster_retry`. When the flag is false, behavior must remain unchanged. Update `RpgRoller/Services/GameSkillService.cs`, `RpgRoller/Services/IGameService.cs`, and `RpgRoller/Api/SkillEndpoints.cs` so skill creation and update calls carry the extra argument all the way through. +Once the property exists, tighten validation. Extend `RpgRoller/Services/SkillDefinitionValidator.cs` so its return value includes the retry flag. Validation must accept `RolemasterAutoRetry = true` only when the ruleset is Rolemaster and the parsed expression kind is `RolemasterOpenEndedPercentile`. For every other ruleset or expression kind, the backend must reject `true` with a specific validation error such as `invalid_rolemaster_retry`. When the flag is false, behavior must remain unchanged. Update `RpgRoller/Services/GameSkillService.cs`, `RpgRoller/Services/IGameService.cs`, and `RpgRoller/Api/SkillEndpoints.cs` so skill creation and update calls carry the extra argument all the way through. Implement the retry rule in a dedicated helper instead of burying threshold math inside the roll engine. Add a new backend helper file, for example `RpgRoller/Services/RolemasterRetryPolicy.cs`, with a small API such as `public static int? ResolveSkippRetryBonus(int firstResult)`. This helper must return `5`, `10`, or `null` according to the exact windows described earlier. Put the thresholds here so both tests and the roll engine read the same source of truth. @@ -93,9 +97,9 @@ This format is intentionally simple. It is readable in the UI, survives persiste Update the compact campaign-log helpers to surface the new special result. `RpgRoller/Services/CampaignLogSummaryBuilder.cs` should accept the stored breakdown when building badges and compact summaries. Add badge codes `rs5` and `rs10` for “Retry +5” and “Retry +10”. When a retry marker is present in the breakdown, append a short retry note to the Rolemaster compact summary, for example `| retry +5` or `| retry +10`, while keeping existing `rf`, `r66`, and `r100` behavior intact. Update `RpgRoller/Components/Pages/HomeControls/CampaignLogPanel.razor.cs` so those new badge codes render as visible labels on the log card. -After the backend shape is stable, wire the UI toggle. Extend `SkillFormModel` in `RpgRoller/Components/Pages/Home.Models.cs` with `RolemasterRetryOnSkipp`. Update `SkillFormModal.razor.cs` so the form copies the value from `InitialModel`, validates it only for Rolemaster open-ended expressions, and clears it automatically when the skill expression becomes invalid for retry. Update `SkillFormModal.razor` to show a checkbox only in the Rolemaster open-ended branch, near the fumble-range input, with concise help text that explains the exact windows. Update `CharacterPanel.razor.cs` so create and edit skill dialogs pass the property in their initial models and request payloads. Do not add a corresponding group-level control. +After the backend shape is stable, wire the UI toggle. Extend `SkillFormModel` in `RpgRoller/Components/Pages/Home.Models.cs` with `RolemasterAutoRetry`. Update `SkillFormModal.razor.cs` so the form copies the value from `InitialModel`, validates it only for Rolemaster open-ended expressions, and clears it automatically when the skill expression becomes invalid for retry. Update `SkillFormModal.razor` to show a checkbox only in the Rolemaster open-ended branch, near the fumble-range input, with concise help text that explains the exact windows. Update `CharacterPanel.razor.cs` so create and edit skill dialogs pass the property in their initial models and request payloads. Do not add a corresponding group-level control. -Expose the setting in the workspace read model so the user can see it again after save. Extend `WorkspaceState.SkillDefinitionLabel(...)` and `RulesetFormHelpers.DescribeRolemasterExpression(...)` as needed so a Rolemaster open-ended skill with retry enabled renders a label that includes the retry rule in compact form, for example `Open-ended percentile: d100!+15, fumble <= 5, retry skipp`. Keep the label short enough that existing layout remains intact. +Expose the setting in the workspace read model so the user can see it again after save. Extend `WorkspaceState.SkillDefinitionLabel(...)` and `RulesetFormHelpers.DescribeRolemasterExpression(...)` as needed so a Rolemaster open-ended skill with retry enabled renders a label that includes the retry rule in compact form, for example `Open-ended percentile: d100!+15, fumble <= 5, auto retry`. Keep the label short enough that existing layout remains intact. Finally, update documentation. `README.md` must describe the new Rolemaster skill option, the retry windows, and the fact that the campaign log now shows retry badges. If an example command or screenshot-free narrative is needed, keep it textual and current rather than writing a historical change note. @@ -166,7 +170,7 @@ Validation is complete only when all of the following are true. The backend proves rule correctness. There must be a unit test where the first attempt result is `78` and the stored final result comes from a retry with `+5`. There must be another where the first attempt result is `96` or another value inside the second band and the stored final result comes from a retry with `+10`. There must be a test where the skill toggle is disabled and an otherwise eligible first result still does not retry. -The persistence layer proves round-trip safety. A test must create a skill with the toggle enabled, persist the database, reload state, and confirm the skill still exposes `RolemasterRetryOnSkipp = true`. +The persistence layer proves round-trip safety. A test must create a skill with the toggle enabled, persist the database, reload state, and confirm the skill still exposes `RolemasterAutoRetry = true`. The API layer proves contract shape. A test must create and update a Rolemaster open-ended skill through HTTP and confirm the toggle round-trips through `SkillSummary`, `CharacterSheet`, and roll results. Invalid combinations must return a concrete API error rather than silently coercing the value. @@ -211,14 +215,14 @@ At the end of the implementation, these interfaces and shapes must exist. In `RpgRoller/Domain/GameModels.cs`, `Skill` must expose: - public bool RolemasterRetryOnSkipp { get; set; } + public bool RolemasterAutoRetry { get; set; } In `RpgRoller/Contracts/ApiContracts.cs`, these records must include the new Boolean: - public sealed record CreateSkillRequest(..., int? FumbleRange = null, bool RolemasterRetryOnSkipp = false); - public sealed record UpdateSkillRequest(..., int? FumbleRange = null, bool RolemasterRetryOnSkipp = false); - public sealed record SkillSummary(..., int? FumbleRange, bool RolemasterRetryOnSkipp); - public sealed record CharacterSheetSkill(..., int? FumbleRange, bool RolemasterRetryOnSkipp); + public sealed record CreateSkillRequest(..., int? FumbleRange = null, bool RolemasterAutoRetry = false); + public sealed record UpdateSkillRequest(..., int? FumbleRange = null, bool RolemasterAutoRetry = false); + public sealed record SkillSummary(..., int? FumbleRange, bool RolemasterAutoRetry); + public sealed record CharacterSheetSkill(..., int? FumbleRange, bool RolemasterAutoRetry); `RollDieResult` must gain an optional attempt marker: