From 48439fd21deed80e2b4eedfb4619563972d19a13 Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Fri, 3 Apr 2026 00:32:17 +0200 Subject: [PATCH] Persist Rolemaster fumble range --- README.md | 2 + RpgRoller.Tests/Api/CampaignApiTests.cs | 29 ++ RpgRoller.Tests/HostingCoverageTests.cs | 137 +++++++++ .../Services/ServicePersistenceTests.cs | 27 ++ .../ServiceSkillGroupAndOwnershipTests.cs | 23 +- .../Services/WorkspaceQueryServiceTests.cs | 8 +- RpgRoller/Api/SkillEndpoints.cs | 8 +- RpgRoller/Components/Pages/Home.Models.cs | 2 + .../HomeControls/CharacterPanel.razor.cs | 14 +- .../HomeControls/SkillFormModal.razor.cs | 5 +- RpgRoller/Contracts/ApiContracts.cs | 16 +- RpgRoller/Data/RpgRollerDbContext.cs | 2 + RpgRoller/Domain/GameModels.cs | 2 + ...22501_AddRolemasterFumbleRange.Designer.cs | 264 ++++++++++++++++++ ...20260402222501_AddRolemasterFumbleRange.cs | 38 +++ .../RpgRollerDbContextModelSnapshot.cs | 6 + RpgRoller/Services/GameService.cs | 89 ++++-- RpgRoller/Services/IGameService.cs | 8 +- openapi/RpgRoller.json | 30 ++ 19 files changed, 654 insertions(+), 56 deletions(-) create mode 100644 RpgRoller/Migrations/20260402222501_AddRolemasterFumbleRange.Designer.cs create mode 100644 RpgRoller/Migrations/20260402222501_AddRolemasterFumbleRange.cs diff --git a/README.md b/README.md index 734c47f..30ddb58 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,8 @@ Gameplay capabilities now include: - Shared top header control across all authenticated workspace screens (play, campaign management, admin) - Admin user management is integrated into workspace screen toggles (`Play`, `Campaign Management`, `Admin`) - Rolemaster expression validation currently recognizes `2d10+48`, `d100+4`, and `d100!+85`, including Rolemaster-only negative modifiers such as `d100-15` +- Rolemaster open-ended percentile skills and skill-group defaults now persist a nullable `FumbleRange` field, while D6 and D&D rows migrate forward unchanged +- Startup migration coverage is validated against a copied temp-file instance of `RpgRoller/App_Data/rpgroller.development.db` before feature work is considered complete ## Prerequisites diff --git a/RpgRoller.Tests/Api/CampaignApiTests.cs b/RpgRoller.Tests/Api/CampaignApiTests.cs index dd6b8ce..f1b7db3 100644 --- a/RpgRoller.Tests/Api/CampaignApiTests.cs +++ b/RpgRoller.Tests/Api/CampaignApiTests.cs @@ -79,6 +79,35 @@ public sealed class CampaignApiTests : ApiTestBase Assert.Equal("rolemaster", campaign.RulesetId); } + [Fact] + public async Task RolemasterSkillDefinitions_RoundTripFumbleRangeThroughApi() + { + using var factory = CreateFactory(88, 42, 17); + using var gmClient = factory.CreateClient(new() { AllowAutoRedirect = false }); + + await RegisterAsync(gmClient, "gm-rm-skill", "Password123", "Game Master"); + await LoginAsync(gmClient, "gm-rm-skill", "Password123"); + + var campaign = await PostAsync(gmClient, "/api/campaigns", new("Shadow World", "rolemaster")); + var character = await PostAsync(gmClient, "/api/characters", new("Kalen", campaign.Id)); + + var missingFumbleRange = await gmClient.PostAsJsonAsync($"/api/characters/{character.Id}/skills", new CreateSkillRequest("Bad Open Ended", "d100!+35", 0, false)); + Assert.Equal(HttpStatusCode.BadRequest, missingFumbleRange.StatusCode); + + var group = await PostAsync(gmClient, $"/api/characters/{character.Id}/skill-groups", new("Perception", "d100!+15", 0, false, 5)); + Assert.Equal(5, group.FumbleRange); + + var skill = await PostAsync(gmClient, $"/api/characters/{character.Id}/skills", new("Awareness", "d100!+35", 0, false, group.Id, 3)); + Assert.Equal(3, skill.FumbleRange); + + var updatedSkill = await PutAsync(gmClient, $"/api/skills/{skill.Id}", new("Awareness", "d100!+45", 0, false, group.Id, 4)); + Assert.Equal(4, updatedSkill.FumbleRange); + + var sheet = await GetAsync(gmClient, $"/api/characters/{character.Id}/sheet"); + Assert.Equal(5, Assert.Single(sheet.SkillGroups).FumbleRange); + Assert.Equal(4, Assert.Single(sheet.Skills).FumbleRange); + } + [Fact] public async Task SkillGroupsAndOwnerTransfer_WorkThroughApi() { diff --git a/RpgRoller.Tests/HostingCoverageTests.cs b/RpgRoller.Tests/HostingCoverageTests.cs index 3e5fa83..32d083d 100644 --- a/RpgRoller.Tests/HostingCoverageTests.cs +++ b/RpgRoller.Tests/HostingCoverageTests.cs @@ -1,7 +1,9 @@ using Microsoft.Data.Sqlite; +using Microsoft.AspNetCore.Builder; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; using RpgRoller.Data; using RpgRoller.Hosting; @@ -125,6 +127,16 @@ public sealed class HostingCoverageTests Assert.Contains("WildDice", columns); Assert.Contains("AllowFumble", columns); + Assert.Contains("FumbleRange", columns); + + using var skillGroupsTableInfoCommand = verifyConnection.CreateCommand(); + skillGroupsTableInfoCommand.CommandText = "PRAGMA table_info('SkillGroups');"; + using var skillGroupsTableInfoReader = skillGroupsTableInfoCommand.ExecuteReader(); + var skillGroupColumns = new HashSet(StringComparer.OrdinalIgnoreCase); + while (skillGroupsTableInfoReader.Read()) + skillGroupColumns.Add(skillGroupsTableInfoReader.GetString(1)); + + Assert.Contains("FumbleRange", skillGroupColumns); using var rollTableInfoCommand = verifyConnection.CreateCommand(); rollTableInfoCommand.CommandText = "PRAGMA table_info('RollLogEntries');"; @@ -183,5 +195,130 @@ public sealed class HostingCoverageTests rolesHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260226160859_AddAuthorizationRolesAndCampaignDeletion';"; var rolesHistoryCount = Convert.ToInt32(rolesHistoryCommand.ExecuteScalar()); Assert.Equal(1, rolesHistoryCount); + + using var rolemasterHistoryCommand = verifyConnection.CreateCommand(); + rolemasterHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260402222501_AddRolemasterFumbleRange';"; + var rolemasterHistoryCount = Convert.ToInt32(rolemasterHistoryCommand.ExecuteScalar()); + Assert.Equal(1, rolemasterHistoryCount); + } + + [Fact] + public void InitializeRpgRollerState_MigratesCopiedDevelopmentDatabaseAndPreservesD6Rolling() + { + var sourceDbPath = Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "RpgRoller", "App_Data", "rpgroller.development.db"); + var copiedDbPath = Path.Combine(Path.GetTempPath(), $"rpgroller-dev-copy-{Guid.NewGuid():N}.db"); + File.Copy(Path.GetFullPath(sourceDbPath), copiedDbPath, overwrite: true); + + Guid skillId; + Guid ownerUserId; + Guid characterId; + var campaignCountBefore = 0; + var skillCountBefore = 0; + using (var connection = new SqliteConnection($"Data Source={copiedDbPath}")) + { + connection.Open(); + + using var countsCommand = connection.CreateCommand(); + countsCommand.CommandText = """ + SELECT (SELECT COUNT(*) FROM Campaigns), + (SELECT COUNT(*) FROM Skills); + """; + using var countsReader = countsCommand.ExecuteReader(); + Assert.True(countsReader.Read()); + campaignCountBefore = countsReader.GetInt32(0); + skillCountBefore = countsReader.GetInt32(1); + + using var existingSkillCommand = connection.CreateCommand(); + existingSkillCommand.CommandText = """ + SELECT s.Id, c.OwnerUserId, c.Id + FROM Skills s + INNER JOIN Characters c ON c.Id = s.CharacterId + INNER JOIN Campaigns cp ON cp.Id = c.CampaignId + WHERE cp.Ruleset = 'D6' + ORDER BY s.Name + LIMIT 1; + """; + using var existingSkillReader = existingSkillCommand.ExecuteReader(); + Assert.True(existingSkillReader.Read()); + skillId = Guid.Parse(existingSkillReader.GetString(0)); + ownerUserId = Guid.Parse(existingSkillReader.GetString(1)); + characterId = Guid.Parse(existingSkillReader.GetString(2)); + + using var sessionCommand = connection.CreateCommand(); + sessionCommand.CommandText = """ + INSERT INTO Sessions ("Token", "UserId", "CreatedAtUtc") + VALUES ($token, $userId, $createdAtUtc); + """; + var tokenParameter = sessionCommand.CreateParameter(); + tokenParameter.ParameterName = "$token"; + tokenParameter.Value = "migration-test-session"; + sessionCommand.Parameters.Add(tokenParameter); + + var userParameter = sessionCommand.CreateParameter(); + userParameter.ParameterName = "$userId"; + userParameter.Value = ownerUserId.ToString(); + sessionCommand.Parameters.Add(userParameter); + + var createdAtParameter = sessionCommand.CreateParameter(); + createdAtParameter.ParameterName = "$createdAtUtc"; + createdAtParameter.Value = DateTimeOffset.UtcNow.ToString("O"); + sessionCommand.Parameters.Add(createdAtParameter); + + _ = sessionCommand.ExecuteNonQuery(); + } + + var builder = WebApplication.CreateBuilder(new WebApplicationOptions + { + ContentRootPath = Path.GetTempPath(), + EnvironmentName = Environments.Development + }); + builder.Configuration.AddInMemoryCollection(new Dictionary + { + ["ConnectionStrings:RpgRoller"] = $"Data Source={copiedDbPath}" + }); + builder.Services.AddRpgRollerCore(builder.Configuration, builder.Environment); + + using var app = builder.Build(); + app.InitializeRpgRollerState(); + + using var scope = app.Services.CreateScope(); + var game = scope.ServiceProvider.GetRequiredService(); + var rollResult = game.RollSkill("migration-test-session", skillId, "public"); + Assert.True(rollResult.Succeeded); + Assert.NotEmpty(ServiceTestSupport.GetValue(rollResult).Dice); + + var migratedSheet = ServiceTestSupport.GetValue(game.GetCharacterSheet("migration-test-session", characterId)); + Assert.Contains(migratedSheet.Skills, skill => skill.Id == skillId); + + using var verifyConnection = new SqliteConnection($"Data Source={copiedDbPath}"); + verifyConnection.Open(); + + using var countsAfterCommand = verifyConnection.CreateCommand(); + countsAfterCommand.CommandText = """ + SELECT (SELECT COUNT(*) FROM Campaigns), + (SELECT COUNT(*) FROM Skills); + """; + using var countsAfterReader = countsAfterCommand.ExecuteReader(); + Assert.True(countsAfterReader.Read()); + Assert.Equal(campaignCountBefore, countsAfterReader.GetInt32(0)); + Assert.Equal(skillCountBefore, countsAfterReader.GetInt32(1)); + + using var skillsTableInfoCommand = verifyConnection.CreateCommand(); + skillsTableInfoCommand.CommandText = "PRAGMA table_info('Skills');"; + using var skillsTableInfoReader = skillsTableInfoCommand.ExecuteReader(); + var skillColumns = new HashSet(StringComparer.OrdinalIgnoreCase); + while (skillsTableInfoReader.Read()) + skillColumns.Add(skillsTableInfoReader.GetString(1)); + + Assert.Contains("FumbleRange", skillColumns); + + using var skillGroupsTableInfoCommand = verifyConnection.CreateCommand(); + skillGroupsTableInfoCommand.CommandText = "PRAGMA table_info('SkillGroups');"; + using var skillGroupsTableInfoReader = skillGroupsTableInfoCommand.ExecuteReader(); + var skillGroupColumns = new HashSet(StringComparer.OrdinalIgnoreCase); + while (skillGroupsTableInfoReader.Read()) + skillGroupColumns.Add(skillGroupsTableInfoReader.GetString(1)); + + Assert.Contains("FumbleRange", skillGroupColumns); } } diff --git a/RpgRoller.Tests/Services/ServicePersistenceTests.cs b/RpgRoller.Tests/Services/ServicePersistenceTests.cs index c0e12c9..15e7499 100644 --- a/RpgRoller.Tests/Services/ServicePersistenceTests.cs +++ b/RpgRoller.Tests/Services/ServicePersistenceTests.cs @@ -92,4 +92,31 @@ public sealed class ServicePersistenceTests Assert.False(invalidExpressionHarness.Service.RollSkill(ownerSession, skill.Id, "public").Succeeded); Assert.False(service.GetCampaignLog(string.Empty, campaign.Id).Succeeded); } + + [Fact] + public void RolemasterFumbleRange_PersistsAcrossDatabaseReload() + { + using var harness = ServiceTestSupport.CreateHarness(); + var service = harness.Service; + + service.Register("gm-rm-persist", "Password123", "GM"); + service.Register("owner-rm-persist", "Password123", "Owner"); + + var gmSession = ServiceTestSupport.GetValue(service.Login("gm-rm-persist", "Password123")).SessionToken; + var ownerSession = ServiceTestSupport.GetValue(service.Login("owner-rm-persist", "Password123")).SessionToken; + + var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Rolemaster Persistence", "rolemaster")); + var character = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Loremaster", campaign.Id)); + var group = ServiceTestSupport.GetValue(service.CreateSkillGroup(ownerSession, character.Id, "Perception", "d100!+25", 0, false, 5)); + var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, character.Id, "Read Runes", "d100!+35", 0, false, group.Id, 3)); + + using var reloadedHarness = ServiceTestSupport.CreateHarnessFromPath(harness.DbPath); + var reloadedSheet = ServiceTestSupport.GetValue(reloadedHarness.Service.GetCharacterSheet(ownerSession, character.Id)); + + var reloadedGroup = Assert.Single(reloadedSheet.SkillGroups, current => current.Id == group.Id); + Assert.Equal(5, reloadedGroup.FumbleRange); + + var reloadedSkill = Assert.Single(reloadedSheet.Skills, current => current.Id == skill.Id); + Assert.Equal(3, reloadedSkill.FumbleRange); + } } diff --git a/RpgRoller.Tests/Services/ServiceSkillGroupAndOwnershipTests.cs b/RpgRoller.Tests/Services/ServiceSkillGroupAndOwnershipTests.cs index 31cb0ea..f32e9df 100644 --- a/RpgRoller.Tests/Services/ServiceSkillGroupAndOwnershipTests.cs +++ b/RpgRoller.Tests/Services/ServiceSkillGroupAndOwnershipTests.cs @@ -195,19 +195,34 @@ public sealed class ServiceSkillGroupAndOwnershipTests var negativeDndSkill = service.CreateSkill(ownerSession, dndCharacter.Id, "Invalid", "1d20-1", 0, false); Assert.False(negativeDndSkill.Succeeded); - var rolemasterGroup = ServiceTestSupport.GetValue(service.CreateSkillGroup(ownerSession, rolemasterCharacter.Id, "Initiative", "2d10-15", 3, true)); - Assert.Equal("2d10-15", rolemasterGroup.DiceRollDefinition); + var invalidRolemasterOptions = service.CreateSkillGroup(ownerSession, rolemasterCharacter.Id, "Invalid", "2d10-15", 3, true); + Assert.False(invalidRolemasterOptions.Succeeded); + + var rolemasterGroup = ServiceTestSupport.GetValue(service.CreateSkillGroup(ownerSession, rolemasterCharacter.Id, "Awareness", "d100!+15", 0, false, 5)); + Assert.Equal("d100!+15", rolemasterGroup.DiceRollDefinition); Assert.Equal(0, rolemasterGroup.WildDice); Assert.False(rolemasterGroup.AllowFumble); + Assert.Equal(5, rolemasterGroup.FumbleRange); - var percentileSkill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, rolemasterCharacter.Id, "Perception", "1d100-20", 4, true)); + var percentileWithFumbleRange = service.CreateSkill(ownerSession, rolemasterCharacter.Id, "Bad Percentile", "1d100-20", 0, false, null, 5); + Assert.False(percentileWithFumbleRange.Succeeded); + + var percentileSkill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, rolemasterCharacter.Id, "Perception", "1d100-20", 0, false, rolemasterGroup.Id)); Assert.Equal("d100-20", percentileSkill.DiceRollDefinition); Assert.Equal(0, percentileSkill.WildDice); Assert.False(percentileSkill.AllowFumble); + Assert.Null(percentileSkill.FumbleRange); - var openEndedSkill = ServiceTestSupport.GetValue(service.UpdateSkill(ownerSession, percentileSkill.Id, "Perception", "1d100!+85", 5, true)); + var missingOpenEndedFumbleRange = service.UpdateSkill(ownerSession, percentileSkill.Id, "Perception", "1d100!+85", 0, false, rolemasterGroup.Id); + Assert.False(missingOpenEndedFumbleRange.Succeeded); + + var invalidOpenEndedFumbleRange = service.UpdateSkill(ownerSession, percentileSkill.Id, "Perception", "1d100!+85", 0, false, rolemasterGroup.Id, 96); + Assert.False(invalidOpenEndedFumbleRange.Succeeded); + + var openEndedSkill = ServiceTestSupport.GetValue(service.UpdateSkill(ownerSession, percentileSkill.Id, "Perception", "1d100!+85", 0, false, rolemasterGroup.Id, 5)); Assert.Equal("d100!+85", openEndedSkill.DiceRollDefinition); Assert.Equal(0, openEndedSkill.WildDice); Assert.False(openEndedSkill.AllowFumble); + Assert.Equal(5, openEndedSkill.FumbleRange); } } diff --git a/RpgRoller.Tests/Services/WorkspaceQueryServiceTests.cs b/RpgRoller.Tests/Services/WorkspaceQueryServiceTests.cs index f10370b..3f2fa2b 100644 --- a/RpgRoller.Tests/Services/WorkspaceQueryServiceTests.cs +++ b/RpgRoller.Tests/Services/WorkspaceQueryServiceTests.cs @@ -87,11 +87,11 @@ public sealed class WorkspaceQueryServiceTests public ServiceResult DeleteCharacter(string sessionToken, Guid characterId) => throw new NotSupportedException(); public ServiceResult ActivateCharacter(string sessionToken, Guid characterId) => throw new NotSupportedException(); public ServiceResult> GetOwnCharacters(string sessionToken) => throw new NotSupportedException(); - public ServiceResult CreateSkillGroup(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble) => throw new NotSupportedException(); - public ServiceResult UpdateSkillGroup(string sessionToken, Guid skillGroupId, string name, string diceRollDefinition, int wildDice, bool allowFumble) => throw new NotSupportedException(); + public ServiceResult CreateSkillGroup(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, int? fumbleRange = null) => throw new NotSupportedException(); + public ServiceResult UpdateSkillGroup(string sessionToken, Guid skillGroupId, string name, string diceRollDefinition, int wildDice, bool allowFumble, int? fumbleRange = null) => throw new NotSupportedException(); public ServiceResult DeleteSkillGroup(string sessionToken, Guid skillGroupId) => throw new NotSupportedException(); - public ServiceResult CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null) => throw new NotSupportedException(); - public ServiceResult UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null) => throw new NotSupportedException(); + public ServiceResult CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null) => throw new NotSupportedException(); + public ServiceResult UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null) => throw new NotSupportedException(); public ServiceResult DeleteSkill(string sessionToken, Guid skillId) => throw new NotSupportedException(); public ServiceResult GetCharacterSheet(string sessionToken, Guid characterId) => throw new NotSupportedException(); public ServiceResult RollSkill(string sessionToken, Guid skillId, string visibility) => throw new NotSupportedException(); diff --git a/RpgRoller/Api/SkillEndpoints.cs b/RpgRoller/Api/SkillEndpoints.cs index 782ad68..d10440f 100644 --- a/RpgRoller/Api/SkillEndpoints.cs +++ b/RpgRoller/Api/SkillEndpoints.cs @@ -9,13 +9,13 @@ internal static class SkillEndpoints { group.MapPost("/characters/{characterId:guid}/skills", (Guid characterId, CreateSkillRequest request, HttpContext context, IGameService game) => { - var result = game.CreateSkill(context.GetRequiredSessionToken(), characterId, request.Name, request.DiceRollDefinition, request.WildDice, request.AllowFumble, request.SkillGroupId); + var result = game.CreateSkill(context.GetRequiredSessionToken(), characterId, request.Name, request.DiceRollDefinition, request.WildDice, request.AllowFumble, request.SkillGroupId, request.FumbleRange); 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); + var result = game.UpdateSkill(context.GetRequiredSessionToken(), skillId, request.Name, request.DiceRollDefinition, request.WildDice, request.AllowFumble, request.SkillGroupId, request.FumbleRange); return ApiResultMapper.ToApiResult(result); }); @@ -27,13 +27,13 @@ internal static class SkillEndpoints group.MapPost("/characters/{characterId:guid}/skill-groups", (Guid characterId, CreateSkillGroupRequest request, HttpContext context, IGameService game) => { - var result = game.CreateSkillGroup(context.GetRequiredSessionToken(), characterId, request.Name, request.DiceRollDefinition, request.WildDice, request.AllowFumble); + var result = game.CreateSkillGroup(context.GetRequiredSessionToken(), characterId, request.Name, request.DiceRollDefinition, request.WildDice, request.AllowFumble, request.FumbleRange); return ApiResultMapper.ToApiResult(result); }); group.MapPut("/skill-groups/{skillGroupId:guid}", (Guid skillGroupId, UpdateSkillGroupRequest request, HttpContext context, IGameService game) => { - var result = game.UpdateSkillGroup(context.GetRequiredSessionToken(), skillGroupId, request.Name, request.DiceRollDefinition, request.WildDice, request.AllowFumble); + var result = game.UpdateSkillGroup(context.GetRequiredSessionToken(), skillGroupId, request.Name, request.DiceRollDefinition, request.WildDice, request.AllowFumble, request.FumbleRange); return ApiResultMapper.ToApiResult(result); }); diff --git a/RpgRoller/Components/Pages/Home.Models.cs b/RpgRoller/Components/Pages/Home.Models.cs index cfd5d9f..4f828f1 100644 --- a/RpgRoller/Components/Pages/Home.Models.cs +++ b/RpgRoller/Components/Pages/Home.Models.cs @@ -46,6 +46,7 @@ public sealed class SkillFormModel public string SkillGroupId { get; set; } = string.Empty; public int WildDice { get; set; } public bool AllowFumble { get; set; } + public int? FumbleRange { get; set; } } public sealed class SkillGroupFormModel @@ -54,6 +55,7 @@ public sealed class SkillGroupFormModel public string DiceRollDefinition { get; set; } = string.Empty; public int WildDice { get; set; } public bool AllowFumble { get; set; } + public int? FumbleRange { get; set; } } public enum HomeViewMode diff --git a/RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor.cs b/RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor.cs index d8671ef..3cfd2ff 100644 --- a/RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor.cs +++ b/RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor.cs @@ -19,7 +19,8 @@ public partial class CharacterPanel DiceRollDefinition = selectedGroup?.DiceRollDefinition ?? string.Empty, SkillGroupId = selectedGroup?.Id.ToString() ?? string.Empty, WildDice = selectedGroup?.WildDice ?? (IsD6 ? 1 : 0), - AllowFumble = selectedGroup?.AllowFumble ?? IsD6 + AllowFumble = selectedGroup?.AllowFumble ?? IsD6, + FumbleRange = selectedGroup?.FumbleRange }; CreateSkillFormVersion++; @@ -35,7 +36,8 @@ public partial class CharacterPanel DiceRollDefinition = skill.DiceRollDefinition, SkillGroupId = skill.SkillGroupId?.ToString() ?? string.Empty, WildDice = skill.WildDice, - AllowFumble = skill.AllowFumble + AllowFumble = skill.AllowFumble, + FumbleRange = skill.FumbleRange }; EditSkillFormVersion++; @@ -99,6 +101,7 @@ public partial class CharacterPanel SkillGroupState.Model.DiceRollDefinition = string.Empty; SkillGroupState.Model.WildDice = IsD6 ? 1 : 0; SkillGroupState.Model.AllowFumble = IsD6; + SkillGroupState.Model.FumbleRange = null; SkillGroupState.ResetValidation(); ShowCreateSkillGroupModal = true; } @@ -110,6 +113,7 @@ public partial class CharacterPanel SkillGroupState.Model.DiceRollDefinition = skillGroup.DiceRollDefinition; SkillGroupState.Model.WildDice = skillGroup.WildDice; SkillGroupState.Model.AllowFumble = skillGroup.AllowFumble; + SkillGroupState.Model.FumbleRange = skillGroup.FumbleRange; SkillGroupState.ResetValidation(); ShowEditSkillGroupModal = true; } @@ -155,7 +159,8 @@ public partial class CharacterPanel SkillGroupState.Model.Name.Trim(), SkillGroupState.Model.DiceRollDefinition.Trim(), SkillGroupState.Model.WildDice, - SkillGroupState.Model.AllowFumble)); + SkillGroupState.Model.AllowFumble, + SkillGroupState.Model.FumbleRange)); CloseSkillGroupModals(); await SkillGroupCreated.InvokeAsync(createdGroup.Id); } @@ -202,7 +207,8 @@ public partial class CharacterPanel SkillGroupState.Model.Name.Trim(), SkillGroupState.Model.DiceRollDefinition.Trim(), SkillGroupState.Model.WildDice, - SkillGroupState.Model.AllowFumble)); + SkillGroupState.Model.AllowFumble, + SkillGroupState.Model.FumbleRange)); CloseSkillGroupModals(); await SkillGroupUpdated.InvokeAsync(updatedGroup.Id); } diff --git a/RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor.cs b/RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor.cs index de2f369..3de3427 100644 --- a/RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor.cs +++ b/RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor.cs @@ -18,6 +18,7 @@ public partial class SkillFormModal FormState.Model.SkillGroupId = InitialModel.SkillGroupId; FormState.Model.WildDice = InitialModel.WildDice; FormState.Model.AllowFumble = InitialModel.AllowFumble; + FormState.Model.FumbleRange = InitialModel.FumbleRange; FormState.ResetValidation(); AppliedFormVersion = FormVersion; PendingNameFocus = AutoFocusName; @@ -66,7 +67,7 @@ public partial class SkillFormModal SkillSummary skill; if (EditingSkillId.HasValue) { - skill = await ApiClient.RequestAsync("PUT", $"/api/skills/{EditingSkillId.Value}", new UpdateSkillRequest(FormState.Model.Name.Trim(), FormState.Model.DiceRollDefinition.Trim(), FormState.Model.WildDice, FormState.Model.AllowFumble, skillGroupId)); + skill = await ApiClient.RequestAsync("PUT", $"/api/skills/{EditingSkillId.Value}", new UpdateSkillRequest(FormState.Model.Name.Trim(), FormState.Model.DiceRollDefinition.Trim(), FormState.Model.WildDice, FormState.Model.AllowFumble, skillGroupId, FormState.Model.FumbleRange)); } else { @@ -76,7 +77,7 @@ public partial class SkillFormModal return; } - skill = await ApiClient.RequestAsync("POST", $"/api/characters/{SelectedCharacterId.Value}/skills", new CreateSkillRequest(FormState.Model.Name.Trim(), FormState.Model.DiceRollDefinition.Trim(), FormState.Model.WildDice, FormState.Model.AllowFumble, skillGroupId)); + skill = await ApiClient.RequestAsync("POST", $"/api/characters/{SelectedCharacterId.Value}/skills", new CreateSkillRequest(FormState.Model.Name.Trim(), FormState.Model.DiceRollDefinition.Trim(), FormState.Model.WildDice, FormState.Model.AllowFumble, skillGroupId, FormState.Model.FumbleRange)); } await SkillSaved.InvokeAsync(skill.Id); diff --git a/RpgRoller/Contracts/ApiContracts.cs b/RpgRoller/Contracts/ApiContracts.cs index a859331..9305f75 100644 --- a/RpgRoller/Contracts/ApiContracts.cs +++ b/RpgRoller/Contracts/ApiContracts.cs @@ -34,17 +34,17 @@ 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); +public sealed record CreateSkillRequest(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); +public sealed record UpdateSkillRequest(string Name, string DiceRollDefinition, int WildDice, bool AllowFumble, Guid? SkillGroupId = null, int? FumbleRange = null); -public sealed record SkillGroupSummary(Guid Id, Guid CharacterId, string Name, string DiceRollDefinition, int WildDice, bool AllowFumble); +public sealed record SkillGroupSummary(Guid Id, Guid CharacterId, string Name, string DiceRollDefinition, int WildDice, bool AllowFumble, int? FumbleRange); -public sealed record CreateSkillGroupRequest(string Name, string DiceRollDefinition, int WildDice, bool AllowFumble); +public sealed record CreateSkillGroupRequest(string Name, string DiceRollDefinition, int WildDice, bool AllowFumble, int? FumbleRange = null); -public sealed record UpdateSkillGroupRequest(string Name, string DiceRollDefinition, int WildDice, bool AllowFumble); +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); +public sealed record SkillSummary(Guid Id, Guid CharacterId, Guid? SkillGroupId, string Name, string DiceRollDefinition, int WildDice, bool AllowFumble, int? FumbleRange); public sealed record RollSkillRequest(string Visibility); @@ -52,9 +52,9 @@ public sealed record RollDieResult(int Roll, bool Crit, bool Fumble, bool Wild, public sealed record RollResult(Guid RollId, Guid CampaignId, Guid CharacterId, Guid SkillId, Guid RollerUserId, string Visibility, int Result, string Breakdown, IReadOnlyList Dice, DateTimeOffset TimestampUtc); -public sealed record CharacterSheetSkillGroup(Guid Id, string Name, string DiceRollDefinition, int WildDice, bool AllowFumble); +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); +public sealed record CharacterSheetSkill(Guid Id, Guid? SkillGroupId, string Name, string DiceRollDefinition, int WildDice, bool AllowFumble, int? FumbleRange); public sealed record CharacterSheet(Guid CharacterId, CharacterSheetSkillGroup[] SkillGroups, CharacterSheetSkill[] Skills); diff --git a/RpgRoller/Data/RpgRollerDbContext.cs b/RpgRoller/Data/RpgRollerDbContext.cs index 077e661..72cd927 100644 --- a/RpgRoller/Data/RpgRollerDbContext.cs +++ b/RpgRoller/Data/RpgRollerDbContext.cs @@ -54,6 +54,7 @@ public sealed class RpgRollerDbContext : DbContext entity.Property(x => x.DiceRollDefinition).IsRequired().HasMaxLength(128); entity.Property(x => x.WildDice).IsRequired(); entity.Property(x => x.AllowFumble).IsRequired(); + entity.Property(x => x.FumbleRange).IsRequired(false); entity.HasIndex(x => x.CharacterId); entity.HasIndex(x => x.SkillGroupId); }); @@ -65,6 +66,7 @@ public sealed class RpgRollerDbContext : DbContext entity.Property(x => x.DiceRollDefinition).IsRequired().HasMaxLength(128); entity.Property(x => x.WildDice).IsRequired(); entity.Property(x => x.AllowFumble).IsRequired(); + entity.Property(x => x.FumbleRange).IsRequired(false); entity.HasIndex(x => x.CharacterId); }); diff --git a/RpgRoller/Domain/GameModels.cs b/RpgRoller/Domain/GameModels.cs index 9319684..dd530a9 100644 --- a/RpgRoller/Domain/GameModels.cs +++ b/RpgRoller/Domain/GameModels.cs @@ -61,6 +61,7 @@ public sealed class SkillGroup public required string DiceRollDefinition { get; set; } public required int WildDice { get; set; } public required bool AllowFumble { get; set; } + public int? FumbleRange { get; set; } } public sealed class Skill @@ -72,6 +73,7 @@ public sealed class Skill public required string DiceRollDefinition { get; set; } public required int WildDice { get; set; } public required bool AllowFumble { get; set; } + public int? FumbleRange { get; set; } } public sealed class RollLogEntry diff --git a/RpgRoller/Migrations/20260402222501_AddRolemasterFumbleRange.Designer.cs b/RpgRoller/Migrations/20260402222501_AddRolemasterFumbleRange.Designer.cs new file mode 100644 index 0000000..a28e8df --- /dev/null +++ b/RpgRoller/Migrations/20260402222501_AddRolemasterFumbleRange.Designer.cs @@ -0,0 +1,264 @@ +// +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("20260402222501_AddRolemasterFumbleRange")] + partial class AddRolemasterFumbleRange + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.2"); + + modelBuilder.Entity("RpgRoller.Domain.Campaign", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("GmUserId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Ruleset") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GmUserId"); + + b.ToTable("Campaigns"); + }); + + modelBuilder.Entity("RpgRoller.Domain.Character", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CampaignId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CampaignId"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("Characters"); + }); + + modelBuilder.Entity("RpgRoller.Domain.RollLogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Breakdown") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("CampaignId") + .HasColumnType("TEXT"); + + b.Property("CharacterId") + .HasColumnType("TEXT"); + + b.Property("Dice") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Result") + .HasColumnType("INTEGER"); + + b.Property("RollerUserId") + .HasColumnType("TEXT"); + + b.Property("SkillId") + .HasColumnType("TEXT"); + + b.Property("TimestampUtc") + .HasColumnType("TEXT"); + + b.Property("Visibility") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CampaignId"); + + b.HasIndex("CharacterId"); + + b.HasIndex("RollerUserId"); + + b.HasIndex("SkillId"); + + b.ToTable("RollLogEntries"); + }); + + modelBuilder.Entity("RpgRoller.Domain.Skill", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AllowFumble") + .HasColumnType("INTEGER"); + + b.Property("CharacterId") + .HasColumnType("TEXT"); + + b.Property("DiceRollDefinition") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("FumbleRange") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("SkillGroupId") + .HasColumnType("TEXT"); + + b.Property("WildDice") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CharacterId"); + + b.HasIndex("SkillGroupId"); + + b.ToTable("Skills"); + }); + + modelBuilder.Entity("RpgRoller.Domain.SkillGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AllowFumble") + .HasColumnType("INTEGER"); + + b.Property("CharacterId") + .HasColumnType("TEXT"); + + b.Property("DiceRollDefinition") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("FumbleRange") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("WildDice") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CharacterId"); + + b.ToTable("SkillGroups"); + }); + + modelBuilder.Entity("RpgRoller.Domain.UserAccount", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ActiveCharacterId") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Roles") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UsernameNormalized") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UsernameNormalized") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("RpgRoller.Domain.UserSession", b => + { + b.Property("Token") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Token"); + + b.HasIndex("UserId"); + + b.ToTable("Sessions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/RpgRoller/Migrations/20260402222501_AddRolemasterFumbleRange.cs b/RpgRoller/Migrations/20260402222501_AddRolemasterFumbleRange.cs new file mode 100644 index 0000000..337fb35 --- /dev/null +++ b/RpgRoller/Migrations/20260402222501_AddRolemasterFumbleRange.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace RpgRoller.Migrations +{ + /// + public partial class AddRolemasterFumbleRange : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "FumbleRange", + table: "Skills", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + name: "FumbleRange", + table: "SkillGroups", + type: "INTEGER", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "FumbleRange", + table: "Skills"); + + migrationBuilder.DropColumn( + name: "FumbleRange", + table: "SkillGroups"); + } + } +} diff --git a/RpgRoller/Migrations/RpgRollerDbContextModelSnapshot.cs b/RpgRoller/Migrations/RpgRollerDbContextModelSnapshot.cs index d36cb19..af25f4b 100644 --- a/RpgRoller/Migrations/RpgRollerDbContextModelSnapshot.cs +++ b/RpgRoller/Migrations/RpgRollerDbContextModelSnapshot.cs @@ -138,6 +138,9 @@ namespace RpgRoller.Migrations .HasMaxLength(128) .HasColumnType("TEXT"); + b.Property("FumbleRange") + .HasColumnType("INTEGER"); + b.Property("Name") .IsRequired() .HasMaxLength(128) @@ -175,6 +178,9 @@ namespace RpgRoller.Migrations .HasMaxLength(128) .HasColumnType("TEXT"); + b.Property("FumbleRange") + .HasColumnType("INTEGER"); + b.Property("Name") .IsRequired() .HasMaxLength(128) diff --git a/RpgRoller/Services/GameService.cs b/RpgRoller/Services/GameService.cs index d302315..68d4430 100644 --- a/RpgRoller/Services/GameService.cs +++ b/RpgRoller/Services/GameService.cs @@ -496,7 +496,7 @@ public sealed class GameService : IGameService } } - public ServiceResult CreateSkillGroup(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble) + public ServiceResult CreateSkillGroup(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, int? fumbleRange = null) { if (string.IsNullOrWhiteSpace(name)) return ServiceResult.Failure("invalid_skill_group_name", "Skill group name is required."); @@ -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); + var prototypeValidation = ValidateSkillDefinition(campaign.Ruleset, diceRollDefinition, wildDice, allowFumble, fumbleRange); if (!prototypeValidation.Succeeded) return ServiceResult.Failure(prototypeValidation.Error!.Code, prototypeValidation.Error.Message); @@ -527,7 +527,8 @@ public sealed class GameService : IGameService Name = name.Trim(), DiceRollDefinition = prototypeValidation.Value!.CanonicalExpression, WildDice = prototypeValidation.Value.WildDice, - AllowFumble = prototypeValidation.Value.AllowFumble + AllowFumble = prototypeValidation.Value.AllowFumble, + FumbleRange = prototypeValidation.Value.FumbleRange }; m_SkillGroupsById[group.Id] = group; @@ -538,7 +539,7 @@ public sealed class GameService : IGameService } } - public ServiceResult UpdateSkillGroup(string sessionToken, Guid skillGroupId, string name, string diceRollDefinition, int wildDice, bool allowFumble) + public ServiceResult UpdateSkillGroup(string sessionToken, Guid skillGroupId, string name, string diceRollDefinition, int wildDice, bool allowFumble, int? fumbleRange = null) { if (string.IsNullOrWhiteSpace(name)) return ServiceResult.Failure("invalid_skill_group_name", "Skill group name is required."); @@ -559,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); + var prototypeValidation = ValidateSkillDefinition(campaign.Ruleset, diceRollDefinition, wildDice, allowFumble, fumbleRange); if (!prototypeValidation.Succeeded) return ServiceResult.Failure(prototypeValidation.Error!.Code, prototypeValidation.Error.Message); @@ -567,6 +568,7 @@ public sealed class GameService : IGameService group.DiceRollDefinition = prototypeValidation.Value!.CanonicalExpression; group.WildDice = prototypeValidation.Value.WildDice; group.AllowFumble = prototypeValidation.Value.AllowFumble; + group.FumbleRange = prototypeValidation.Value.FumbleRange; TouchCharacterLocked(campaign.Id, character.Id); PersistStateLocked(); @@ -603,7 +605,7 @@ public sealed class GameService : IGameService } } - public ServiceResult CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null) + public ServiceResult CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null) { if (string.IsNullOrWhiteSpace(name)) return ServiceResult.Failure("invalid_skill_name", "Skill name is required."); @@ -623,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); + var skillValidation = ValidateSkillDefinition(campaign.Ruleset, diceRollDefinition, wildDice, allowFumble, fumbleRange); if (!skillValidation.Succeeded) return ServiceResult.Failure(skillValidation.Error!.Code, skillValidation.Error.Message); @@ -639,7 +641,8 @@ public sealed class GameService : IGameService Name = name.Trim(), DiceRollDefinition = skillValidation.Value!.CanonicalExpression, WildDice = skillValidation.Value.WildDice, - AllowFumble = skillValidation.Value.AllowFumble + AllowFumble = skillValidation.Value.AllowFumble, + FumbleRange = skillValidation.Value.FumbleRange }; m_SkillsById[skill.Id] = skill; @@ -650,7 +653,7 @@ public sealed class GameService : IGameService } } - public ServiceResult UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null) + public ServiceResult UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null) { if (string.IsNullOrWhiteSpace(name)) return ServiceResult.Failure("invalid_skill_name", "Skill name is required."); @@ -671,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); + var skillValidation = ValidateSkillDefinition(campaign.Ruleset, diceRollDefinition, wildDice, allowFumble, fumbleRange); if (!skillValidation.Succeeded) return ServiceResult.Failure(skillValidation.Error!.Code, skillValidation.Error.Message); @@ -683,6 +686,7 @@ public sealed class GameService : IGameService skill.DiceRollDefinition = skillValidation.Value!.CanonicalExpression; skill.WildDice = skillValidation.Value.WildDice; skill.AllowFumble = skillValidation.Value.AllowFumble; + skill.FumbleRange = skillValidation.Value.FumbleRange; skill.SkillGroupId = resolvedSkillGroupId.Value; TouchCharacterLocked(campaign.Id, character.Id); @@ -876,33 +880,64 @@ public sealed class GameService : IGameService } } - private static ServiceResult<(string CanonicalExpression, int WildDice, bool AllowFumble)> ValidateSkillDefinition(RulesetKind ruleset, string diceRollDefinition, int wildDice, bool allowFumble) + 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)>.Failure(expressionValidation.Error!.Code, expressionValidation.Error.Message); + return ServiceResult<(string CanonicalExpression, int WildDice, bool AllowFumble, int? FumbleRange)>.Failure(expressionValidation.Error!.Code, expressionValidation.Error.Message); - var optionsValidation = ValidateSkillOptions(ruleset, wildDice, allowFumble); + var optionsValidation = ValidateSkillOptions(ruleset, expressionValidation.Value!, wildDice, allowFumble, fumbleRange); if (!optionsValidation.Succeeded) - return ServiceResult<(string CanonicalExpression, int WildDice, bool AllowFumble)>.Failure(optionsValidation.Error!.Code, optionsValidation.Error.Message); + 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)>.Success((expressionValidation.Value!.Canonical, optionsValidation.Value!.WildDice, optionsValidation.Value.AllowFumble)); + 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)> ValidateSkillOptions(RulesetKind ruleset, int wildDice, bool allowFumble) + 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)>.Failure("invalid_wild_dice", "Wild dice must be between 0 and 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)>.Failure("invalid_wild_dice", "D6 skills require at least one wild die."); + 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)>.Success((wildDice, allowFumble)); + 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)); } - return ServiceResult<(int WildDice, bool AllowFumble)>.Success((0, 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."); + + 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, Skill skill) @@ -1139,22 +1174,22 @@ public sealed class GameService : IGameService private static CharacterSheetSkillGroup ToCharacterSheetSkillGroup(SkillGroup skillGroup) { - return new(skillGroup.Id, skillGroup.Name, skillGroup.DiceRollDefinition, skillGroup.WildDice, skillGroup.AllowFumble); + return new(skillGroup.Id, skillGroup.Name, skillGroup.DiceRollDefinition, skillGroup.WildDice, skillGroup.AllowFumble, skillGroup.FumbleRange); } private static SkillGroupSummary ToSkillGroupSummary(SkillGroup skillGroup) { - return new(skillGroup.Id, skillGroup.CharacterId, skillGroup.Name, skillGroup.DiceRollDefinition, skillGroup.WildDice, skillGroup.AllowFumble); + return new(skillGroup.Id, skillGroup.CharacterId, skillGroup.Name, skillGroup.DiceRollDefinition, skillGroup.WildDice, skillGroup.AllowFumble, skillGroup.FumbleRange); } private static CharacterSheetSkill ToCharacterSheetSkill(Skill skill) { - return new(skill.Id, skill.SkillGroupId, skill.Name, skill.DiceRollDefinition, skill.WildDice, skill.AllowFumble); + return new(skill.Id, skill.SkillGroupId, skill.Name, skill.DiceRollDefinition, skill.WildDice, skill.AllowFumble, skill.FumbleRange); } private static SkillSummary ToSkillSummary(Skill skill) { - return new(skill.Id, skill.CharacterId, skill.SkillGroupId, skill.Name, skill.DiceRollDefinition, skill.WildDice, skill.AllowFumble); + return new(skill.Id, skill.CharacterId, skill.SkillGroupId, skill.Name, skill.DiceRollDefinition, skill.WildDice, skill.AllowFumble, skill.FumbleRange); } private static RollResult ToRollResult(RollLogEntry entry, IReadOnlyList dice) @@ -1658,7 +1693,8 @@ public sealed class GameService : IGameService Name = skill.Name, DiceRollDefinition = skill.DiceRollDefinition, WildDice = skill.WildDice, - AllowFumble = skill.AllowFumble + AllowFumble = skill.AllowFumble, + FumbleRange = skill.FumbleRange }; } @@ -1671,7 +1707,8 @@ public sealed class GameService : IGameService Name = skillGroup.Name, DiceRollDefinition = skillGroup.DiceRollDefinition, WildDice = skillGroup.WildDice, - AllowFumble = skillGroup.AllowFumble + AllowFumble = skillGroup.AllowFumble, + FumbleRange = skillGroup.FumbleRange }; } diff --git a/RpgRoller/Services/IGameService.cs b/RpgRoller/Services/IGameService.cs index 078241e..889f79f 100644 --- a/RpgRoller/Services/IGameService.cs +++ b/RpgRoller/Services/IGameService.cs @@ -28,11 +28,11 @@ public interface IGameService ServiceResult ActivateCharacter(string sessionToken, Guid characterId); ServiceResult> GetOwnCharacters(string sessionToken); - ServiceResult CreateSkillGroup(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble); - ServiceResult UpdateSkillGroup(string sessionToken, Guid skillGroupId, string name, string diceRollDefinition, int wildDice, bool allowFumble); + ServiceResult CreateSkillGroup(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, int? fumbleRange = null); + ServiceResult UpdateSkillGroup(string sessionToken, Guid skillGroupId, string name, string diceRollDefinition, int wildDice, bool allowFumble, int? fumbleRange = null); ServiceResult DeleteSkillGroup(string sessionToken, Guid skillGroupId); - ServiceResult CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null); - ServiceResult UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null); + ServiceResult CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null); + ServiceResult UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null); ServiceResult DeleteSkill(string sessionToken, Guid skillId); ServiceResult GetCharacterSheet(string sessionToken, Guid characterId); diff --git a/openapi/RpgRoller.json b/openapi/RpgRoller.json index fd35ed1..f0ad162 100644 --- a/openapi/RpgRoller.json +++ b/openapi/RpgRoller.json @@ -900,6 +900,16 @@ }, "allowFumble": { "type": "boolean" + }, + "skillGroupId": { + "type": "string", + "format": "uuid", + "nullable": true + }, + "fumbleRange": { + "type": "integer", + "format": "int32", + "nullable": true } }, "required": [ @@ -924,6 +934,16 @@ }, "allowFumble": { "type": "boolean" + }, + "skillGroupId": { + "type": "string", + "format": "uuid", + "nullable": true + }, + "fumbleRange": { + "type": "integer", + "format": "int32", + "nullable": true } }, "required": [ @@ -944,6 +964,11 @@ "type": "string", "format": "uuid" }, + "skillGroupId": { + "type": "string", + "format": "uuid", + "nullable": true + }, "name": { "type": "string" }, @@ -956,6 +981,11 @@ }, "allowFumble": { "type": "boolean" + }, + "fumbleRange": { + "type": "integer", + "format": "int32", + "nullable": true } }, "required": [