Extract service rule helpers

This commit is contained in:
2026-04-04 23:13:28 +02:00
parent 7ec2887df2
commit fa5bad23a7
7 changed files with 225 additions and 115 deletions

View File

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

View File

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

View File

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

View File

@@ -282,14 +282,14 @@ public sealed class GameService : IGameService
if (!m_UsersById.TryGetValue(userId, out var targetUser))
return ServiceResult<AdminUserSummary>.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<AdminUserSummary>.Failure("invalid_role", "Unsupported role.");
if (user.Id == targetUser.Id && !normalizedRoles.Contains(UserRoles.Admin, StringComparer.Ordinal))
return ServiceResult<AdminUserSummary>.Failure("forbidden", "You cannot remove your own admin role.");
targetUser.Roles = SerializeRoles(normalizedRoles);
targetUser.Roles = RoleSerializer.Serialize(normalizedRoles);
PersistStateLocked();
return ServiceResult<AdminUserSummary>.Success(ToAdminUserSummary(targetUser));
}
@@ -516,7 +516,7 @@ public sealed class GameService : IGameService
if (!CanEditCharacterLocked(user.Id, character, campaign))
return ServiceResult<SkillGroupSummary>.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<SkillGroupSummary>.Failure(prototypeValidation.Error!.Code, prototypeValidation.Error.Message);
@@ -560,7 +560,7 @@ public sealed class GameService : IGameService
if (!CanEditCharacterLocked(user.Id, character, campaign))
return ServiceResult<SkillGroupSummary>.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<SkillGroupSummary>.Failure(prototypeValidation.Error!.Code, prototypeValidation.Error.Message);
@@ -625,7 +625,7 @@ public sealed class GameService : IGameService
if (!CanEditCharacterLocked(user.Id, character, campaign))
return ServiceResult<SkillSummary>.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<SkillSummary>.Failure(skillValidation.Error!.Code, skillValidation.Error.Message);
@@ -674,7 +674,7 @@ public sealed class GameService : IGameService
if (!CanEditCharacterLocked(user.Id, character, campaign))
return ServiceResult<SkillSummary>.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<SkillSummary>.Failure(skillValidation.Error!.Code, skillValidation.Error.Message);
@@ -764,7 +764,7 @@ public sealed class GameService : IGameService
if (!parsedExpression.Succeeded)
return ServiceResult<RollResult>.Failure(parsedExpression.Error!.Code, parsedExpression.Error.Message);
var parsedVisibility = ParseVisibility(visibility);
var parsedVisibility = RollVisibilityParser.Parse(visibility);
if (!parsedVisibility.Succeeded)
return ServiceResult<RollResult>.Failure(parsedVisibility.Error!.Code, parsedVisibility.Error.Message);
@@ -794,11 +794,11 @@ public sealed class GameService : IGameService
if (!parsedExpression.Succeeded)
return ServiceResult<RollResult>.Failure(parsedExpression.Error!.Code, parsedExpression.Error.Message);
var parsedVisibility = ParseVisibility(visibility);
var parsedVisibility = RollVisibilityParser.Parse(visibility);
if (!parsedVisibility.Succeeded)
return ServiceResult<RollResult>.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<RollDieResult> 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<Guid?>.Success(skillGroup.Id);
}
private ServiceResult<RollVisibility> ParseVisibility(string visibility)
{
if (string.Equals(visibility, "public", StringComparison.OrdinalIgnoreCase))
return ServiceResult<RollVisibility>.Success(RollVisibility.Public);
if (string.Equals(visibility, "private", StringComparison.OrdinalIgnoreCase))
return ServiceResult<RollVisibility>.Success(RollVisibility.Private);
return ServiceResult<RollVisibility>.Failure("invalid_visibility", "Visibility must be 'public' or 'private'.");
}
private ServiceResult<RollResult> RecordRollLocked(
UserAccount user,
Campaign campaign,
@@ -1242,15 +1171,6 @@ public sealed class GameService : IGameService
return ServiceResult<RollResult>.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<string> ParseRoles(string serializedRoles)
{
return NormalizeRoles(serializedRoles.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries));
}
private static string SerializeRoles(IReadOnlyList<string> roles)
{
return string.Join(",", NormalizeRoles(roles));
}
private static string[] NormalizeRoles(IEnumerable<string> 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";

View File

@@ -0,0 +1,29 @@
namespace RpgRoller.Services;
public static class RoleSerializer
{
public static IReadOnlyList<string> Parse(string serializedRoles)
{
return Normalize(serializedRoles.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries));
}
public static string Serialize(IReadOnlyList<string> roles)
{
return string.Join(",", Normalize(roles));
}
public static string[] Normalize(IEnumerable<string> 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);
}
}

View File

@@ -0,0 +1,17 @@
using RpgRoller.Domain;
namespace RpgRoller.Services;
public static class RollVisibilityParser
{
public static ServiceResult<RollVisibility> Parse(string visibility)
{
if (string.Equals(visibility, "public", StringComparison.OrdinalIgnoreCase))
return ServiceResult<RollVisibility>.Success(RollVisibility.Public);
if (string.Equals(visibility, "private", StringComparison.OrdinalIgnoreCase))
return ServiceResult<RollVisibility>.Success(RollVisibility.Private);
return ServiceResult<RollVisibility>.Failure("invalid_visibility", "Visibility must be 'public' or 'private'.");
}
}

View File

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