diff --git a/README.md b/README.md index 65808f4..e941361 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,8 @@ Backend state persistence: Gameplay capabilities now include: -- Skill groups per character (create, rename, and assign/reassign skills to groups) +- Skill groups per character with skill prototypes (create/edit/delete groups, assign/reassign skills, and prefill new skill forms from group defaults) +- Skill and skill-group deletion flows - GM-driven character owner transfer within campaign management flows ## Prerequisites diff --git a/RpgRoller.Tests/Api/CampaignApiTests.cs b/RpgRoller.Tests/Api/CampaignApiTests.cs index e11d062..4b5196a 100644 --- a/RpgRoller.Tests/Api/CampaignApiTests.cs +++ b/RpgRoller.Tests/Api/CampaignApiTests.cs @@ -73,9 +73,12 @@ public sealed class CampaignApiTests : ApiTestBase var campaign = await PostAsync(gmClient, "/api/campaigns", new("Grouped Campaign", "d6")); var character = await PostAsync(ownerClient, "/api/characters", new("Grouped Hero", campaign.Id)); - var createdGroup = await PostAsync(ownerClient, $"/api/characters/{character.Id}/skill-groups", new("Combat")); - var renamedGroup = await PutAsync(gmClient, $"/api/skill-groups/{createdGroup.Id}", new("Battle")); + var createdGroup = await PostAsync(ownerClient, $"/api/characters/{character.Id}/skill-groups", new("Combat", "2D+1", 1, true)); + var renamedGroup = await PutAsync(gmClient, $"/api/skill-groups/{createdGroup.Id}", new("Battle", "3D+2", 2, false)); Assert.Equal("Battle", renamedGroup.Name); + Assert.Equal("3D+2", renamedGroup.DiceRollDefinition); + Assert.Equal(2, renamedGroup.WildDice); + Assert.False(renamedGroup.AllowFumble); var groupedSkill = await PostAsync(ownerClient, $"/api/characters/{character.Id}/skills", new("Strike", "2D+1", 1, true, renamedGroup.Id)); Assert.Equal(renamedGroup.Id, groupedSkill.SkillGroupId); @@ -83,6 +86,15 @@ public sealed class CampaignApiTests : ApiTestBase var ungroupedSkill = await PutAsync(ownerClient, $"/api/skills/{groupedSkill.Id}", new("Strike", "2D+1", 1, true, null)); Assert.Null(ungroupedSkill.SkillGroupId); + var groupedAgainSkill = await PutAsync(ownerClient, $"/api/skills/{groupedSkill.Id}", new("Strike", "2D+1", 1, true, renamedGroup.Id)); + Assert.Equal(renamedGroup.Id, groupedAgainSkill.SkillGroupId); + + var deleteSkill = await ownerClient.DeleteAsync($"/api/skills/{groupedAgainSkill.Id}"); + Assert.Equal(HttpStatusCode.OK, deleteSkill.StatusCode); + + var deleteGroup = await ownerClient.DeleteAsync($"/api/skill-groups/{renamedGroup.Id}"); + Assert.Equal(HttpStatusCode.OK, deleteGroup.StatusCode); + var transferResult = await PutAsync(gmClient, $"/api/characters/{character.Id}", new("Grouped Hero", campaign.Id, "receiver2")); Assert.Equal("Grouped Hero", transferResult.Name); @@ -93,6 +105,6 @@ public sealed class CampaignApiTests : ApiTestBase Assert.Equal(HttpStatusCode.OK, receiverActivate.StatusCode); var details = await GetAsync(gmClient, $"/api/campaigns/{campaign.Id}"); - Assert.Contains(details.SkillGroups, group => group.Id == renamedGroup.Id); + Assert.DoesNotContain(details.SkillGroups, group => group.Id == renamedGroup.Id); } } diff --git a/RpgRoller.Tests/Services/ServiceSkillGroupAndOwnershipTests.cs b/RpgRoller.Tests/Services/ServiceSkillGroupAndOwnershipTests.cs index 4ce155a..bd0e084 100644 --- a/RpgRoller.Tests/Services/ServiceSkillGroupAndOwnershipTests.cs +++ b/RpgRoller.Tests/Services/ServiceSkillGroupAndOwnershipTests.cs @@ -20,25 +20,41 @@ public sealed class ServiceSkillGroupAndOwnershipTests var ownerCharacter = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Owner Char", campaign.Id)); var otherCharacter = ServiceTestSupport.GetValue(service.CreateCharacter(otherSession, "Other Char", campaign.Id)); - var ownerGroup = ServiceTestSupport.GetValue(service.CreateSkillGroup(ownerSession, ownerCharacter.Id, "Combat")); - Assert.False(service.CreateSkillGroup(ownerSession, otherCharacter.Id, "Not Allowed").Succeeded); + var ownerGroup = ServiceTestSupport.GetValue(service.CreateSkillGroup(ownerSession, ownerCharacter.Id, "Combat", "2D+1", 1, true)); + Assert.False(service.CreateSkillGroup(ownerSession, otherCharacter.Id, "Not Allowed", "2D+1", 1, true).Succeeded); - Assert.False(service.UpdateSkillGroup(otherSession, ownerGroup.Id, "Renamed by Other").Succeeded); - var renamedGroup = ServiceTestSupport.GetValue(service.UpdateSkillGroup(gmSession, ownerGroup.Id, "Battle")); + Assert.False(service.UpdateSkillGroup(otherSession, ownerGroup.Id, "Renamed by Other", "2D+1", 1, true).Succeeded); + var renamedGroup = ServiceTestSupport.GetValue(service.UpdateSkillGroup(gmSession, ownerGroup.Id, "Battle", "3D+2", 2, false)); Assert.Equal("Battle", renamedGroup.Name); + Assert.Equal("3D+2", renamedGroup.DiceRollDefinition); + Assert.Equal(2, renamedGroup.WildDice); + Assert.False(renamedGroup.AllowFumble); var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, ownerCharacter.Id, "Strike", "2D+1", 1, true, renamedGroup.Id)); Assert.Equal(renamedGroup.Id, skill.SkillGroupId); - var otherGroup = ServiceTestSupport.GetValue(service.CreateSkillGroup(otherSession, otherCharacter.Id, "Other Group")); + var otherGroup = ServiceTestSupport.GetValue(service.CreateSkillGroup(otherSession, otherCharacter.Id, "Other Group", "2D+1", 1, true)); Assert.False(service.UpdateSkill(ownerSession, skill.Id, "Strike", "2D+1", 1, true, otherGroup.Id).Succeeded); var ungroupedSkill = ServiceTestSupport.GetValue(service.UpdateSkill(ownerSession, skill.Id, "Strike", "2D+1", 1, true, null)); Assert.Null(ungroupedSkill.SkillGroupId); + var regroupedSkill = ServiceTestSupport.GetValue(service.UpdateSkill(ownerSession, skill.Id, "Strike", "2D+1", 1, true, renamedGroup.Id)); + Assert.Equal(renamedGroup.Id, regroupedSkill.SkillGroupId); + + var deletedGroup = ServiceTestSupport.GetValue(service.DeleteSkillGroup(ownerSession, renamedGroup.Id)); + Assert.True(deletedGroup); + + var afterGroupDelete = ServiceTestSupport.GetValue(service.GetCampaign(ownerSession, campaign.Id)); + Assert.Empty(afterGroupDelete.SkillGroups); + Assert.Null(afterGroupDelete.Skills.Single(s => s.Id == regroupedSkill.Id).SkillGroupId); + + var deletedSkill = ServiceTestSupport.GetValue(service.DeleteSkill(ownerSession, regroupedSkill.Id)); + Assert.True(deletedSkill); + var ownerView = ServiceTestSupport.GetValue(service.GetCampaign(ownerSession, campaign.Id)); - Assert.Single(ownerView.SkillGroups); - Assert.Equal(ownerCharacter.Id, ownerView.SkillGroups[0].CharacterId); + Assert.Empty(ownerView.SkillGroups); + Assert.Empty(ownerView.Skills); } [Fact] diff --git a/RpgRoller/Api/SkillEndpoints.cs b/RpgRoller/Api/SkillEndpoints.cs index f3a7fb1..782ad68 100644 --- a/RpgRoller/Api/SkillEndpoints.cs +++ b/RpgRoller/Api/SkillEndpoints.cs @@ -19,15 +19,27 @@ internal static class SkillEndpoints return ApiResultMapper.ToApiResult(result); }); + group.MapDelete("/skills/{skillId:guid}", (Guid skillId, HttpContext context, IGameService game) => + { + var result = game.DeleteSkill(context.GetRequiredSessionToken(), skillId); + return ApiResultMapper.ToApiResult(result); + }); + group.MapPost("/characters/{characterId:guid}/skill-groups", (Guid characterId, CreateSkillGroupRequest request, HttpContext context, IGameService game) => { - var result = game.CreateSkillGroup(context.GetRequiredSessionToken(), characterId, request.Name); + var result = game.CreateSkillGroup(context.GetRequiredSessionToken(), characterId, request.Name, request.DiceRollDefinition, request.WildDice, request.AllowFumble); 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); + var result = game.UpdateSkillGroup(context.GetRequiredSessionToken(), skillGroupId, request.Name, request.DiceRollDefinition, request.WildDice, request.AllowFumble); + return ApiResultMapper.ToApiResult(result); + }); + + group.MapDelete("/skill-groups/{skillGroupId:guid}", (Guid skillGroupId, HttpContext context, IGameService game) => + { + var result = game.DeleteSkillGroup(context.GetRequiredSessionToken(), skillGroupId); return ApiResultMapper.ToApiResult(result); }); diff --git a/RpgRoller/Components/Pages/Home.Models.cs b/RpgRoller/Components/Pages/Home.Models.cs index dbb24b9..cfd5d9f 100644 --- a/RpgRoller/Components/Pages/Home.Models.cs +++ b/RpgRoller/Components/Pages/Home.Models.cs @@ -51,6 +51,9 @@ public sealed class SkillFormModel public sealed class SkillGroupFormModel { public string Name { get; set; } = string.Empty; + public string DiceRollDefinition { get; set; } = string.Empty; + public int WildDice { get; set; } + public bool AllowFumble { get; set; } } public enum HomeViewMode diff --git a/RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor b/RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor index 11c1082..07043dc 100644 --- a/RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor +++ b/RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor @@ -45,14 +45,6 @@ class="muted">| Owner: @OwnerLabel(SelectedCharacter.OwnerUserId) | Campaign: @SelectedCampaign.Name
- + @if (SkillGroupState.Errors.TryGetValue("diceRollDefinition", out var expressionError)) + { +

@expressionError

+ } + + @if (IsD6) + { + + + @if (SkillGroupState.Errors.TryGetValue("wildDice", out var wildDiceError)) + { +

@wildDiceError

+ } + + + + } + @if (SkillGroupState.Errors.TryGetValue("character", out var characterError)) {

@characterError

} + @if (SkillGroupState.Errors.TryGetValue("group", out var groupError)) + { +

@groupError

+ } +
diff --git a/RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor.cs b/RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor.cs index 3daa06f..0907263 100644 --- a/RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor.cs +++ b/RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor.cs @@ -7,15 +7,19 @@ namespace RpgRoller.Components.Pages.HomeControls; [ExcludeFromCodeCoverage] public partial class CharacterPanel { - private void OpenCreateSkillModal() + private void OpenCreateSkillModal(Guid? skillGroupId = null) { + var selectedGroup = skillGroupId.HasValue + ? SelectedCharacterSkillGroups.FirstOrDefault(group => group.Id == skillGroupId.Value) + : null; + CreateSkillInitialModel = new() { Name = string.Empty, - DiceRollDefinition = string.Empty, - SkillGroupId = string.Empty, - WildDice = IsD6 ? 1 : 0, - AllowFumble = IsD6 + DiceRollDefinition = selectedGroup?.DiceRollDefinition ?? string.Empty, + SkillGroupId = selectedGroup?.Id.ToString() ?? string.Empty, + WildDice = selectedGroup?.WildDice ?? (IsD6 ? 1 : 0), + AllowFumble = selectedGroup?.AllowFumble ?? IsD6 }; CreateSkillFormVersion++; @@ -71,6 +75,9 @@ public partial class CharacterPanel private void OpenCreateSkillGroupModal() { SkillGroupState.Model.Name = string.Empty; + SkillGroupState.Model.DiceRollDefinition = string.Empty; + SkillGroupState.Model.WildDice = IsD6 ? 1 : 0; + SkillGroupState.Model.AllowFumble = IsD6; SkillGroupState.ResetValidation(); ShowCreateSkillGroupModal = true; } @@ -79,6 +86,9 @@ public partial class CharacterPanel { EditingSkillGroupId = skillGroup.Id; SkillGroupState.Model.Name = skillGroup.Name; + SkillGroupState.Model.DiceRollDefinition = skillGroup.DiceRollDefinition; + SkillGroupState.Model.WildDice = skillGroup.WildDice; + SkillGroupState.Model.AllowFumble = skillGroup.AllowFumble; SkillGroupState.ResetValidation(); ShowEditSkillGroupModal = true; } @@ -98,6 +108,12 @@ public partial class CharacterPanel if (string.IsNullOrWhiteSpace(SkillGroupState.Model.Name)) SkillGroupState.Errors["name"] = "Skill group name is required."; + if (string.IsNullOrWhiteSpace(SkillGroupState.Model.DiceRollDefinition)) + SkillGroupState.Errors["diceRollDefinition"] = "Expression is required."; + + if (IsD6 && SkillGroupState.Model.WildDice < 1) + SkillGroupState.Errors["wildDice"] = "D6 groups require at least one wild die."; + if (!SelectedCharacterId.HasValue) SkillGroupState.Errors["character"] = "Select a character first."; @@ -111,7 +127,14 @@ public partial class CharacterPanel try { var selectedCharacterId = SelectedCharacterId!.Value; - var createdGroup = await ApiClient.RequestAsync("POST", $"/api/characters/{selectedCharacterId}/skill-groups", new CreateSkillGroupRequest(SkillGroupState.Model.Name.Trim())); + var createdGroup = await ApiClient.RequestAsync( + "POST", + $"/api/characters/{selectedCharacterId}/skill-groups", + new CreateSkillGroupRequest( + SkillGroupState.Model.Name.Trim(), + SkillGroupState.Model.DiceRollDefinition.Trim(), + SkillGroupState.Model.WildDice, + SkillGroupState.Model.AllowFumble)); CloseSkillGroupModals(); await SkillGroupCreated.InvokeAsync(createdGroup.Id); } @@ -132,6 +155,12 @@ public partial class CharacterPanel if (string.IsNullOrWhiteSpace(SkillGroupState.Model.Name)) SkillGroupState.Errors["name"] = "Skill group name is required."; + if (string.IsNullOrWhiteSpace(SkillGroupState.Model.DiceRollDefinition)) + SkillGroupState.Errors["diceRollDefinition"] = "Expression is required."; + + if (IsD6 && SkillGroupState.Model.WildDice < 1) + SkillGroupState.Errors["wildDice"] = "D6 groups require at least one wild die."; + if (!EditingSkillGroupId.HasValue) SkillGroupState.Errors["group"] = "Select a skill group first."; @@ -145,7 +174,14 @@ public partial class CharacterPanel try { var editingSkillGroupId = EditingSkillGroupId!.Value; - var updatedGroup = await ApiClient.RequestAsync("PUT", $"/api/skill-groups/{editingSkillGroupId}", new UpdateSkillGroupRequest(SkillGroupState.Model.Name.Trim())); + var updatedGroup = await ApiClient.RequestAsync( + "PUT", + $"/api/skill-groups/{editingSkillGroupId}", + new UpdateSkillGroupRequest( + SkillGroupState.Model.Name.Trim(), + SkillGroupState.Model.DiceRollDefinition.Trim(), + SkillGroupState.Model.WildDice, + SkillGroupState.Model.AllowFumble)); CloseSkillGroupModals(); await SkillGroupUpdated.InvokeAsync(updatedGroup.Id); } @@ -159,6 +195,32 @@ public partial class CharacterPanel } } + private async Task DeleteSkillAsync(Guid skillId) + { + try + { + await ApiClient.RequestAsync("DELETE", $"/api/skills/{skillId}"); + await SkillDeleted.InvokeAsync(skillId); + } + catch (ApiRequestException ex) + { + await ErrorOccurred.InvokeAsync(ex.Message); + } + } + + private async Task DeleteSkillGroupAsync(Guid skillGroupId) + { + try + { + await ApiClient.RequestAsync("DELETE", $"/api/skill-groups/{skillGroupId}"); + await SkillGroupDeleted.InvokeAsync(skillGroupId); + } + catch (ApiRequestException ex) + { + await ErrorOccurred.InvokeAsync(ex.Message); + } + } + private static string InitialsFor(string value) { var words = value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); @@ -250,6 +312,15 @@ public partial class CharacterPanel [Parameter] public EventCallback SkillGroupUpdated { get; set; } + [Parameter] + public EventCallback SkillDeleted { get; set; } + + [Parameter] + public EventCallback SkillGroupDeleted { get; set; } + + [Parameter] + public EventCallback ErrorOccurred { get; set; } + [Parameter] public EventCallback RollRequested { get; set; } } diff --git a/RpgRoller/Components/Pages/Workspace.razor b/RpgRoller/Components/Pages/Workspace.razor index 572eb46..517183f 100644 --- a/RpgRoller/Components/Pages/Workspace.razor +++ b/RpgRoller/Components/Pages/Workspace.razor @@ -85,6 +85,9 @@ SkillUpdated="OnSkillUpdatedAsync" SkillGroupCreated="OnSkillGroupCreatedAsync" SkillGroupUpdated="OnSkillGroupUpdatedAsync" + SkillDeleted="OnSkillDeletedAsync" + SkillGroupDeleted="OnSkillGroupDeletedAsync" + ErrorOccurred="OnCharacterPanelErrorAsync" RollRequested="RollSkillAsync"/> x.Id); entity.Property(x => x.Name).IsRequired().HasMaxLength(128); + entity.Property(x => x.DiceRollDefinition).IsRequired().HasMaxLength(128); + entity.Property(x => x.WildDice).IsRequired(); + entity.Property(x => x.AllowFumble).IsRequired(); entity.HasIndex(x => x.CharacterId); }); diff --git a/RpgRoller/Domain/GameModels.cs b/RpgRoller/Domain/GameModels.cs index c9a420e..6e6c5ac 100644 --- a/RpgRoller/Domain/GameModels.cs +++ b/RpgRoller/Domain/GameModels.cs @@ -51,6 +51,9 @@ public sealed class SkillGroup public required Guid Id { get; init; } public required Guid CharacterId { get; set; } public required string Name { get; set; } + public required string DiceRollDefinition { get; set; } + public required int WildDice { get; set; } + public required bool AllowFumble { get; set; } } public sealed class Skill diff --git a/RpgRoller/Migrations/20260226131003_AddSkillGroupPrototypes.Designer.cs b/RpgRoller/Migrations/20260226131003_AddSkillGroupPrototypes.Designer.cs new file mode 100644 index 0000000..e579268 --- /dev/null +++ b/RpgRoller/Migrations/20260226131003_AddSkillGroupPrototypes.Designer.cs @@ -0,0 +1,253 @@ +// +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("20260226131003_AddSkillGroupPrototypes")] + partial class AddSkillGroupPrototypes + { + /// + 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("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("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("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/20260226131003_AddSkillGroupPrototypes.cs b/RpgRoller/Migrations/20260226131003_AddSkillGroupPrototypes.cs new file mode 100644 index 0000000..110fd13 --- /dev/null +++ b/RpgRoller/Migrations/20260226131003_AddSkillGroupPrototypes.cs @@ -0,0 +1,87 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace RpgRoller.Migrations +{ + /// + public partial class AddSkillGroupPrototypes : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AllowFumble", + table: "SkillGroups", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "DiceRollDefinition", + table: "SkillGroups", + type: "TEXT", + maxLength: 128, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "WildDice", + table: "SkillGroups", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.Sql(""" + UPDATE SkillGroups + SET DiceRollDefinition = CASE + WHEN EXISTS ( + SELECT 1 + FROM Characters c + INNER JOIN Campaigns cp ON cp.Id = c.CampaignId + WHERE c.Id = SkillGroups.CharacterId + AND cp.Ruleset = 'D6') + THEN '1D' + ELSE '1d20' + END, + WildDice = CASE + WHEN EXISTS ( + SELECT 1 + FROM Characters c + INNER JOIN Campaigns cp ON cp.Id = c.CampaignId + WHERE c.Id = SkillGroups.CharacterId + AND cp.Ruleset = 'D6') + THEN 1 + ELSE 0 + END, + AllowFumble = CASE + WHEN EXISTS ( + SELECT 1 + FROM Characters c + INNER JOIN Campaigns cp ON cp.Id = c.CampaignId + WHERE c.Id = SkillGroups.CharacterId + AND cp.Ruleset = 'D6') + THEN 1 + ELSE 0 + END + WHERE DiceRollDefinition = ''; + """); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "AllowFumble", + table: "SkillGroups"); + + migrationBuilder.DropColumn( + name: "DiceRollDefinition", + table: "SkillGroups"); + + migrationBuilder.DropColumn( + name: "WildDice", + table: "SkillGroups"); + } + } +} diff --git a/RpgRoller/Migrations/RpgRollerDbContextModelSnapshot.cs b/RpgRoller/Migrations/RpgRollerDbContextModelSnapshot.cs index 17d2102..e5e756e 100644 --- a/RpgRoller/Migrations/RpgRollerDbContextModelSnapshot.cs +++ b/RpgRoller/Migrations/RpgRollerDbContextModelSnapshot.cs @@ -164,14 +164,25 @@ namespace RpgRoller.Migrations .ValueGeneratedOnAdd() .HasColumnType("TEXT"); + b.Property("AllowFumble") + .HasColumnType("INTEGER"); + b.Property("CharacterId") .HasColumnType("TEXT"); + b.Property("DiceRollDefinition") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + b.Property("Name") .IsRequired() .HasMaxLength(128) .HasColumnType("TEXT"); + b.Property("WildDice") + .HasColumnType("INTEGER"); + b.HasKey("Id"); b.HasIndex("CharacterId"); diff --git a/RpgRoller/Services/GameService.cs b/RpgRoller/Services/GameService.cs index 150b16f..a51306a 100644 --- a/RpgRoller/Services/GameService.cs +++ b/RpgRoller/Services/GameService.cs @@ -322,7 +322,7 @@ public sealed class GameService : IGameService } } - public ServiceResult CreateSkillGroup(string sessionToken, Guid characterId, string name) + public ServiceResult CreateSkillGroup(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble) { if (string.IsNullOrWhiteSpace(name)) return ServiceResult.Failure("invalid_skill_group_name", "Skill group name is required."); @@ -340,11 +340,18 @@ 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); + if (!prototypeValidation.Succeeded) + return ServiceResult.Failure(prototypeValidation.Error!.Code, prototypeValidation.Error.Message); + var group = new SkillGroup { Id = Guid.NewGuid(), CharacterId = character.Id, - Name = name.Trim() + Name = name.Trim(), + DiceRollDefinition = prototypeValidation.Value!.CanonicalExpression, + WildDice = prototypeValidation.Value.WildDice, + AllowFumble = prototypeValidation.Value.AllowFumble }; m_SkillGroupsById[group.Id] = group; @@ -355,7 +362,7 @@ public sealed class GameService : IGameService } } - public ServiceResult UpdateSkillGroup(string sessionToken, Guid skillGroupId, string name) + public ServiceResult UpdateSkillGroup(string sessionToken, Guid skillGroupId, string name, string diceRollDefinition, int wildDice, bool allowFumble) { if (string.IsNullOrWhiteSpace(name)) return ServiceResult.Failure("invalid_skill_group_name", "Skill group name is required."); @@ -374,7 +381,14 @@ 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); + if (!prototypeValidation.Succeeded) + return ServiceResult.Failure(prototypeValidation.Error!.Code, prototypeValidation.Error.Message); + group.Name = name.Trim(); + group.DiceRollDefinition = prototypeValidation.Value!.CanonicalExpression; + group.WildDice = prototypeValidation.Value.WildDice; + group.AllowFumble = prototypeValidation.Value.AllowFumble; TouchCampaignLocked(campaign.Id); PersistStateLocked(); @@ -382,6 +396,33 @@ public sealed class GameService : IGameService } } + public ServiceResult DeleteSkillGroup(string sessionToken, Guid skillGroupId) + { + lock (m_Gate) + { + var user = ResolveUserLocked(sessionToken); + if (user is null) + return ServiceResult.Failure("unauthorized", "You must be logged in."); + + if (!m_SkillGroupsById.TryGetValue(skillGroupId, out var group)) + return ServiceResult.Failure("skill_group_not_found", "Skill group was not found."); + + var character = m_CharactersById[group.CharacterId]; + var campaign = m_CampaignsById[character.CampaignId]; + if (!CanEditCharacterLocked(user.Id, character, campaign)) + return ServiceResult.Failure("forbidden", "Only the owner or GM can manage skill groups."); + + foreach (var skill in m_SkillsById.Values.Where(skill => skill.SkillGroupId == group.Id)) + skill.SkillGroupId = null; + + m_SkillGroupsById.Remove(group.Id); + TouchCampaignLocked(campaign.Id); + + PersistStateLocked(); + return ServiceResult.Success(true); + } + } + public ServiceResult CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null) { if (string.IsNullOrWhiteSpace(name)) @@ -400,13 +441,9 @@ public sealed class GameService : IGameService if (!CanEditCharacterLocked(user.Id, character, campaign)) return ServiceResult.Failure("forbidden", "Only the owner or GM can edit skills."); - var expressionValidation = DiceRules.ParseExpression(campaign.Ruleset, diceRollDefinition); - if (!expressionValidation.Succeeded) - return ServiceResult.Failure(expressionValidation.Error!.Code, expressionValidation.Error.Message); - - var optionsValidation = ValidateSkillOptions(campaign.Ruleset, wildDice, allowFumble); - if (!optionsValidation.Succeeded) - return ServiceResult.Failure(optionsValidation.Error!.Code, optionsValidation.Error.Message); + var skillValidation = ValidateSkillDefinition(campaign.Ruleset, diceRollDefinition, wildDice, allowFumble); + if (!skillValidation.Succeeded) + return ServiceResult.Failure(skillValidation.Error!.Code, skillValidation.Error.Message); var resolvedSkillGroupId = ResolveSkillGroupForSkillChangeLocked(skillGroupId, character.Id); if (!resolvedSkillGroupId.Succeeded) @@ -418,9 +455,9 @@ public sealed class GameService : IGameService CharacterId = character.Id, SkillGroupId = resolvedSkillGroupId.Value, Name = name.Trim(), - DiceRollDefinition = expressionValidation.Value!.Canonical, - WildDice = optionsValidation.Value!.WildDice, - AllowFumble = optionsValidation.Value.AllowFumble + DiceRollDefinition = skillValidation.Value!.CanonicalExpression, + WildDice = skillValidation.Value.WildDice, + AllowFumble = skillValidation.Value.AllowFumble }; m_SkillsById[skill.Id] = skill; @@ -450,22 +487,18 @@ public sealed class GameService : IGameService if (!CanEditCharacterLocked(user.Id, character, campaign)) return ServiceResult.Failure("forbidden", "Only the owner or GM can edit skills."); - var expressionValidation = DiceRules.ParseExpression(campaign.Ruleset, diceRollDefinition); - if (!expressionValidation.Succeeded) - return ServiceResult.Failure(expressionValidation.Error!.Code, expressionValidation.Error.Message); - - var optionsValidation = ValidateSkillOptions(campaign.Ruleset, wildDice, allowFumble); - if (!optionsValidation.Succeeded) - return ServiceResult.Failure(optionsValidation.Error!.Code, optionsValidation.Error.Message); + var skillValidation = ValidateSkillDefinition(campaign.Ruleset, diceRollDefinition, wildDice, allowFumble); + if (!skillValidation.Succeeded) + return ServiceResult.Failure(skillValidation.Error!.Code, skillValidation.Error.Message); var resolvedSkillGroupId = ResolveSkillGroupForSkillChangeLocked(skillGroupId, character.Id); if (!resolvedSkillGroupId.Succeeded) return ServiceResult.Failure(resolvedSkillGroupId.Error!.Code, resolvedSkillGroupId.Error.Message); skill.Name = name.Trim(); - skill.DiceRollDefinition = expressionValidation.Value!.Canonical; - skill.WildDice = optionsValidation.Value!.WildDice; - skill.AllowFumble = optionsValidation.Value.AllowFumble; + skill.DiceRollDefinition = skillValidation.Value!.CanonicalExpression; + skill.WildDice = skillValidation.Value.WildDice; + skill.AllowFumble = skillValidation.Value.AllowFumble; skill.SkillGroupId = resolvedSkillGroupId.Value; TouchCampaignLocked(campaign.Id); @@ -474,6 +507,30 @@ public sealed class GameService : IGameService } } + public ServiceResult DeleteSkill(string sessionToken, Guid skillId) + { + lock (m_Gate) + { + var user = ResolveUserLocked(sessionToken); + if (user is null) + return ServiceResult.Failure("unauthorized", "You must be logged in."); + + if (!m_SkillsById.TryGetValue(skillId, out var skill)) + return ServiceResult.Failure("skill_not_found", "Skill was not found."); + + var character = m_CharactersById[skill.CharacterId]; + var campaign = m_CampaignsById[character.CampaignId]; + if (!CanEditCharacterLocked(user.Id, character, campaign)) + return ServiceResult.Failure("forbidden", "Only the owner or GM can edit skills."); + + m_SkillsById.Remove(skill.Id); + TouchCampaignLocked(campaign.Id); + + PersistStateLocked(); + return ServiceResult.Success(true); + } + } + public ServiceResult RollSkill(string sessionToken, Guid skillId, string visibility) { lock (m_Gate) @@ -548,6 +605,19 @@ public sealed class GameService : IGameService } } + private static ServiceResult<(string CanonicalExpression, int WildDice, bool AllowFumble)> ValidateSkillDefinition(RulesetKind ruleset, string diceRollDefinition, int wildDice, bool allowFumble) + { + var expressionValidation = DiceRules.ParseExpression(ruleset, diceRollDefinition); + if (!expressionValidation.Succeeded) + return ServiceResult<(string CanonicalExpression, int WildDice, bool AllowFumble)>.Failure(expressionValidation.Error!.Code, expressionValidation.Error.Message); + + var optionsValidation = ValidateSkillOptions(ruleset, wildDice, allowFumble); + 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)>.Success((expressionValidation.Value!.Canonical, optionsValidation.Value!.WildDice, optionsValidation.Value.AllowFumble)); + } + private static ServiceResult<(int WildDice, bool AllowFumble)> ValidateSkillOptions(RulesetKind ruleset, int wildDice, bool allowFumble) { if (wildDice < 0 || wildDice > 50) @@ -732,7 +802,7 @@ public sealed class GameService : IGameService private static SkillGroupSummary ToSkillGroupSummary(SkillGroup skillGroup) { - return new(skillGroup.Id, skillGroup.CharacterId, skillGroup.Name); + return new(skillGroup.Id, skillGroup.CharacterId, skillGroup.Name, skillGroup.DiceRollDefinition, skillGroup.WildDice, skillGroup.AllowFumble); } private static SkillSummary ToSkillSummary(Skill skill) @@ -992,7 +1062,10 @@ public sealed class GameService : IGameService { Id = skillGroup.Id, CharacterId = skillGroup.CharacterId, - Name = skillGroup.Name + Name = skillGroup.Name, + DiceRollDefinition = skillGroup.DiceRollDefinition, + WildDice = skillGroup.WildDice, + AllowFumble = skillGroup.AllowFumble }; } diff --git a/RpgRoller/Services/IGameService.cs b/RpgRoller/Services/IGameService.cs index ea2b212..1523be4 100644 --- a/RpgRoller/Services/IGameService.cs +++ b/RpgRoller/Services/IGameService.cs @@ -21,10 +21,12 @@ public interface IGameService ServiceResult ActivateCharacter(string sessionToken, Guid characterId); ServiceResult> GetCurrentCampaignCharacters(string sessionToken); - ServiceResult CreateSkillGroup(string sessionToken, Guid characterId, string name); - ServiceResult UpdateSkillGroup(string sessionToken, Guid skillGroupId, string name); + 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 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 DeleteSkill(string sessionToken, Guid skillId); ServiceResult RollSkill(string sessionToken, Guid skillId, string visibility); ServiceResult> GetCampaignLog(string sessionToken, Guid campaignId);