diff --git a/README.md b/README.md index fba7c64..760646a 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ Backend: - `RpgRoller/Hosting/`: service registration + startup initialization - `RpgRoller/Api/`: endpoint mapping modules and auth/session filter helpers - `RpgRoller/Services/`: game workflows with explicit method parameters (no API DTO dependencies) +- `RpgRoller/Services/SkillDefinitionValidator.cs`, `RoleSerializer.cs`, `RollVisibilityParser.cs`, and `CustomRollOptionsResolver.cs`: extracted pure backend rule helpers used by `GameService` Frontend: diff --git a/RpgRoller.Tests/Services/ServiceHelperExtractionTests.cs b/RpgRoller.Tests/Services/ServiceHelperExtractionTests.cs new file mode 100644 index 0000000..3dcb2ce --- /dev/null +++ b/RpgRoller.Tests/Services/ServiceHelperExtractionTests.cs @@ -0,0 +1,67 @@ +namespace RpgRoller.Tests; + +public sealed class ServiceHelperExtractionTests +{ + [Fact] + public void RoleSerializer_NormalizesParsesAndSerializesRoles() + { + var normalized = RoleSerializer.Normalize([" Admin ", "gm", "admin", "", "GM"]); + var serialized = RoleSerializer.Serialize(normalized); + var parsed = RoleSerializer.Parse(" admin,GM,admin "); + + Assert.Equal(["admin", "gm"], normalized); + Assert.Equal("admin,gm", serialized); + Assert.Equal(["admin", "gm"], parsed); + Assert.True(RoleSerializer.HasRole(serialized, "ADMIN")); + Assert.False(RoleSerializer.HasRole(serialized, "owner")); + } + + [Theory] + [InlineData("public", RollVisibility.Public)] + [InlineData("PRIVATE", RollVisibility.Private)] + public void RollVisibilityParser_ParsesKnownValues(string input, RollVisibility expected) + { + var result = RollVisibilityParser.Parse(input); + + Assert.True(result.Succeeded); + Assert.Equal(expected, result.Value); + } + + [Fact] + public void RollVisibilityParser_RejectsUnknownValue() + { + var result = RollVisibilityParser.Parse("hidden"); + + Assert.False(result.Succeeded); + Assert.Equal("invalid_visibility", result.Error!.Code); + } + + [Fact] + public void CustomRollOptionsResolver_ReturnsD6DefaultsOnlyForD6() + { + Assert.Equal((1, true, (int?)null), CustomRollOptionsResolver.Resolve(RulesetKind.D6)); + Assert.Equal((0, false, (int?)null), CustomRollOptionsResolver.Resolve(RulesetKind.Dnd5e)); + Assert.Equal((0, false, (int?)null), CustomRollOptionsResolver.Resolve(RulesetKind.Rolemaster)); + } + + [Fact] + public void SkillDefinitionValidator_ValidatesRulesetSpecificOptions() + { + var d6 = SkillDefinitionValidator.Validate(RulesetKind.D6, "2D+1", 1, true, null); + var rolemaster = SkillDefinitionValidator.Validate(RulesetKind.Rolemaster, "d100!+15", 0, false, 5); + var invalidD6 = SkillDefinitionValidator.Validate(RulesetKind.D6, "2D+1", 0, true, null); + var invalidRolemaster = SkillDefinitionValidator.Validate(RulesetKind.Rolemaster, "d100!+15", 0, false, null); + + Assert.True(d6.Succeeded); + Assert.Equal(("2D+1", 1, true, (int?)null), d6.Value); + + Assert.True(rolemaster.Succeeded); + Assert.Equal(("d100!+15", 0, false, (int?)5), rolemaster.Value); + + Assert.False(invalidD6.Succeeded); + Assert.Equal("invalid_wild_dice", invalidD6.Error!.Code); + + Assert.False(invalidRolemaster.Succeeded); + Assert.Equal("invalid_fumble_range", invalidRolemaster.Error!.Code); + } +} diff --git a/RpgRoller/Services/CustomRollOptionsResolver.cs b/RpgRoller/Services/CustomRollOptionsResolver.cs new file mode 100644 index 0000000..16d7a9a --- /dev/null +++ b/RpgRoller/Services/CustomRollOptionsResolver.cs @@ -0,0 +1,18 @@ +using RpgRoller.Domain; + +namespace RpgRoller.Services; + +public static class CustomRollOptionsResolver +{ + private const int DefaultCustomD6WildDice = 1; + private const bool DefaultCustomD6AllowFumble = true; + + public static (int WildDice, bool AllowFumble, int? FumbleRange) Resolve(RulesetKind ruleset) + { + return ruleset switch + { + RulesetKind.D6 => (DefaultCustomD6WildDice, DefaultCustomD6AllowFumble, null), + _ => (0, false, null) + }; + } +} diff --git a/RpgRoller/Services/GameService.cs b/RpgRoller/Services/GameService.cs index d0bc118..5dd6ccc 100644 --- a/RpgRoller/Services/GameService.cs +++ b/RpgRoller/Services/GameService.cs @@ -282,14 +282,14 @@ public sealed class GameService : IGameService if (!m_UsersById.TryGetValue(userId, out var targetUser)) return ServiceResult.Failure("user_not_found", "User was not found."); - var normalizedRoles = NormalizeRoles(roles); + var normalizedRoles = RoleSerializer.Normalize(roles); if (normalizedRoles.Any(role => !string.Equals(role, UserRoles.Admin, StringComparison.Ordinal))) return ServiceResult.Failure("invalid_role", "Unsupported role."); if (user.Id == targetUser.Id && !normalizedRoles.Contains(UserRoles.Admin, StringComparer.Ordinal)) return ServiceResult.Failure("forbidden", "You cannot remove your own admin role."); - targetUser.Roles = SerializeRoles(normalizedRoles); + targetUser.Roles = RoleSerializer.Serialize(normalizedRoles); PersistStateLocked(); return ServiceResult.Success(ToAdminUserSummary(targetUser)); } @@ -516,7 +516,7 @@ public sealed class GameService : IGameService if (!CanEditCharacterLocked(user.Id, character, campaign)) return ServiceResult.Failure("forbidden", "Only the owner or GM can manage skill groups."); - var prototypeValidation = ValidateSkillDefinition(campaign.Ruleset, diceRollDefinition, wildDice, allowFumble, fumbleRange); + var prototypeValidation = SkillDefinitionValidator.Validate(campaign.Ruleset, diceRollDefinition, wildDice, allowFumble, fumbleRange); if (!prototypeValidation.Succeeded) return ServiceResult.Failure(prototypeValidation.Error!.Code, prototypeValidation.Error.Message); @@ -560,7 +560,7 @@ public sealed class GameService : IGameService if (!CanEditCharacterLocked(user.Id, character, campaign)) return ServiceResult.Failure("forbidden", "Only the owner or GM can manage skill groups."); - var prototypeValidation = ValidateSkillDefinition(campaign.Ruleset, diceRollDefinition, wildDice, allowFumble, fumbleRange); + var prototypeValidation = SkillDefinitionValidator.Validate(campaign.Ruleset, diceRollDefinition, wildDice, allowFumble, fumbleRange); if (!prototypeValidation.Succeeded) return ServiceResult.Failure(prototypeValidation.Error!.Code, prototypeValidation.Error.Message); @@ -625,7 +625,7 @@ public sealed class GameService : IGameService if (!CanEditCharacterLocked(user.Id, character, campaign)) return ServiceResult.Failure("forbidden", "Only the owner or GM can edit skills."); - var skillValidation = ValidateSkillDefinition(campaign.Ruleset, diceRollDefinition, wildDice, allowFumble, fumbleRange); + var skillValidation = SkillDefinitionValidator.Validate(campaign.Ruleset, diceRollDefinition, wildDice, allowFumble, fumbleRange); if (!skillValidation.Succeeded) return ServiceResult.Failure(skillValidation.Error!.Code, skillValidation.Error.Message); @@ -674,7 +674,7 @@ public sealed class GameService : IGameService if (!CanEditCharacterLocked(user.Id, character, campaign)) return ServiceResult.Failure("forbidden", "Only the owner or GM can edit skills."); - var skillValidation = ValidateSkillDefinition(campaign.Ruleset, diceRollDefinition, wildDice, allowFumble, fumbleRange); + var skillValidation = SkillDefinitionValidator.Validate(campaign.Ruleset, diceRollDefinition, wildDice, allowFumble, fumbleRange); if (!skillValidation.Succeeded) return ServiceResult.Failure(skillValidation.Error!.Code, skillValidation.Error.Message); @@ -764,7 +764,7 @@ public sealed class GameService : IGameService if (!parsedExpression.Succeeded) return ServiceResult.Failure(parsedExpression.Error!.Code, parsedExpression.Error.Message); - var parsedVisibility = ParseVisibility(visibility); + var parsedVisibility = RollVisibilityParser.Parse(visibility); if (!parsedVisibility.Succeeded) return ServiceResult.Failure(parsedVisibility.Error!.Code, parsedVisibility.Error.Message); @@ -794,11 +794,11 @@ public sealed class GameService : IGameService if (!parsedExpression.Succeeded) return ServiceResult.Failure(parsedExpression.Error!.Code, parsedExpression.Error.Message); - var parsedVisibility = ParseVisibility(visibility); + var parsedVisibility = RollVisibilityParser.Parse(visibility); if (!parsedVisibility.Succeeded) return ServiceResult.Failure(parsedVisibility.Error!.Code, parsedVisibility.Error.Message); - var (wildDice, allowFumble, fumbleRange) = ResolveCustomRollOptions(campaign.Ruleset); + var (wildDice, allowFumble, fumbleRange) = CustomRollOptionsResolver.Resolve(campaign.Ruleset); var roll = ComputeRoll(campaign.Ruleset, parsedExpression.Value!, wildDice, allowFumble, fumbleRange); return RecordRollLocked(user, campaign, character, CustomRollSkillId, parsedVisibility.Value, roll, parsedExpression.Value!.Canonical); } @@ -893,66 +893,6 @@ public sealed class GameService : IGameService } } - private static ServiceResult<(string CanonicalExpression, int WildDice, bool AllowFumble, int? FumbleRange)> ValidateSkillDefinition(RulesetKind ruleset, string diceRollDefinition, int wildDice, bool allowFumble, int? fumbleRange) - { - 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); - - var optionsValidation = ValidateSkillOptions(ruleset, expressionValidation.Value!, wildDice, allowFumble, fumbleRange); - 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)>.Success((expressionValidation.Value!.Canonical, optionsValidation.Value!.WildDice, optionsValidation.Value.AllowFumble, optionsValidation.Value.FumbleRange)); - } - - private static ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)> ValidateSkillOptions(RulesetKind ruleset, DiceExpression expression, int wildDice, bool allowFumble, int? fumbleRange) - { - 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."); - - 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."); - - 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)>.Success((wildDice, allowFumble, null)); - } - - 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."); - - if (allowFumble) - return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)>.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."); - - 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)>.Success((0, false, fumbleRange)); - } - - 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)>.Success((0, false, null)); - } - - 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)>.Success((0, false, null)); - } - private (int Total, string Breakdown, IReadOnlyList Dice) ComputeRoll(RulesetKind ruleset, DiceExpression expression, int wildDice, bool allowFumble, int? fumbleRange) { if (ruleset == RulesetKind.D6) @@ -1201,17 +1141,6 @@ public sealed class GameService : IGameService return ServiceResult.Success(skillGroup.Id); } - private ServiceResult ParseVisibility(string visibility) - { - if (string.Equals(visibility, "public", StringComparison.OrdinalIgnoreCase)) - return ServiceResult.Success(RollVisibility.Public); - - if (string.Equals(visibility, "private", StringComparison.OrdinalIgnoreCase)) - return ServiceResult.Success(RollVisibility.Private); - - return ServiceResult.Failure("invalid_visibility", "Visibility must be 'public' or 'private'."); - } - private ServiceResult RecordRollLocked( UserAccount user, Campaign campaign, @@ -1242,15 +1171,6 @@ public sealed class GameService : IGameService return ServiceResult.Success(ToRollResult(entry, roll.Dice)); } - private static (int WildDice, bool AllowFumble, int? FumbleRange) ResolveCustomRollOptions(RulesetKind ruleset) - { - return ruleset switch - { - RulesetKind.D6 => (DefaultCustomD6WildDice, DefaultCustomD6AllowFumble, null), - _ => (0, false, null) - }; - } - private static string FormatLoggedBreakdown(Guid skillId, string canonicalExpression, string breakdown) { return skillId == CustomRollSkillId @@ -1275,12 +1195,12 @@ public sealed class GameService : IGameService private static UserSummary ToUserSummary(UserAccount user) { - return new(user.Id, user.Username, user.DisplayName, ParseRoles(user.Roles)); + return new(user.Id, user.Username, user.DisplayName, RoleSerializer.Parse(user.Roles)); } private static AdminUserSummary ToAdminUserSummary(UserAccount user) { - return new(user.Id, user.Username, user.DisplayName, ParseRoles(user.Roles)); + return new(user.Id, user.Username, user.DisplayName, RoleSerializer.Parse(user.Roles)); } private static CampaignOption ToCampaignOption(Campaign campaign) @@ -1702,29 +1622,9 @@ public sealed class GameService : IGameService TouchRosterLocked(campaignId); } - private static IReadOnlyList ParseRoles(string serializedRoles) - { - return NormalizeRoles(serializedRoles.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)); - } - - private static string SerializeRoles(IReadOnlyList roles) - { - return string.Join(",", NormalizeRoles(roles)); - } - - private static string[] NormalizeRoles(IEnumerable roles) - { - return roles - .Where(role => !string.IsNullOrWhiteSpace(role)) - .Select(role => role.Trim().ToLowerInvariant()) - .Distinct(StringComparer.Ordinal) - .OrderBy(role => role, StringComparer.Ordinal) - .ToArray(); - } - private static bool UserHasRoleLocked(UserAccount user, string role) { - return ParseRoles(user.Roles).Contains(role, StringComparer.OrdinalIgnoreCase); + return RoleSerializer.HasRole(user.Roles, role); } private UserSession CreateSession(Guid userId) @@ -1854,7 +1754,7 @@ public sealed class GameService : IGameService UsernameNormalized = normalizedUsername, PasswordHash = user.PasswordHash, DisplayName = user.DisplayName, - Roles = string.IsNullOrWhiteSpace(user.Roles) ? string.Empty : SerializeRoles(ParseRoles(user.Roles)), + Roles = string.IsNullOrWhiteSpace(user.Roles) ? string.Empty : RoleSerializer.Serialize(RoleSerializer.Parse(user.Roles)), ActiveCharacterId = user.ActiveCharacterId }; m_UsersById[storedUser.Id] = storedUser; @@ -2018,8 +1918,6 @@ public sealed class GameService : IGameService private const int CampaignLogHistoryWindowSize = 100; private const int CampaignLogLivePageSize = 25; - private const int DefaultCustomD6WildDice = 1; - private const bool DefaultCustomD6AllowFumble = true; private const string CustomRollBreakdownSeparator = " => "; private static readonly Guid CustomRollSkillId = Guid.Empty; private const string CustomRollLabel = "Custom roll"; diff --git a/RpgRoller/Services/RoleSerializer.cs b/RpgRoller/Services/RoleSerializer.cs new file mode 100644 index 0000000..4341611 --- /dev/null +++ b/RpgRoller/Services/RoleSerializer.cs @@ -0,0 +1,29 @@ +namespace RpgRoller.Services; + +public static class RoleSerializer +{ + public static IReadOnlyList Parse(string serializedRoles) + { + return Normalize(serializedRoles.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)); + } + + public static string Serialize(IReadOnlyList roles) + { + return string.Join(",", Normalize(roles)); + } + + public static string[] Normalize(IEnumerable roles) + { + return roles + .Where(role => !string.IsNullOrWhiteSpace(role)) + .Select(role => role.Trim().ToLowerInvariant()) + .Distinct(StringComparer.Ordinal) + .OrderBy(role => role, StringComparer.Ordinal) + .ToArray(); + } + + public static bool HasRole(string serializedRoles, string role) + { + return Parse(serializedRoles).Contains(role, StringComparer.OrdinalIgnoreCase); + } +} diff --git a/RpgRoller/Services/RollVisibilityParser.cs b/RpgRoller/Services/RollVisibilityParser.cs new file mode 100644 index 0000000..146d0a7 --- /dev/null +++ b/RpgRoller/Services/RollVisibilityParser.cs @@ -0,0 +1,17 @@ +using RpgRoller.Domain; + +namespace RpgRoller.Services; + +public static class RollVisibilityParser +{ + public static ServiceResult Parse(string visibility) + { + if (string.Equals(visibility, "public", StringComparison.OrdinalIgnoreCase)) + return ServiceResult.Success(RollVisibility.Public); + + if (string.Equals(visibility, "private", StringComparison.OrdinalIgnoreCase)) + return ServiceResult.Success(RollVisibility.Private); + + return ServiceResult.Failure("invalid_visibility", "Visibility must be 'public' or 'private'."); + } +} diff --git a/RpgRoller/Services/SkillDefinitionValidator.cs b/RpgRoller/Services/SkillDefinitionValidator.cs new file mode 100644 index 0000000..0c2525c --- /dev/null +++ b/RpgRoller/Services/SkillDefinitionValidator.cs @@ -0,0 +1,80 @@ +using RpgRoller.Domain; + +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) + { + 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); + + var optionsValidation = ValidateOptions(ruleset, expressionValidation.Value!, wildDice, allowFumble, fumbleRange); + 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)>.Success(( + expressionValidation.Value!.Canonical, + optionsValidation.Value!.WildDice, + optionsValidation.Value.AllowFumble, + optionsValidation.Value.FumbleRange)); + } + + private static ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)> ValidateOptions( + RulesetKind ruleset, + DiceExpression expression, + int wildDice, + bool allowFumble, + int? fumbleRange) + { + 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."); + + 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."); + + 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)>.Success((wildDice, allowFumble, null)); + } + + 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."); + + if (allowFumble) + return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)>.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."); + + 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)>.Success((0, false, fumbleRange)); + } + + 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)>.Success((0, false, null)); + } + + 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)>.Success((0, false, null)); + } +}