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

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

View File

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

View File

@@ -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++;

View File

@@ -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<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>
}
<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">

View File

@@ -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<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
{
if (!SelectedCharacterId.HasValue)
@@ -90,7 +94,7 @@ public partial class SkillFormModal
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);
@@ -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);

View File

@@ -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;
}

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

View File

@@ -51,6 +51,7 @@ public sealed class RpgRollerDbContext(DbContextOptions<RpgRollerDbContext> 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);
});

View File

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

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)
.HasColumnType("TEXT");
b.Property<bool>("RolemasterAutoRetry")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<Guid?>("SkillGroupId")
.HasColumnType("TEXT");

View File

@@ -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<RollDieResult> 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);
}
}

View File

@@ -140,14 +140,14 @@ public sealed class GameService : IGameService
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)

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))
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))
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)
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,
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<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))
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))
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)
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.AllowFumble = skillValidation.Value.AllowFumble;
skill.FumbleRange = skillValidation.Value.FumbleRange;
skill.RolemasterAutoRetry = skillValidation.Value.RolemasterAutoRetry;
skill.SkillGroupId = resolvedSkillGroupId.Value;
stateStore.TouchCharacterLocked(campaign.Id, character.Id);

View File

@@ -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
};
}

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> UpdateSkillGroup(string sessionToken, Guid skillGroupId, string name, string diceRollDefinition, int wildDice, bool allowFumble, int? fumbleRange = null);
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> UpdateSkill(string sessionToken, Guid skillId, 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, bool rolemasterAutoRetry = false);
ServiceResult<bool> DeleteSkill(string sessionToken, Guid skillId);
ServiceResult<CharacterSheet> GetCharacterSheet(string sessionToken, Guid characterId);

View File

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