Add skill-group prototypes, delete flows, and per-group skill creation UX

This commit is contained in:
2026-02-26 14:12:15 +01:00
parent 04bc8095e6
commit 3b1a314a75
17 changed files with 740 additions and 111 deletions

View File

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

View File

@@ -73,9 +73,12 @@ public sealed class CampaignApiTests : ApiTestBase
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns", new("Grouped Campaign", "d6"));
var character = await PostAsync<CreateCharacterRequest, CharacterSummary>(ownerClient, "/api/characters", new("Grouped Hero", campaign.Id));
var createdGroup = await PostAsync<CreateSkillGroupRequest, SkillGroupSummary>(ownerClient, $"/api/characters/{character.Id}/skill-groups", new("Combat"));
var renamedGroup = await PutAsync<UpdateSkillGroupRequest, SkillGroupSummary>(gmClient, $"/api/skill-groups/{createdGroup.Id}", new("Battle"));
var createdGroup = await PostAsync<CreateSkillGroupRequest, SkillGroupSummary>(ownerClient, $"/api/characters/{character.Id}/skill-groups", new("Combat", "2D+1", 1, true));
var renamedGroup = await PutAsync<UpdateSkillGroupRequest, SkillGroupSummary>(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<CreateSkillRequest, SkillSummary>(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<UpdateSkillRequest, SkillSummary>(ownerClient, $"/api/skills/{groupedSkill.Id}", new("Strike", "2D+1", 1, true, null));
Assert.Null(ungroupedSkill.SkillGroupId);
var groupedAgainSkill = await PutAsync<UpdateSkillRequest, SkillSummary>(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<UpdateCharacterRequest, CharacterSummary>(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<CampaignDetails>(gmClient, $"/api/campaigns/{campaign.Id}");
Assert.Contains(details.SkillGroups, group => group.Id == renamedGroup.Id);
Assert.DoesNotContain(details.SkillGroups, group => group.Id == renamedGroup.Id);
}
}

View File

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

View File

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

View File

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

View File

@@ -45,14 +45,6 @@
class="muted">| Owner: @OwnerLabel(SelectedCharacter.OwnerUserId) | Campaign: @SelectedCampaign.Name</span>
</h3>
<div class="chip-toolbar">
<button type="button"
class="chip-button"
title="Add skill group"
disabled="@(IsMutating || !CanEditCharacter(SelectedCharacter))"
@onclick="OpenCreateSkillGroupModal">
<span aria-hidden="true">+</span>
<span class="sr-only">Add skill group</span>
</button>
<label class="visibility-control" for="roll-visibility">Visibility</label>
<select id="roll-visibility" value="@(RollVisibility == "private" ? "private" : "public")" @onchange="OnRollVisibilityChangedAsync">
<option value="public">Public</option>
@@ -74,15 +66,26 @@
<div class="skill-group-block">
<div class="skill-group-head">
<strong>@group.Name</strong>
<button
type="button"
class="chip-button"
title="Rename skill group"
disabled="@(IsMutating || !CanEditCharacter(SelectedCharacter))"
@onclick="() => OpenEditSkillGroupModal(group)">
<span aria-hidden="true">✎</span>
<span class="sr-only">Rename @group.Name</span>
</button>
<div class="skill-chip-actions">
<button
type="button"
class="chip-button"
title="Edit skill group"
disabled="@(IsMutating || !CanEditCharacter(SelectedCharacter))"
@onclick="() => OpenEditSkillGroupModal(group)">
<span aria-hidden="true">✎</span>
<span class="sr-only">Edit @group.Name</span>
</button>
<button
type="button"
class="chip-button"
title="Delete skill group"
disabled="@(IsMutating || !CanEditCharacter(SelectedCharacter))"
@onclick="() => DeleteSkillGroupAsync(group.Id)">
<span aria-hidden="true">✕</span>
<span class="sr-only">Delete @group.Name</span>
</button>
</div>
</div>
@if (groupSkills.Count == 0)
{
@@ -115,61 +118,93 @@
<span aria-hidden="true">⚄</span>
<span class="sr-only">Roll @skill.Name</span>
</button>
</div>
</div>
}
</div>
</div>
}
@if (ungroupedSkills.Count > 0)
{
<div class="skill-group-block">
<div class="skill-group-head">
<strong>Ungrouped</strong>
</div>
<div class="skill-list">
@foreach (var skill in ungroupedSkills)
{
<div class="skill-item">
<div class="skill-details">
<strong>@skill.Name</strong>
<span>@SkillDefinitionLabel(skill)</span>
</div>
<div class="skill-chip-actions">
<button
type="button"
class="chip-button"
title="Edit skill"
title="Delete skill"
disabled="@(IsMutating || !CanEditSkill(skill))"
@onclick="() => OpenEditSkillModal(skill)">
<span aria-hidden="true"></span>
<span class="sr-only">Edit @skill.Name</span>
</button>
<button
type="button"
class="chip-button"
title="Roll skill"
disabled="@(IsMutating || !CanRollSkill(skill))"
@onclick="() => RollSkillAsync(skill.Id)">
<span aria-hidden="true">⚄</span>
<span class="sr-only">Roll @skill.Name</span>
@onclick="() => DeleteSkillAsync(skill.Id)">
<span aria-hidden="true"></span>
<span class="sr-only">Delete @skill.Name</span>
</button>
</div>
</div>
}
<button
type="button"
class="skill-item create-skill-item"
disabled="@(IsMutating || !CanEditCharacter(SelectedCharacter))"
@onclick="() => OpenCreateSkillModal(group.Id)">
<span class="skill-create-icon" aria-hidden="true">+</span>
<span>Add skill</span>
</button>
</div>
</div>
}
<div class="skill-list">
<button
type="button"
class="skill-item create-skill-item"
disabled="@(IsMutating || !CanEditCharacter(SelectedCharacter))"
@onclick="OpenCreateSkillModal">
<span class="skill-create-icon" aria-hidden="true">+</span>
<span>Add skill</span>
</button>
<div class="skill-group-block">
<div class="skill-group-head">
<strong>Ungrouped</strong>
</div>
@if (ungroupedSkills.Count == 0)
{
<p class="empty">No ungrouped skills.</p>
}
<div class="skill-list">
@foreach (var skill in ungroupedSkills)
{
<div class="skill-item">
<div class="skill-details">
<strong>@skill.Name</strong>
<span>@SkillDefinitionLabel(skill)</span>
</div>
<div class="skill-chip-actions">
<button
type="button"
class="chip-button"
title="Edit skill"
disabled="@(IsMutating || !CanEditSkill(skill))"
@onclick="() => OpenEditSkillModal(skill)">
<span aria-hidden="true">✎</span>
<span class="sr-only">Edit @skill.Name</span>
</button>
<button
type="button"
class="chip-button"
title="Roll skill"
disabled="@(IsMutating || !CanRollSkill(skill))"
@onclick="() => RollSkillAsync(skill.Id)">
<span aria-hidden="true">⚄</span>
<span class="sr-only">Roll @skill.Name</span>
</button>
<button
type="button"
class="chip-button"
title="Delete skill"
disabled="@(IsMutating || !CanEditSkill(skill))"
@onclick="() => DeleteSkillAsync(skill.Id)">
<span aria-hidden="true">✕</span>
<span class="sr-only">Delete @skill.Name</span>
</button>
</div>
</div>
}
<button
type="button"
class="skill-item create-skill-item"
disabled="@(IsMutating || !CanEditCharacter(SelectedCharacter))"
@onclick="() => OpenCreateSkillModal(null)">
<span class="skill-create-icon" aria-hidden="true">+</span>
<span>Add skill</span>
</button>
</div>
</div>
<button
type="button"
class="add-row-button"
disabled="@(IsMutating || !CanEditCharacter(SelectedCharacter))"
@onclick="OpenCreateSkillGroupModal">[+] Add group
</button>
</article>
}
<div class="character-panel-fill" aria-hidden="true"></div>
@@ -180,7 +215,7 @@
{
<div class="modal-overlay" role="presentation">
<section class="modal-card" role="dialog" aria-modal="true" aria-label="Skill Group">
<h2>@(ShowEditSkillGroupModal ? "Rename Skill Group" : "Create Skill Group")</h2>
<h2>@(ShowEditSkillGroupModal ? "Edit Skill Group" : "Create Skill Group")</h2>
@if (!string.IsNullOrWhiteSpace(SkillGroupState.ErrorMessage))
{
<p class="form-error">@SkillGroupState.ErrorMessage</p>
@@ -192,10 +227,36 @@
{
<p class="field-error">@groupNameError</p>
}
<label for="skill-group-expression">Prototype expression</label>
<input id="skill-group-expression" @bind="SkillGroupState.Model.DiceRollDefinition" @bind:event="oninput"/>
@if (SkillGroupState.Errors.TryGetValue("diceRollDefinition", out var expressionError))
{
<p class="field-error">@expressionError</p>
}
@if (IsD6)
{
<label for="skill-group-wild-dice">Prototype wild dice</label>
<input id="skill-group-wild-dice" type="number" min="1" step="1" @bind="SkillGroupState.Model.WildDice"/>
@if (SkillGroupState.Errors.TryGetValue("wildDice", out var wildDiceError))
{
<p class="field-error">@wildDiceError</p>
}
<label for="skill-group-allow-fumble">Prototype allow fumble</label>
<input id="skill-group-allow-fumble" type="checkbox" @bind="SkillGroupState.Model.AllowFumble"/>
}
@if (SkillGroupState.Errors.TryGetValue("character", out var characterError))
{
<p class="field-error">@characterError</p>
}
@if (SkillGroupState.Errors.TryGetValue("group", out var groupError))
{
<p class="field-error">@groupError</p>
}
<div class="inline-actions">
<button type="submit" disabled="@(IsMutating || IsSubmittingSkillGroup)">@(ShowEditSkillGroupModal ? "Save Group" : "Create Group")</button>
<button type="button" class="ghost" disabled="@(IsMutating || IsSubmittingSkillGroup)" @onclick="CloseSkillGroupModals">Cancel</button>

View File

@@ -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<SkillGroupSummary>("POST", $"/api/characters/{selectedCharacterId}/skill-groups", new CreateSkillGroupRequest(SkillGroupState.Model.Name.Trim()));
var createdGroup = await ApiClient.RequestAsync<SkillGroupSummary>(
"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<SkillGroupSummary>("PUT", $"/api/skill-groups/{editingSkillGroupId}", new UpdateSkillGroupRequest(SkillGroupState.Model.Name.Trim()));
var updatedGroup = await ApiClient.RequestAsync<SkillGroupSummary>(
"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<bool>("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<bool>("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<Guid> SkillGroupUpdated { get; set; }
[Parameter]
public EventCallback<Guid> SkillDeleted { get; set; }
[Parameter]
public EventCallback<Guid> SkillGroupDeleted { get; set; }
[Parameter]
public EventCallback<string> ErrorOccurred { get; set; }
[Parameter]
public EventCallback<Guid> RollRequested { get; set; }
}

View File

@@ -85,6 +85,9 @@
SkillUpdated="OnSkillUpdatedAsync"
SkillGroupCreated="OnSkillGroupCreatedAsync"
SkillGroupUpdated="OnSkillGroupUpdatedAsync"
SkillDeleted="OnSkillDeletedAsync"
SkillGroupDeleted="OnSkillGroupDeletedAsync"
ErrorOccurred="OnCharacterPanelErrorAsync"
RollRequested="RollSkillAsync"/>
<CampaignLogPanel

View File

@@ -392,6 +392,24 @@ public partial class Workspace : IAsyncDisposable
SetStatus("Skill group updated.", false);
}
private async Task OnSkillDeletedAsync(Guid _)
{
await RefreshCampaignScopeAsync();
SetStatus("Skill deleted.", false);
}
private async Task OnSkillGroupDeletedAsync(Guid _)
{
await RefreshCampaignScopeAsync();
SetStatus("Skill group deleted.", false);
}
private Task OnCharacterPanelErrorAsync(string message)
{
SetStatus(message, true);
return Task.CompletedTask;
}
private async Task RollSkillAsync(Guid skillId)
{
if (SelectedCampaign is null)

View File

@@ -30,11 +30,11 @@ public sealed record CreateSkillRequest(string Name, string DiceRollDefinition,
public sealed record UpdateSkillRequest(string Name, string DiceRollDefinition, int WildDice, bool AllowFumble, Guid? SkillGroupId = null);
public sealed record SkillGroupSummary(Guid Id, Guid CharacterId, string Name);
public sealed record SkillGroupSummary(Guid Id, Guid CharacterId, string Name, string DiceRollDefinition, int WildDice, bool AllowFumble);
public sealed record CreateSkillGroupRequest(string Name);
public sealed record CreateSkillGroupRequest(string Name, string DiceRollDefinition, int WildDice, bool AllowFumble);
public sealed record UpdateSkillGroupRequest(string Name);
public sealed record UpdateSkillGroupRequest(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);

View File

@@ -61,6 +61,9 @@ public sealed class RpgRollerDbContext : DbContext
{
entity.HasKey(x => 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);
});

View File

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

View File

@@ -0,0 +1,253 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using RpgRoller.Data;
#nullable disable
namespace RpgRoller.Migrations
{
[DbContext(typeof(RpgRollerDbContext))]
[Migration("20260226131003_AddSkillGroupPrototypes")]
partial class AddSkillGroupPrototypes
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.2");
modelBuilder.Entity("RpgRoller.Domain.Campaign", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("GmUserId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Ruleset")
.IsRequired()
.HasColumnType("TEXT");
b.Property<long>("Version")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("GmUserId");
b.ToTable("Campaigns");
});
modelBuilder.Entity("RpgRoller.Domain.Character", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("CampaignId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<Guid>("OwnerUserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CampaignId");
b.HasIndex("OwnerUserId");
b.ToTable("Characters");
});
modelBuilder.Entity("RpgRoller.Domain.RollLogEntry", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Breakdown")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<Guid>("CampaignId")
.HasColumnType("TEXT");
b.Property<Guid>("CharacterId")
.HasColumnType("TEXT");
b.Property<string>("Dice")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Result")
.HasColumnType("INTEGER");
b.Property<Guid>("RollerUserId")
.HasColumnType("TEXT");
b.Property<Guid>("SkillId")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("TimestampUtc")
.HasColumnType("TEXT");
b.Property<string>("Visibility")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CampaignId");
b.HasIndex("CharacterId");
b.HasIndex("RollerUserId");
b.HasIndex("SkillId");
b.ToTable("RollLogEntries");
});
modelBuilder.Entity("RpgRoller.Domain.Skill", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<bool>("AllowFumble")
.HasColumnType("INTEGER");
b.Property<Guid>("CharacterId")
.HasColumnType("TEXT");
b.Property<string>("DiceRollDefinition")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<Guid?>("SkillGroupId")
.HasColumnType("TEXT");
b.Property<int>("WildDice")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CharacterId");
b.HasIndex("SkillGroupId");
b.ToTable("Skills");
});
modelBuilder.Entity("RpgRoller.Domain.SkillGroup", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<bool>("AllowFumble")
.HasColumnType("INTEGER");
b.Property<Guid>("CharacterId")
.HasColumnType("TEXT");
b.Property<string>("DiceRollDefinition")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("WildDice")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CharacterId");
b.ToTable("SkillGroups");
});
modelBuilder.Entity("RpgRoller.Domain.UserAccount", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid?>("ActiveCharacterId")
.HasColumnType("TEXT");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("UsernameNormalized")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UsernameNormalized")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("RpgRoller.Domain.UserSession", b =>
{
b.Property<string>("Token")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Token");
b.HasIndex("UserId");
b.ToTable("Sessions");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,87 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace RpgRoller.Migrations
{
/// <inheritdoc />
public partial class AddSkillGroupPrototypes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "AllowFumble",
table: "SkillGroups",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<string>(
name: "DiceRollDefinition",
table: "SkillGroups",
type: "TEXT",
maxLength: 128,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<int>(
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 = '';
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AllowFumble",
table: "SkillGroups");
migrationBuilder.DropColumn(
name: "DiceRollDefinition",
table: "SkillGroups");
migrationBuilder.DropColumn(
name: "WildDice",
table: "SkillGroups");
}
}
}

View File

@@ -164,14 +164,25 @@ namespace RpgRoller.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<bool>("AllowFumble")
.HasColumnType("INTEGER");
b.Property<Guid>("CharacterId")
.HasColumnType("TEXT");
b.Property<string>("DiceRollDefinition")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("WildDice")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CharacterId");

View File

@@ -322,7 +322,7 @@ public sealed class GameService : IGameService
}
}
public ServiceResult<SkillGroupSummary> CreateSkillGroup(string sessionToken, Guid characterId, string name)
public ServiceResult<SkillGroupSummary> CreateSkillGroup(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble)
{
if (string.IsNullOrWhiteSpace(name))
return ServiceResult<SkillGroupSummary>.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<SkillGroupSummary>.Failure("forbidden", "Only the owner or GM can manage skill groups.");
var prototypeValidation = ValidateSkillDefinition(campaign.Ruleset, diceRollDefinition, wildDice, allowFumble);
if (!prototypeValidation.Succeeded)
return ServiceResult<SkillGroupSummary>.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<SkillGroupSummary> UpdateSkillGroup(string sessionToken, Guid skillGroupId, string name)
public ServiceResult<SkillGroupSummary> UpdateSkillGroup(string sessionToken, Guid skillGroupId, string name, string diceRollDefinition, int wildDice, bool allowFumble)
{
if (string.IsNullOrWhiteSpace(name))
return ServiceResult<SkillGroupSummary>.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<SkillGroupSummary>.Failure("forbidden", "Only the owner or GM can manage skill groups.");
var prototypeValidation = ValidateSkillDefinition(campaign.Ruleset, diceRollDefinition, wildDice, allowFumble);
if (!prototypeValidation.Succeeded)
return ServiceResult<SkillGroupSummary>.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<bool> DeleteSkillGroup(string sessionToken, Guid skillGroupId)
{
lock (m_Gate)
{
var user = ResolveUserLocked(sessionToken);
if (user is null)
return ServiceResult<bool>.Failure("unauthorized", "You must be logged in.");
if (!m_SkillGroupsById.TryGetValue(skillGroupId, out var group))
return ServiceResult<bool>.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<bool>.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<bool>.Success(true);
}
}
public ServiceResult<SkillSummary> 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<SkillSummary>.Failure("forbidden", "Only the owner or GM can edit skills.");
var expressionValidation = DiceRules.ParseExpression(campaign.Ruleset, diceRollDefinition);
if (!expressionValidation.Succeeded)
return ServiceResult<SkillSummary>.Failure(expressionValidation.Error!.Code, expressionValidation.Error.Message);
var optionsValidation = ValidateSkillOptions(campaign.Ruleset, wildDice, allowFumble);
if (!optionsValidation.Succeeded)
return ServiceResult<SkillSummary>.Failure(optionsValidation.Error!.Code, optionsValidation.Error.Message);
var skillValidation = ValidateSkillDefinition(campaign.Ruleset, diceRollDefinition, wildDice, allowFumble);
if (!skillValidation.Succeeded)
return ServiceResult<SkillSummary>.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<SkillSummary>.Failure("forbidden", "Only the owner or GM can edit skills.");
var expressionValidation = DiceRules.ParseExpression(campaign.Ruleset, diceRollDefinition);
if (!expressionValidation.Succeeded)
return ServiceResult<SkillSummary>.Failure(expressionValidation.Error!.Code, expressionValidation.Error.Message);
var optionsValidation = ValidateSkillOptions(campaign.Ruleset, wildDice, allowFumble);
if (!optionsValidation.Succeeded)
return ServiceResult<SkillSummary>.Failure(optionsValidation.Error!.Code, optionsValidation.Error.Message);
var skillValidation = ValidateSkillDefinition(campaign.Ruleset, diceRollDefinition, wildDice, allowFumble);
if (!skillValidation.Succeeded)
return ServiceResult<SkillSummary>.Failure(skillValidation.Error!.Code, skillValidation.Error.Message);
var resolvedSkillGroupId = ResolveSkillGroupForSkillChangeLocked(skillGroupId, character.Id);
if (!resolvedSkillGroupId.Succeeded)
return ServiceResult<SkillSummary>.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<bool> DeleteSkill(string sessionToken, Guid skillId)
{
lock (m_Gate)
{
var user = ResolveUserLocked(sessionToken);
if (user is null)
return ServiceResult<bool>.Failure("unauthorized", "You must be logged in.");
if (!m_SkillsById.TryGetValue(skillId, out var skill))
return ServiceResult<bool>.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<bool>.Failure("forbidden", "Only the owner or GM can edit skills.");
m_SkillsById.Remove(skill.Id);
TouchCampaignLocked(campaign.Id);
PersistStateLocked();
return ServiceResult<bool>.Success(true);
}
}
public ServiceResult<RollResult> 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
};
}

View File

@@ -21,10 +21,12 @@ public interface IGameService
ServiceResult<bool> ActivateCharacter(string sessionToken, Guid characterId);
ServiceResult<IReadOnlyList<CharacterSummary>> GetCurrentCampaignCharacters(string sessionToken);
ServiceResult<SkillGroupSummary> CreateSkillGroup(string sessionToken, Guid characterId, string name);
ServiceResult<SkillGroupSummary> UpdateSkillGroup(string sessionToken, Guid skillGroupId, string name);
ServiceResult<SkillGroupSummary> CreateSkillGroup(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble);
ServiceResult<SkillGroupSummary> UpdateSkillGroup(string sessionToken, Guid skillGroupId, string name, string diceRollDefinition, int wildDice, bool allowFumble);
ServiceResult<bool> DeleteSkillGroup(string sessionToken, Guid skillGroupId);
ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null);
ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null);
ServiceResult<bool> DeleteSkill(string sessionToken, Guid skillId);
ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility);
ServiceResult<IReadOnlyList<CampaignLogEntry>> GetCampaignLog(string sessionToken, Guid campaignId);