Add rolemaster auto retry skill toggle

This commit is contained in:
2026-04-14 22:49:14 +02:00
parent f63c3f8f28
commit d38003a77c
29 changed files with 468 additions and 80 deletions

View File

@@ -80,6 +80,7 @@ Rolemaster support:
- Standard expressions such as `d10`, `15d10`, `2d10+48`, and `d100-15` - Standard expressions such as `d10`, `15d10`, `2d10+48`, and `d100-15`
- Open-ended percentile expressions such as `d100!+85` - Open-ended percentile expressions such as `d100!+85`
- Conditional `FumbleRange` handling for open-ended percentile skills and skill-group defaults - 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 - 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 - Compact log badges and summaries for open-ended/fumble-related events

View File

@@ -76,7 +76,7 @@ public sealed class CampaignApiTests(WebApplicationFactory<Program> factory) : A
} }
[Fact] [Fact]
public async Task RolemasterSkillDefinitions_RoundTripFumbleRangeThroughApi() public async Task RolemasterSkillDefinitions_RoundTripRetryAndFumbleOptionsThroughApi()
{ {
using var factory = CreateFactory(88, 42, 17); using var factory = CreateFactory(88, 42, 17);
using var gmClient = factory.CreateClient(new() { AllowAutoRedirect = false }); using var gmClient = factory.CreateClient(new() { AllowAutoRedirect = false });
@@ -93,15 +93,22 @@ public sealed class CampaignApiTests(WebApplicationFactory<Program> factory) : A
var group = await PostAsync<CreateSkillGroupRequest, SkillGroupSummary>(gmClient, $"/api/characters/{character.Id}/skill-groups", new("Perception", "d100!+15", 0, false, 5)); var group = await PostAsync<CreateSkillGroupRequest, SkillGroupSummary>(gmClient, $"/api/characters/{character.Id}/skill-groups", new("Perception", "d100!+15", 0, false, 5));
Assert.Equal(5, group.FumbleRange); Assert.Equal(5, group.FumbleRange);
var skill = await PostAsync<CreateSkillRequest, SkillSummary>(gmClient, $"/api/characters/{character.Id}/skills", new("Awareness", "d100!+35", 0, false, group.Id, 3)); var invalidRetry = await gmClient.PostAsJsonAsync($"/api/characters/{character.Id}/skills", new CreateSkillRequest("Bad Retry", "d100+35", 0, false, group.Id, null, true));
Assert.Equal(3, skill.FumbleRange); Assert.Equal(HttpStatusCode.BadRequest, invalidRetry.StatusCode);
var updatedSkill = await PutAsync<UpdateSkillRequest, SkillSummary>(gmClient, $"/api/skills/{skill.Id}", new("Awareness", "d100!+45", 0, false, group.Id, 4)); var skill = await PostAsync<CreateSkillRequest, SkillSummary>(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<UpdateSkillRequest, SkillSummary>(gmClient, $"/api/skills/{skill.Id}", new("Awareness", "d100!+45", 0, false, group.Id, 4, true));
Assert.Equal(4, updatedSkill.FumbleRange); Assert.Equal(4, updatedSkill.FumbleRange);
Assert.True(updatedSkill.RolemasterAutoRetry);
var sheet = await GetAsync<CharacterSheet>(gmClient, $"/api/characters/{character.Id}/sheet"); var sheet = await GetAsync<CharacterSheet>(gmClient, $"/api/characters/{character.Id}/sheet");
Assert.Equal(5, Assert.Single(sheet.SkillGroups).FumbleRange); 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] [Fact]

View File

@@ -131,6 +131,7 @@ public sealed class HostingCoverageTests
Assert.Contains("WildDice", columns); Assert.Contains("WildDice", columns);
Assert.Contains("AllowFumble", columns); Assert.Contains("AllowFumble", columns);
Assert.Contains("FumbleRange", columns); Assert.Contains("FumbleRange", columns);
Assert.Contains("RolemasterAutoRetry", columns);
using var skillGroupsTableInfoCommand = verifyConnection.CreateCommand(); using var skillGroupsTableInfoCommand = verifyConnection.CreateCommand();
skillGroupsTableInfoCommand.CommandText = "PRAGMA table_info('SkillGroups');"; skillGroupsTableInfoCommand.CommandText = "PRAGMA table_info('SkillGroups');";
@@ -208,6 +209,11 @@ public sealed class HostingCoverageTests
rolemasterHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260402222501_AddRolemasterFumbleRange';"; rolemasterHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260402222501_AddRolemasterFumbleRange';";
var rolemasterHistoryCount = Convert.ToInt32(rolemasterHistoryCommand.ExecuteScalar()); var rolemasterHistoryCount = Convert.ToInt32(rolemasterHistoryCommand.ExecuteScalar());
Assert.Equal(1, rolemasterHistoryCount); 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] [Fact]
@@ -348,6 +354,11 @@ public sealed class HostingCoverageTests
rolemasterHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260402222501_AddRolemasterFumbleRange';"; rolemasterHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260402222501_AddRolemasterFumbleRange';";
var rolemasterHistoryCount = Convert.ToInt32(rolemasterHistoryCommand.ExecuteScalar()); var rolemasterHistoryCount = Convert.ToInt32(rolemasterHistoryCommand.ExecuteScalar());
Assert.Equal(1, rolemasterHistoryCount); 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] [Fact]
@@ -459,6 +470,7 @@ public sealed class HostingCoverageTests
skillColumns.Add(skillsTableInfoReader.GetString(1)); skillColumns.Add(skillsTableInfoReader.GetString(1));
Assert.Contains("FumbleRange", skillColumns); Assert.Contains("FumbleRange", skillColumns);
Assert.Contains("RolemasterAutoRetry", skillColumns);
using var skillGroupsTableInfoCommand = verifyConnection.CreateCommand(); using var skillGroupsTableInfoCommand = verifyConnection.CreateCommand();
skillGroupsTableInfoCommand.CommandText = "PRAGMA table_info('SkillGroups');"; skillGroupsTableInfoCommand.CommandText = "PRAGMA table_info('SkillGroups');";
@@ -473,5 +485,10 @@ public sealed class HostingCoverageTests
authorizationRolesHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260226170000_AddAuthorizationRoles';"; authorizationRolesHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260226170000_AddAuthorizationRoles';";
var authorizationRolesHistoryCount = Convert.ToInt32(authorizationRolesHistoryCommand.ExecuteScalar()); var authorizationRolesHistoryCount = Convert.ToInt32(authorizationRolesHistoryCommand.ExecuteScalar());
Assert.Equal(1, authorizationRolesHistoryCount); 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);
} }
} }

View File

@@ -48,20 +48,24 @@ public sealed class ServiceHelperExtractionTests
public void SkillDefinitionValidator_ValidatesRulesetSpecificOptions() public void SkillDefinitionValidator_ValidatesRulesetSpecificOptions()
{ {
var d6 = SkillDefinitionValidator.Validate(RulesetKind.D6, "2D+1", 1, true, null); 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 invalidD6 = SkillDefinitionValidator.Validate(RulesetKind.D6, "2D+1", 0, true, null);
var invalidRolemaster = SkillDefinitionValidator.Validate(RulesetKind.Rolemaster, "d100!+15", 0, false, 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.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.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.False(invalidD6.Succeeded);
Assert.Equal("invalid_wild_dice", invalidD6.Error!.Code); Assert.Equal("invalid_wild_dice", invalidD6.Error!.Code);
Assert.False(invalidRolemaster.Succeeded); Assert.False(invalidRolemaster.Succeeded);
Assert.Equal("invalid_fumble_range", invalidRolemaster.Error!.Code); Assert.Equal("invalid_fumble_range", invalidRolemaster.Error!.Code);
Assert.False(invalidRetry.Succeeded);
Assert.Equal("invalid_rolemaster_retry", invalidRetry.Error!.Code);
} }
} }

View File

@@ -94,7 +94,7 @@ public sealed class ServicePersistenceTests
} }
[Fact] [Fact]
public void RolemasterFumbleRange_PersistsAcrossDatabaseReload() public void RolemasterSkillOptions_PersistAcrossDatabaseReload()
{ {
using var harness = ServiceTestSupport.CreateHarness(); using var harness = ServiceTestSupport.CreateHarness();
var service = harness.Service; var service = harness.Service;
@@ -108,7 +108,7 @@ public sealed class ServicePersistenceTests
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Rolemaster Persistence", "rolemaster")); var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Rolemaster Persistence", "rolemaster"));
var character = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Loremaster", campaign.Id)); 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 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); using var reloadedHarness = ServiceTestSupport.CreateHarnessFromPath(harness.DbPath);
var reloadedSheet = ServiceTestSupport.GetValue(reloadedHarness.Service.GetCharacterSheet(ownerSession, character.Id)); 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); var reloadedSkill = Assert.Single(reloadedSheet.Skills, current => current.Id == skill.Id);
Assert.Equal(3, reloadedSkill.FumbleRange); Assert.Equal(3, reloadedSkill.FumbleRange);
Assert.True(reloadedSkill.RolemasterAutoRetry);
} }
} }

View File

@@ -324,7 +324,8 @@ public sealed class ServiceSharedHelperTests
DiceRollDefinition = "d100!+25", DiceRollDefinition = "d100!+25",
WildDice = 0, WildDice = 0,
AllowFumble = false, AllowFumble = false,
FumbleRange = 3 FumbleRange = 3,
RolemasterAutoRetry = true
}; };
store.RebuildCampaignStateLocked(); store.RebuildCampaignStateLocked();
store.TouchRosterLocked(campaignId); store.TouchRosterLocked(campaignId);
@@ -371,6 +372,7 @@ public sealed class ServiceSharedHelperTests
Assert.Single(sheet.Skills); Assert.Single(sheet.Skills);
Assert.Equal(5, groupSummary.FumbleRange); Assert.Equal(5, groupSummary.FumbleRange);
Assert.Equal(3, skillSummary.FumbleRange); Assert.Equal(3, skillSummary.FumbleRange);
Assert.True(skillSummary.RolemasterAutoRetry);
Assert.Equal("private", rollResult.Visibility); Assert.Equal("private", rollResult.Visibility);
Assert.Equal("Owner", logDto.RollerDisplayName); Assert.Equal("Owner", logDto.RollerDisplayName);
Assert.Equal("private-self", logListDto.VisibilityStyle); Assert.Equal("private-self", logListDto.VisibilityStyle);

View File

@@ -224,5 +224,12 @@ public sealed class ServiceSkillGroupAndOwnershipTests
Assert.Equal(0, openEndedSkill.WildDice); Assert.Equal(0, openEndedSkill.WildDice);
Assert.False(openEndedSkill.AllowFumble); Assert.False(openEndedSkill.AllowFumble);
Assert.Equal(5, openEndedSkill.FumbleRange); 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);
} }
} }

View File

@@ -122,12 +122,12 @@ public sealed class WorkspaceQueryServiceTests
throw new NotSupportedException(); throw new NotSupportedException();
} }
public ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null) public ServiceResult<SkillSummary> 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(); throw new NotSupportedException();
} }
public ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null) public ServiceResult<SkillSummary> 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(); throw new NotSupportedException();
} }

View File

@@ -27,13 +27,13 @@ public sealed class WorkspaceStateTests
[Fact] [Fact]
public void SkillDefinitionLabel_FormatsD6RolemasterAndDefaultRulesets() 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"), []) }; 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)); Assert.Equal("d100!+15, wild 1, fumble on", state.SkillDefinitionLabel(skill));
state.SelectedCampaign = new(Guid.NewGuid(), "Alpha", "rolemaster", new(Guid.NewGuid(), "GM"), []); 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"), []); state.SelectedCampaign = new(Guid.NewGuid(), "Alpha", "dnd5e", new(Guid.NewGuid(), "GM"), []);
Assert.Equal("d100!+15", state.SkillDefinitionLabel(skill)); 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]), SelectedCampaign = new(Guid.NewGuid(), "Alpha", "d6", new(Guid.NewGuid(), "GM"), [ownedCharacter, secondOwnedCharacter, otherCharacter]),
SelectedCharacterId = secondOwnedCharacter.Id, SelectedCharacterId = secondOwnedCharacter.Id,
ActiveCharacterId = ownedCharacter.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)] SelectedCharacterSkillGroups = [new(Guid.NewGuid(), "Combat", "2D+1", 1, true, null)]
}; };

View File

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

View File

@@ -48,6 +48,7 @@ public sealed class SkillFormModel
public int WildDice { get; set; } public int WildDice { get; set; }
public bool AllowFumble { get; set; } public bool AllowFumble { get; set; }
public int? FumbleRange { get; set; } public int? FumbleRange { get; set; }
public bool RolemasterAutoRetry { get; set; }
} }
public sealed class SkillGroupFormModel public sealed class SkillGroupFormModel

View File

@@ -19,7 +19,8 @@ public partial class CharacterPanel
SkillGroupId = selectedGroup?.Id.ToString() ?? string.Empty, SkillGroupId = selectedGroup?.Id.ToString() ?? string.Empty,
WildDice = selectedGroup?.WildDice ?? (IsD6Ruleset ? 1 : 0), WildDice = selectedGroup?.WildDice ?? (IsD6Ruleset ? 1 : 0),
AllowFumble = selectedGroup?.AllowFumble ?? IsD6Ruleset, AllowFumble = selectedGroup?.AllowFumble ?? IsD6Ruleset,
FumbleRange = selectedGroup?.FumbleRange FumbleRange = selectedGroup?.FumbleRange,
RolemasterAutoRetry = false
}; };
if (IsRolemasterRuleset && string.IsNullOrWhiteSpace(CreateSkillInitialModel.DiceRollDefinition)) if (IsRolemasterRuleset && string.IsNullOrWhiteSpace(CreateSkillInitialModel.DiceRollDefinition))
@@ -40,7 +41,8 @@ public partial class CharacterPanel
SkillGroupId = skill.SkillGroupId?.ToString() ?? string.Empty, SkillGroupId = skill.SkillGroupId?.ToString() ?? string.Empty,
WildDice = skill.WildDice, WildDice = skill.WildDice,
AllowFumble = skill.AllowFumble, AllowFumble = skill.AllowFumble,
FumbleRange = skill.FumbleRange FumbleRange = skill.FumbleRange,
RolemasterAutoRetry = skill.RolemasterAutoRetry
}; };
EditSkillFormVersion++; EditSkillFormVersion++;

View File

@@ -30,7 +30,7 @@ internal static class RulesetFormHelpers
return parseResult.Succeeded && parseResult.Value is not null && parseResult.Value.Kind == DiceExpressionKind.RolemasterOpenEndedPercentile; 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); var parseResult = TryParseRolemasterExpression(expression);
if (!parseResult.Succeeded || parseResult.Value is null) if (!parseResult.Succeeded || parseResult.Value is null)
@@ -38,7 +38,7 @@ internal static class RulesetFormHelpers
return parseResult.Value.Kind switch 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}" _ => $"Rolemaster: {parseResult.Value.Canonical}"
}; };
} }
@@ -55,4 +55,17 @@ internal static class RulesetFormHelpers
return DiceRules.ParseExpression(RulesetKind.Rolemaster, expression); return DiceRules.ParseExpression(RulesetKind.Rolemaster, expression);
} }
private static string DescribeOpenEndedExpression(string canonicalExpression, int? fumbleRange, bool rolemasterAutoRetry)
{
var parts = new List<string> { $"Open-ended percentile: {canonicalExpression}" };
if (fumbleRange.HasValue)
parts.Add($"fumble <= {fumbleRange.Value}");
if (rolemasterAutoRetry)
parts.Add("auto retry");
return string.Join(", ", parts);
}
} }

View File

@@ -56,6 +56,10 @@
{ {
<p class="field-error">@fumbleRangeError</p> <p class="field-error">@fumbleRangeError</p>
} }
<label for="skill-auto-retry">Automatic retry</label>
<input id="skill-auto-retry" type="checkbox" @bind="FormState.Model.RolemasterAutoRetry"/>
<p class="field-help">When later enabled in rolling, retry bands are 77-90 and 91-110.</p>
} }
} }
<div class="inline-actions"> <div class="inline-actions">

View File

@@ -19,6 +19,7 @@ public partial class SkillFormModal
FormState.Model.WildDice = InitialModel.WildDice; FormState.Model.WildDice = InitialModel.WildDice;
FormState.Model.AllowFumble = InitialModel.AllowFumble; FormState.Model.AllowFumble = InitialModel.AllowFumble;
FormState.Model.FumbleRange = InitialModel.FumbleRange; FormState.Model.FumbleRange = InitialModel.FumbleRange;
FormState.Model.RolemasterAutoRetry = InitialModel.RolemasterAutoRetry;
SynchronizeRulesetSpecificFields(); SynchronizeRulesetSpecificFields();
FormState.ResetValidation(); FormState.ResetValidation();
AppliedFormVersion = FormVersion; AppliedFormVersion = FormVersion;
@@ -53,7 +54,10 @@ public partial class SkillFormModal
FormState.Errors["fumbleRange"] = "Open-ended Rolemaster skills require a fumble range."; FormState.Errors["fumbleRange"] = "Open-ended Rolemaster skills require a fumble range.";
} }
else else
{
FormState.Model.FumbleRange = null; FormState.Model.FumbleRange = null;
FormState.Model.RolemasterAutoRetry = false;
}
if (!IsD6Ruleset) if (!IsD6Ruleset)
{ {
@@ -81,7 +85,7 @@ public partial class SkillFormModal
{ {
SkillSummary skill; SkillSummary skill;
if (EditingSkillId.HasValue) if (EditingSkillId.HasValue)
skill = await ApiClient.RequestAsync<SkillSummary>("PUT", $"/api/skills/{EditingSkillId.Value}", new UpdateSkillRequest(FormState.Model.Name.Trim(), FormState.Model.DiceRollDefinition.Trim(), FormState.Model.WildDice, FormState.Model.AllowFumble, skillGroupId, FormState.Model.FumbleRange)); skill = await ApiClient.RequestAsync<SkillSummary>("PUT", $"/api/skills/{EditingSkillId.Value}", new UpdateSkillRequest(FormState.Model.Name.Trim(), FormState.Model.DiceRollDefinition.Trim(), FormState.Model.WildDice, FormState.Model.AllowFumble, skillGroupId, FormState.Model.FumbleRange, FormState.Model.RolemasterAutoRetry));
else else
{ {
if (!SelectedCharacterId.HasValue) if (!SelectedCharacterId.HasValue)
@@ -90,7 +94,7 @@ public partial class SkillFormModal
return; return;
} }
skill = await ApiClient.RequestAsync<SkillSummary>("POST", $"/api/characters/{SelectedCharacterId.Value}/skills", new CreateSkillRequest(FormState.Model.Name.Trim(), FormState.Model.DiceRollDefinition.Trim(), FormState.Model.WildDice, FormState.Model.AllowFumble, skillGroupId, FormState.Model.FumbleRange)); skill = await ApiClient.RequestAsync<SkillSummary>("POST", $"/api/characters/{SelectedCharacterId.Value}/skills", new CreateSkillRequest(FormState.Model.Name.Trim(), FormState.Model.DiceRollDefinition.Trim(), FormState.Model.WildDice, FormState.Model.AllowFumble, skillGroupId, FormState.Model.FumbleRange, FormState.Model.RolemasterAutoRetry));
} }
await SkillSaved.InvokeAsync(skill.Id); await SkillSaved.InvokeAsync(skill.Id);
@@ -115,7 +119,10 @@ public partial class SkillFormModal
private void SynchronizeRulesetSpecificFields() private void SynchronizeRulesetSpecificFields()
{ {
if (!IsRolemasterRuleset) if (!IsRolemasterRuleset)
{
FormState.Model.RolemasterAutoRetry = false;
return; return;
}
NormalizeRolemasterFumbleRange(); NormalizeRolemasterFumbleRange();
} }
@@ -135,6 +142,7 @@ public partial class SkillFormModal
} }
FormState.Model.FumbleRange = null; FormState.Model.FumbleRange = null;
FormState.Model.RolemasterAutoRetry = false;
} }
private bool IsD6Ruleset => RulesetFormHelpers.IsD6(RulesetId); private bool IsD6Ruleset => RulesetFormHelpers.IsD6(RulesetId);

View File

@@ -27,7 +27,7 @@ public sealed class WorkspaceState
if (!string.Equals(SelectedCampaign?.RulesetId, "d6", StringComparison.OrdinalIgnoreCase)) if (!string.Equals(SelectedCampaign?.RulesetId, "d6", StringComparison.OrdinalIgnoreCase))
{ {
if (string.Equals(SelectedCampaign?.RulesetId, RulesetFormHelpers.RulesetIds.Rolemaster, 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; return skill.DiceRollDefinition;
} }

View File

@@ -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 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); 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 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); 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 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); public sealed record CharacterSheet(Guid CharacterId, CharacterSheetSkillGroup[] SkillGroups, CharacterSheetSkill[] Skills);

View File

@@ -51,6 +51,7 @@ public sealed class RpgRollerDbContext(DbContextOptions<RpgRollerDbContext> opti
entity.Property(x => x.WildDice).IsRequired(); entity.Property(x => x.WildDice).IsRequired();
entity.Property(x => x.AllowFumble).IsRequired(); entity.Property(x => x.AllowFumble).IsRequired();
entity.Property(x => x.FumbleRange).IsRequired(false); entity.Property(x => x.FumbleRange).IsRequired(false);
entity.Property(x => x.RolemasterAutoRetry).IsRequired().HasDefaultValue(false);
entity.HasIndex(x => x.CharacterId); entity.HasIndex(x => x.CharacterId);
entity.HasIndex(x => x.SkillGroupId); entity.HasIndex(x => x.SkillGroupId);
}); });

View File

@@ -74,6 +74,7 @@ public sealed class Skill
public required int WildDice { get; set; } public required int WildDice { get; set; }
public required bool AllowFumble { get; set; } public required bool AllowFumble { get; set; }
public int? FumbleRange { get; set; } public int? FumbleRange { get; set; }
public bool RolemasterAutoRetry { get; set; }
} }
public sealed class RollLogEntry public sealed class RollLogEntry

View File

@@ -0,0 +1,269 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using RpgRoller.Data;
#nullable disable
namespace RpgRoller.Migrations
{
[DbContext(typeof(RpgRollerDbContext))]
[Migration("20260414204309_AddRolemasterAutoRetry")]
partial class AddRolemasterAutoRetry
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.2");
modelBuilder.Entity("RpgRoller.Domain.Campaign", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("GmUserId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Ruleset")
.IsRequired()
.HasColumnType("TEXT");
b.Property<long>("Version")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("GmUserId");
b.ToTable("Campaigns");
});
modelBuilder.Entity("RpgRoller.Domain.Character", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid?>("CampaignId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<Guid>("OwnerUserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CampaignId");
b.HasIndex("OwnerUserId");
b.ToTable("Characters");
});
modelBuilder.Entity("RpgRoller.Domain.RollLogEntry", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Breakdown")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<Guid>("CampaignId")
.HasColumnType("TEXT");
b.Property<Guid>("CharacterId")
.HasColumnType("TEXT");
b.Property<string>("Dice")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Result")
.HasColumnType("INTEGER");
b.Property<Guid>("RollerUserId")
.HasColumnType("TEXT");
b.Property<Guid>("SkillId")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("TimestampUtc")
.HasColumnType("TEXT");
b.Property<string>("Visibility")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CampaignId");
b.HasIndex("CharacterId");
b.HasIndex("RollerUserId");
b.HasIndex("SkillId");
b.ToTable("RollLogEntries");
});
modelBuilder.Entity("RpgRoller.Domain.Skill", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<bool>("AllowFumble")
.HasColumnType("INTEGER");
b.Property<Guid>("CharacterId")
.HasColumnType("TEXT");
b.Property<string>("DiceRollDefinition")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int?>("FumbleRange")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<bool>("RolemasterAutoRetry")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<Guid?>("SkillGroupId")
.HasColumnType("TEXT");
b.Property<int>("WildDice")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CharacterId");
b.HasIndex("SkillGroupId");
b.ToTable("Skills");
});
modelBuilder.Entity("RpgRoller.Domain.SkillGroup", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<bool>("AllowFumble")
.HasColumnType("INTEGER");
b.Property<Guid>("CharacterId")
.HasColumnType("TEXT");
b.Property<string>("DiceRollDefinition")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int?>("FumbleRange")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("WildDice")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CharacterId");
b.ToTable("SkillGroups");
});
modelBuilder.Entity("RpgRoller.Domain.UserAccount", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid?>("ActiveCharacterId")
.HasColumnType("TEXT");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Roles")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("UsernameNormalized")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UsernameNormalized")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("RpgRoller.Domain.UserSession", b =>
{
b.Property<string>("Token")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Token");
b.HasIndex("UserId");
b.ToTable("Sessions");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace RpgRoller.Migrations
{
/// <inheritdoc />
public partial class AddRolemasterAutoRetry : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "RolemasterAutoRetry",
table: "Skills",
type: "INTEGER",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "RolemasterAutoRetry",
table: "Skills");
}
}
}

View File

@@ -146,6 +146,11 @@ namespace RpgRoller.Migrations
.HasMaxLength(128) .HasMaxLength(128)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<bool>("RolemasterAutoRetry")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<Guid?>("SkillGroupId") b.Property<Guid?>("SkillGroupId")
.HasColumnType("TEXT"); .HasColumnType("TEXT");

View File

@@ -55,7 +55,7 @@ public static class GameDtoMapper
public static SkillSummary ToSkillSummary(Skill skill) 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<RollDieResult> dice) public static RollResult ToRollResult(RollLogEntry entry, IReadOnlyList<RollDieResult> dice)
@@ -98,6 +98,6 @@ public static class GameDtoMapper
private static CharacterSheetSkill ToCharacterSheetSkill(Skill skill) 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);
} }
} }

View File

@@ -140,14 +140,14 @@ public sealed class GameService : IGameService
return m_SkillService.DeleteSkillGroup(sessionToken, skillGroupId); return m_SkillService.DeleteSkillGroup(sessionToken, skillGroupId);
} }
public ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null) public ServiceResult<SkillSummary> 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<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null) public ServiceResult<SkillSummary> 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<bool> DeleteSkill(string sessionToken, Guid skillId) public ServiceResult<bool> DeleteSkill(string sessionToken, Guid skillId)

View File

@@ -114,7 +114,7 @@ public sealed class GameSkillService(GameStateStore stateStore, GamePersistenceS
} }
} }
public ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null) public ServiceResult<SkillSummary> 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)) if (string.IsNullOrWhiteSpace(name))
return ServiceResult<SkillSummary>.Failure("invalid_skill_name", "Skill name is required."); return ServiceResult<SkillSummary>.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)) if (!GameAuthorization.CanEditCharacter(user.Id, character, campaign))
return ServiceResult<SkillSummary>.Failure("forbidden", "Only the owner or GM can edit skills."); return ServiceResult<SkillSummary>.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) if (!skillValidation.Succeeded)
return ServiceResult<SkillSummary>.Failure(skillValidation.Error!.Code, skillValidation.Error.Message); return ServiceResult<SkillSummary>.Failure(skillValidation.Error!.Code, skillValidation.Error.Message);
@@ -151,7 +151,8 @@ public sealed class GameSkillService(GameStateStore stateStore, GamePersistenceS
DiceRollDefinition = skillValidation.Value.CanonicalExpression, DiceRollDefinition = skillValidation.Value.CanonicalExpression,
WildDice = skillValidation.Value.WildDice, WildDice = skillValidation.Value.WildDice,
AllowFumble = skillValidation.Value.AllowFumble, AllowFumble = skillValidation.Value.AllowFumble,
FumbleRange = skillValidation.Value.FumbleRange FumbleRange = skillValidation.Value.FumbleRange,
RolemasterAutoRetry = skillValidation.Value.RolemasterAutoRetry
}; };
stateStore.SkillsById[skill.Id] = skill; stateStore.SkillsById[skill.Id] = skill;
@@ -162,7 +163,7 @@ public sealed class GameSkillService(GameStateStore stateStore, GamePersistenceS
} }
} }
public ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null) public ServiceResult<SkillSummary> 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)) if (string.IsNullOrWhiteSpace(name))
return ServiceResult<SkillSummary>.Failure("invalid_skill_name", "Skill name is required."); return ServiceResult<SkillSummary>.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)) if (!GameAuthorization.CanEditCharacter(user.Id, character, campaign))
return ServiceResult<SkillSummary>.Failure("forbidden", "Only the owner or GM can edit skills."); return ServiceResult<SkillSummary>.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) if (!skillValidation.Succeeded)
return ServiceResult<SkillSummary>.Failure(skillValidation.Error!.Code, skillValidation.Error.Message); return ServiceResult<SkillSummary>.Failure(skillValidation.Error!.Code, skillValidation.Error.Message);
@@ -196,6 +197,7 @@ public sealed class GameSkillService(GameStateStore stateStore, GamePersistenceS
skill.WildDice = skillValidation.Value.WildDice; skill.WildDice = skillValidation.Value.WildDice;
skill.AllowFumble = skillValidation.Value.AllowFumble; skill.AllowFumble = skillValidation.Value.AllowFumble;
skill.FumbleRange = skillValidation.Value.FumbleRange; skill.FumbleRange = skillValidation.Value.FumbleRange;
skill.RolemasterAutoRetry = skillValidation.Value.RolemasterAutoRetry;
skill.SkillGroupId = resolvedSkillGroupId.Value; skill.SkillGroupId = resolvedSkillGroupId.Value;
stateStore.TouchCharacterLocked(campaign.Id, character.Id); stateStore.TouchCharacterLocked(campaign.Id, character.Id);

View File

@@ -62,7 +62,8 @@ public static class GameStateCloneFactory
DiceRollDefinition = skill.DiceRollDefinition, DiceRollDefinition = skill.DiceRollDefinition,
WildDice = skill.WildDice, WildDice = skill.WildDice,
AllowFumble = skill.AllowFumble, AllowFumble = skill.AllowFumble,
FumbleRange = skill.FumbleRange FumbleRange = skill.FumbleRange,
RolemasterAutoRetry = skill.RolemasterAutoRetry
}; };
} }

View File

@@ -31,8 +31,8 @@ public interface IGameService
ServiceResult<SkillGroupSummary> CreateSkillGroup(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, int? fumbleRange = null); ServiceResult<SkillGroupSummary> CreateSkillGroup(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, int? fumbleRange = null);
ServiceResult<SkillGroupSummary> UpdateSkillGroup(string sessionToken, Guid skillGroupId, string name, string diceRollDefinition, int wildDice, bool allowFumble, int? fumbleRange = null); ServiceResult<SkillGroupSummary> UpdateSkillGroup(string sessionToken, Guid skillGroupId, string name, string diceRollDefinition, int wildDice, bool allowFumble, int? fumbleRange = null);
ServiceResult<bool> DeleteSkillGroup(string sessionToken, Guid skillGroupId); ServiceResult<bool> DeleteSkillGroup(string sessionToken, Guid skillGroupId);
ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null); ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null, bool rolemasterAutoRetry = false);
ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null); ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null, bool rolemasterAutoRetry = false);
ServiceResult<bool> DeleteSkill(string sessionToken, Guid skillId); ServiceResult<bool> DeleteSkill(string sessionToken, Guid skillId);
ServiceResult<CharacterSheet> GetCharacterSheet(string sessionToken, Guid characterId); ServiceResult<CharacterSheet> GetCharacterSheet(string sessionToken, Guid characterId);

View File

@@ -4,63 +4,72 @@ namespace RpgRoller.Services;
public static class SkillDefinitionValidator 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); var expressionValidation = DiceRules.ParseExpression(ruleset, diceRollDefinition);
if (!expressionValidation.Succeeded) 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) 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) 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 (ruleset == RulesetKind.D6)
{ {
if (wildDice < 1) 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) 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 (ruleset == RulesetKind.Rolemaster)
{ {
if (wildDice != 0) 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) 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 (expression.Kind == DiceExpressionKind.RolemasterOpenEndedPercentile)
{ {
if (!fumbleRange.HasValue) 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) 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) 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) 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));
} }
} }

View File

@@ -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. 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 ## 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 ## 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) 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`. - [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. - [ ] 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. - [ ] 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. - [ ] 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 users “automatic retry” request. Rationale: Recursive retries would make the feature hard to explain, hard to test, and far removed from the users “automatic retry” request.
Date/Author: 2026-04-04 / Codex 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. 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 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 ## 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 ## Context and Orientation
@@ -69,11 +73,11 @@ Campaign log cards are compact list entries, not full detail records. The compac
## Plan of Work ## 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. 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. 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. 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 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. 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: 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: 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 CreateSkillRequest(..., int? FumbleRange = null, bool RolemasterAutoRetry = false);
public sealed record UpdateSkillRequest(..., int? FumbleRange = null, bool RolemasterRetryOnSkipp = false); public sealed record UpdateSkillRequest(..., int? FumbleRange = null, bool RolemasterAutoRetry = false);
public sealed record SkillSummary(..., int? FumbleRange, bool RolemasterRetryOnSkipp); public sealed record SkillSummary(..., int? FumbleRange, bool RolemasterAutoRetry);
public sealed record CharacterSheetSkill(..., int? FumbleRange, bool RolemasterRetryOnSkipp); public sealed record CharacterSheetSkill(..., int? FumbleRange, bool RolemasterAutoRetry);
`RollDieResult` must gain an optional attempt marker: `RollDieResult` must gain an optional attempt marker: