Add skill groups and GM character owner transfer across stack

This commit is contained in:
2026-02-26 13:54:17 +01:00
parent bf3a6fa645
commit 04bc8095e6
31 changed files with 995 additions and 1180 deletions

View File

@@ -15,7 +15,7 @@ internal static class CharacterEndpoints
group.MapPut("/characters/{characterId:guid}", (Guid characterId, UpdateCharacterRequest request, HttpContext context, IGameService game) =>
{
var result = game.UpdateCharacter(context.GetRequiredSessionToken(), characterId, request.Name, request.CampaignId);
var result = game.UpdateCharacter(context.GetRequiredSessionToken(), characterId, request.Name, request.CampaignId, request.OwnerUsername);
return ApiResultMapper.ToApiResult(result);
});
@@ -33,4 +33,4 @@ internal static class CharacterEndpoints
return group;
}
}
}

View File

@@ -9,13 +9,25 @@ 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);
var result = game.CreateSkill(context.GetRequiredSessionToken(), characterId, request.Name, request.DiceRollDefinition, request.WildDice, request.AllowFumble, request.SkillGroupId);
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);
var result = game.UpdateSkill(context.GetRequiredSessionToken(), skillId, request.Name, request.DiceRollDefinition, request.WildDice, request.AllowFumble, request.SkillGroupId);
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);
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);
return ApiResultMapper.ToApiResult(result);
});
@@ -27,4 +39,4 @@ internal static class SkillEndpoints
return group;
}
}
}

View File

@@ -36,19 +36,26 @@ public sealed class CharacterFormModel
{
public string Name { get; set; } = string.Empty;
public string CampaignId { get; set; } = string.Empty;
public string OwnerUsername { get; set; } = string.Empty;
}
public sealed class SkillFormModel
{
public string Name { get; set; } = string.Empty;
public string DiceRollDefinition { get; set; } = string.Empty;
public string SkillGroupId { get; set; } = string.Empty;
public int WildDice { get; set; }
public bool AllowFumble { get; set; }
}
public sealed class SkillGroupFormModel
{
public string Name { get; set; } = string.Empty;
}
public enum HomeViewMode
{
Loading,
Anonymous,
Workspace
}
}

View File

@@ -26,6 +26,15 @@
{
<p class="field-error">@campaignError</p>
}
@if (AllowOwnerEdit)
{
<label for="@OwnerUsernameInputId">Owner username</label>
<input id="@OwnerUsernameInputId" @bind="FormState.Model.OwnerUsername" @bind:event="oninput" placeholder="Leave empty to keep current owner"/>
@if (FormState.Errors.TryGetValue("ownerUsername", out var ownerUsernameError))
{
<p class="field-error">@ownerUsernameError</p>
}
}
<div class="inline-actions">
<button type="submit" disabled="@(IsMutating || IsSubmitting)">@SubmitLabel</button>
<button type="button" class="ghost" @onclick="CancelRequested">Cancel</button>

View File

@@ -14,6 +14,7 @@ public partial class CharacterFormModal
FormState.Model.Name = InitialModel.Name;
FormState.Model.CampaignId = InitialModel.CampaignId;
FormState.Model.OwnerUsername = InitialModel.OwnerUsername;
FormState.ResetValidation();
AppliedFormVersion = FormVersion;
}
@@ -40,7 +41,8 @@ public partial class CharacterFormModal
CharacterSummary character;
if (EditingCharacterId.HasValue)
{
character = await ApiClient.RequestAsync<CharacterSummary>("PUT", $"/api/characters/{EditingCharacterId.Value}", new UpdateCharacterRequest(FormState.Model.Name.Trim(), campaignId));
var ownerUsername = AllowOwnerEdit && !string.IsNullOrWhiteSpace(FormState.Model.OwnerUsername) ? FormState.Model.OwnerUsername.Trim() : null;
character = await ApiClient.RequestAsync<CharacterSummary>("PUT", $"/api/characters/{EditingCharacterId.Value}", new UpdateCharacterRequest(FormState.Model.Name.Trim(), campaignId, ownerUsername));
}
else
{
@@ -81,6 +83,9 @@ public partial class CharacterFormModal
[Parameter]
public string CampaignInputId { get; set; } = "character-campaign";
[Parameter]
public string OwnerUsernameInputId { get; set; } = "character-owner-username";
[Parameter]
public CharacterFormModel InitialModel { get; set; } = new();
@@ -96,9 +101,12 @@ public partial class CharacterFormModal
[Parameter]
public bool IsMutating { get; set; }
[Parameter]
public bool AllowOwnerEdit { get; set; }
[Parameter]
public EventCallback<Guid> CharacterSaved { get; set; }
[Parameter]
public EventCallback CancelRequested { get; set; }
}
}

View File

@@ -45,6 +45,14 @@
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>
@@ -52,40 +60,107 @@
</select>
</div>
</div>
@if (SelectedCharacterSkills.Count == 0)
@{
var orderedSkillGroups = SelectedCharacterSkillGroups.OrderBy(group => group.Name).ToList();
var ungroupedSkills = SelectedCharacterSkills.Where(skill => !skill.SkillGroupId.HasValue).ToList();
}
@if (SelectedCharacterSkills.Count == 0 && orderedSkillGroups.Count == 0)
{
<p class="empty">No skills for this character yet.</p>
}
<div class="skill-list">
@foreach (var skill in SelectedCharacterSkills)
{
<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>
</div>
@foreach (var group in orderedSkillGroups)
{
var groupSkills = SelectedCharacterSkills.Where(skill => skill.SkillGroupId == group.Id).ToList();
<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>
}
@if (groupSkills.Count == 0)
{
<p class="empty">No skills in this group yet.</p>
}
<div class="skill-list">
@foreach (var skill in groupSkills)
{
<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>
</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"
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>
</div>
</div>
}
</div>
</div>
}
<div class="skill-list">
<button
type="button"
class="skill-item create-skill-item"
@@ -101,6 +176,35 @@
}
</section>
@if (ShowCreateSkillGroupModal || ShowEditSkillGroupModal)
{
<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>
@if (!string.IsNullOrWhiteSpace(SkillGroupState.ErrorMessage))
{
<p class="form-error">@SkillGroupState.ErrorMessage</p>
}
<form class="form-grid" @onsubmit="@(ShowEditSkillGroupModal ? SubmitUpdateSkillGroupAsync : SubmitCreateSkillGroupAsync)" @onsubmit:preventDefault>
<label for="skill-group-name">Group name</label>
<input id="skill-group-name" @bind="SkillGroupState.Model.Name" @bind:event="oninput"/>
@if (SkillGroupState.Errors.TryGetValue("name", out var groupNameError))
{
<p class="field-error">@groupNameError</p>
}
@if (SkillGroupState.Errors.TryGetValue("character", out var characterError))
{
<p class="field-error">@characterError</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>
</div>
</form>
</section>
</div>
}
<SkillFormModal
Visible="ShowCreateSkillModal"
IsD6="IsD6"
@@ -108,12 +212,14 @@
SubmitLabel="Create Skill"
NameInputId="skill-create-name"
ExpressionInputId="skill-create-expression"
SkillGroupInputId="skill-create-group"
WildDiceInputId="skill-create-wild-dice"
AllowFumbleInputId="skill-create-allow-fumble"
InitialModel="CreateSkillInitialModel"
FormVersion="CreateSkillFormVersion"
SelectedCharacterId="SelectedCharacterId"
EditingSkillId="null"
AvailableSkillGroups="SelectedCharacterSkillGroups"
IsMutating="IsMutating"
SkillSaved="OnSkillCreatedAsync"
CancelRequested="CloseSkillModals"/>
@@ -125,12 +231,14 @@
SubmitLabel="Save Skill"
NameInputId="skill-edit-name"
ExpressionInputId="skill-edit-expression"
SkillGroupInputId="skill-edit-group"
WildDiceInputId="skill-edit-wild-dice"
AllowFumbleInputId="skill-edit-allow-fumble"
InitialModel="EditSkillInitialModel"
FormVersion="EditSkillFormVersion"
SelectedCharacterId="SelectedCharacterId"
EditingSkillId="EditingSkillId"
AvailableSkillGroups="SelectedCharacterSkillGroups"
IsMutating="IsMutating"
SkillSaved="OnSkillUpdatedAsync"
CancelRequested="CloseSkillModals"/>

View File

@@ -13,6 +13,7 @@ public partial class CharacterPanel
{
Name = string.Empty,
DiceRollDefinition = string.Empty,
SkillGroupId = string.Empty,
WildDice = IsD6 ? 1 : 0,
AllowFumble = IsD6
};
@@ -28,6 +29,7 @@ public partial class CharacterPanel
{
Name = skill.Name,
DiceRollDefinition = skill.DiceRollDefinition,
SkillGroupId = skill.SkillGroupId?.ToString() ?? string.Empty,
WildDice = skill.WildDice,
AllowFumble = skill.AllowFumble
};
@@ -66,6 +68,97 @@ public partial class CharacterPanel
await RollRequested.InvokeAsync(skillId);
}
private void OpenCreateSkillGroupModal()
{
SkillGroupState.Model.Name = string.Empty;
SkillGroupState.ResetValidation();
ShowCreateSkillGroupModal = true;
}
private void OpenEditSkillGroupModal(SkillGroupSummary skillGroup)
{
EditingSkillGroupId = skillGroup.Id;
SkillGroupState.Model.Name = skillGroup.Name;
SkillGroupState.ResetValidation();
ShowEditSkillGroupModal = true;
}
private void CloseSkillGroupModals()
{
ShowCreateSkillGroupModal = false;
ShowEditSkillGroupModal = false;
EditingSkillGroupId = null;
SkillGroupState.ResetValidation();
}
private async Task SubmitCreateSkillGroupAsync()
{
SkillGroupState.ResetValidation();
if (string.IsNullOrWhiteSpace(SkillGroupState.Model.Name))
SkillGroupState.Errors["name"] = "Skill group name is required.";
if (!SelectedCharacterId.HasValue)
SkillGroupState.Errors["character"] = "Select a character first.";
if (SkillGroupState.Errors.Count > 0)
{
SkillGroupState.ErrorMessage = "Resolve validation issues before submitting.";
return;
}
IsSubmittingSkillGroup = true;
try
{
var selectedCharacterId = SelectedCharacterId!.Value;
var createdGroup = await ApiClient.RequestAsync<SkillGroupSummary>("POST", $"/api/characters/{selectedCharacterId}/skill-groups", new CreateSkillGroupRequest(SkillGroupState.Model.Name.Trim()));
CloseSkillGroupModals();
await SkillGroupCreated.InvokeAsync(createdGroup.Id);
}
catch (ApiRequestException ex)
{
SkillGroupState.ErrorMessage = ex.Message;
}
finally
{
IsSubmittingSkillGroup = false;
}
}
private async Task SubmitUpdateSkillGroupAsync()
{
SkillGroupState.ResetValidation();
if (string.IsNullOrWhiteSpace(SkillGroupState.Model.Name))
SkillGroupState.Errors["name"] = "Skill group name is required.";
if (!EditingSkillGroupId.HasValue)
SkillGroupState.Errors["group"] = "Select a skill group first.";
if (SkillGroupState.Errors.Count > 0)
{
SkillGroupState.ErrorMessage = "Resolve validation issues before submitting.";
return;
}
IsSubmittingSkillGroup = true;
try
{
var editingSkillGroupId = EditingSkillGroupId!.Value;
var updatedGroup = await ApiClient.RequestAsync<SkillGroupSummary>("PUT", $"/api/skill-groups/{editingSkillGroupId}", new UpdateSkillGroupRequest(SkillGroupState.Model.Name.Trim()));
CloseSkillGroupModals();
await SkillGroupUpdated.InvokeAsync(updatedGroup.Id);
}
catch (ApiRequestException ex)
{
SkillGroupState.ErrorMessage = ex.Message;
}
finally
{
IsSubmittingSkillGroup = false;
}
}
private static string InitialsFor(string value)
{
var words = value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
@@ -80,11 +173,19 @@ public partial class CharacterPanel
private bool ShowCreateSkillModal { get; set; }
private bool ShowEditSkillModal { get; set; }
private bool ShowCreateSkillGroupModal { get; set; }
private bool ShowEditSkillGroupModal { get; set; }
private Guid? EditingSkillId { get; set; }
private Guid? EditingSkillGroupId { get; set; }
private SkillFormModel CreateSkillInitialModel { get; set; } = new();
private SkillFormModel EditSkillInitialModel { get; set; } = new();
private FormState<SkillGroupFormModel> SkillGroupState { get; } = new();
private int CreateSkillFormVersion { get; set; }
private int EditSkillFormVersion { get; set; }
private bool IsSubmittingSkillGroup { get; set; }
[Inject]
private RpgRollerApiClient ApiClient { get; set; } = null!;
[Parameter]
public bool IsCampaignDataLoading { get; set; }
@@ -104,6 +205,9 @@ public partial class CharacterPanel
[Parameter]
public IReadOnlyList<SkillSummary> SelectedCharacterSkills { get; set; } = [];
[Parameter]
public IReadOnlyList<SkillGroupSummary> SelectedCharacterSkillGroups { get; set; } = [];
[Parameter]
public bool IsD6 { get; set; }
@@ -140,6 +244,12 @@ public partial class CharacterPanel
[Parameter]
public EventCallback<Guid> SkillUpdated { get; set; }
[Parameter]
public EventCallback<Guid> SkillGroupCreated { get; set; }
[Parameter]
public EventCallback<Guid> SkillGroupUpdated { get; set; }
[Parameter]
public EventCallback<Guid> RollRequested { get; set; }
}

View File

@@ -20,6 +20,18 @@
{
<p class="field-error">@expressionError</p>
}
<label for="@SkillGroupInputId">Group</label>
<select id="@SkillGroupInputId" @bind="FormState.Model.SkillGroupId">
<option value="">No group</option>
@foreach (var group in AvailableSkillGroups)
{
<option value="@group.Id">@group.Name</option>
}
</select>
@if (FormState.Errors.TryGetValue("skillGroupId", out var skillGroupError))
{
<p class="field-error">@skillGroupError</p>
}
@if (IsD6)
{
<label for="@WildDiceInputId">Wild dice</label>

View File

@@ -14,6 +14,7 @@ public partial class SkillFormModal
FormState.Model.Name = InitialModel.Name;
FormState.Model.DiceRollDefinition = InitialModel.DiceRollDefinition;
FormState.Model.SkillGroupId = InitialModel.SkillGroupId;
FormState.Model.WildDice = InitialModel.WildDice;
FormState.Model.AllowFumble = InitialModel.AllowFumble;
FormState.ResetValidation();
@@ -33,6 +34,15 @@ public partial class SkillFormModal
if (IsD6 && FormState.Model.WildDice < 1)
FormState.Errors["wildDice"] = "D6 skills require at least one wild die.";
Guid? skillGroupId = null;
if (!string.IsNullOrWhiteSpace(FormState.Model.SkillGroupId))
{
if (!Guid.TryParse(FormState.Model.SkillGroupId, out var parsedSkillGroupId))
FormState.Errors["skillGroupId"] = "Skill group is invalid.";
else
skillGroupId = parsedSkillGroupId;
}
if (FormState.Errors.Count > 0)
{
FormState.ErrorMessage = "Resolve validation issues before submitting.";
@@ -45,7 +55,7 @@ public partial class SkillFormModal
SkillSummary skill;
if (EditingSkillId.HasValue)
{
skill = await ApiClient.RequestAsync<SkillSummary>("PUT", $"/api/skills/{EditingSkillId.Value}", new UpdateSkillRequest(FormState.Model.Name.Trim(), FormState.Model.DiceRollDefinition.Trim(), FormState.Model.WildDice, FormState.Model.AllowFumble));
skill = await ApiClient.RequestAsync<SkillSummary>("PUT", $"/api/skills/{EditingSkillId.Value}", new UpdateSkillRequest(FormState.Model.Name.Trim(), FormState.Model.DiceRollDefinition.Trim(), FormState.Model.WildDice, FormState.Model.AllowFumble, skillGroupId));
}
else
{
@@ -55,7 +65,7 @@ public partial class SkillFormModal
return;
}
skill = await ApiClient.RequestAsync<SkillSummary>("POST", $"/api/characters/{SelectedCharacterId.Value}/skills", new CreateSkillRequest(FormState.Model.Name.Trim(), FormState.Model.DiceRollDefinition.Trim(), FormState.Model.WildDice, FormState.Model.AllowFumble));
skill = await ApiClient.RequestAsync<SkillSummary>("POST", $"/api/characters/{SelectedCharacterId.Value}/skills", new CreateSkillRequest(FormState.Model.Name.Trim(), FormState.Model.DiceRollDefinition.Trim(), FormState.Model.WildDice, FormState.Model.AllowFumble, skillGroupId));
}
await SkillSaved.InvokeAsync(skill.Id);
@@ -95,6 +105,9 @@ public partial class SkillFormModal
[Parameter]
public string ExpressionInputId { get; set; } = "skill-expression";
[Parameter]
public string SkillGroupInputId { get; set; } = "skill-group";
[Parameter]
public string WildDiceInputId { get; set; } = "skill-wild";
@@ -113,6 +126,9 @@ public partial class SkillFormModal
[Parameter]
public Guid? EditingSkillId { get; set; }
[Parameter]
public IReadOnlyList<SkillGroupSummary> AvailableSkillGroups { get; set; } = [];
[Parameter]
public bool IsMutating { get; set; }
@@ -121,4 +137,4 @@ public partial class SkillFormModal
[Parameter]
public EventCallback CancelRequested { get; set; }
}
}

View File

@@ -70,6 +70,7 @@
SelectedCharacter="SelectedCharacter"
IsMutating="IsMutating"
SelectedCharacterSkills="SelectedCharacterSkills"
SelectedCharacterSkillGroups="SelectedCharacterSkillGroups"
IsD6="IsSelectedCampaignD6"
RollVisibility="RollVisibility"
RollVisibilityChanged="OnRollVisibilityChanged"
@@ -82,6 +83,8 @@
EditCharacterRequested="OpenEditCharacterModal"
SkillCreated="OnSkillCreatedAsync"
SkillUpdated="OnSkillUpdatedAsync"
SkillGroupCreated="OnSkillGroupCreatedAsync"
SkillGroupUpdated="OnSkillGroupUpdatedAsync"
RollRequested="RollSkillAsync"/>
<CampaignLogPanel
@@ -140,11 +143,13 @@
SubmitLabel="Create Character"
NameInputId="character-create-name"
CampaignInputId="character-create-campaign"
OwnerUsernameInputId="character-create-owner"
InitialModel="CreateCharacterInitialModel"
FormVersion="CreateCharacterFormVersion"
EditingCharacterId="null"
Campaigns="Campaigns"
IsMutating="IsMutating"
AllowOwnerEdit="false"
CharacterSaved="OnCharacterCreatedAsync"
CancelRequested="CloseCharacterModals"/>
@@ -154,10 +159,12 @@
SubmitLabel="Save Character"
NameInputId="character-edit-name"
CampaignInputId="character-edit-campaign"
OwnerUsernameInputId="character-edit-owner"
InitialModel="EditCharacterInitialModel"
FormVersion="EditCharacterFormVersion"
EditingCharacterId="EditingCharacterId"
Campaigns="Campaigns"
IsMutating="IsMutating"
AllowOwnerEdit="CanEditCharacterOwner"
CharacterSaved="OnCharacterUpdatedAsync"
CancelRequested="CloseCharacterModals"/>

View File

@@ -282,10 +282,12 @@ public partial class Workspace : IAsyncDisposable
CreateCharacterInitialModel = new()
{
Name = string.Empty,
CampaignId = SelectedCampaignId?.ToString() ?? string.Empty
CampaignId = SelectedCampaignId?.ToString() ?? string.Empty,
OwnerUsername = string.Empty
};
CreateCharacterFormVersion++;
CanEditCharacterOwner = false;
ShowCreateCharacterModal = true;
}
@@ -295,10 +297,12 @@ public partial class Workspace : IAsyncDisposable
EditCharacterInitialModel = new()
{
Name = character.Name,
CampaignId = character.CampaignId.ToString()
CampaignId = character.CampaignId.ToString(),
OwnerUsername = string.Empty
};
EditCharacterFormVersion++;
CanEditCharacterOwner = IsCurrentUserGm;
ShowEditCharacterModal = true;
}
@@ -306,6 +310,7 @@ public partial class Workspace : IAsyncDisposable
{
ShowCreateCharacterModal = false;
ShowEditCharacterModal = false;
CanEditCharacterOwner = false;
EditingCharacterId = null;
}
@@ -375,6 +380,18 @@ public partial class Workspace : IAsyncDisposable
SetStatus("Skill updated.", false);
}
private async Task OnSkillGroupCreatedAsync(Guid _)
{
await RefreshCampaignScopeAsync();
SetStatus("Skill group created.", false);
}
private async Task OnSkillGroupUpdatedAsync(Guid _)
{
await RefreshCampaignScopeAsync();
SetStatus("Skill group updated.", false);
}
private async Task RollSkillAsync(Guid skillId)
{
if (SelectedCampaign is null)
@@ -626,6 +643,7 @@ public partial class Workspace : IAsyncDisposable
LastRoll = null;
ShowCreateCharacterModal = false;
ShowEditCharacterModal = false;
CanEditCharacterOwner = false;
CreateCharacterInitialModel = new();
EditCharacterInitialModel = new();
CreateCharacterFormVersion = 0;
@@ -702,6 +720,7 @@ public partial class Workspace : IAsyncDisposable
private bool ShowCreateCharacterModal { get; set; }
private bool ShowEditCharacterModal { get; set; }
private bool CanEditCharacterOwner { get; set; }
private Guid? EditingCharacterId { get; set; }
private CharacterFormModel CreateCharacterInitialModel { get; set; } = new();
private CharacterFormModel EditCharacterInitialModel { get; set; } = new();
@@ -728,6 +747,9 @@ public partial class Workspace : IAsyncDisposable
private List<SkillSummary> SelectedCharacterSkills =>
SelectedCampaign is null || !SelectedCharacterId.HasValue ? [] : SelectedCampaign.Skills.Where(skill => skill.CharacterId == SelectedCharacterId.Value).OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase).ToList();
private List<SkillGroupSummary> SelectedCharacterSkillGroups =>
SelectedCampaign is null || !SelectedCharacterId.HasValue ? [] : SelectedCampaign.SkillGroups.Where(group => group.CharacterId == SelectedCharacterId.Value).OrderBy(group => group.Name, StringComparer.OrdinalIgnoreCase).ToList();
private bool IsPlayScreen => string.Equals(CurrentScreen, "play", StringComparison.OrdinalIgnoreCase);
private bool IsManagementScreen => !IsPlayScreen;
private string CurrentScreenLabel => IsPlayScreen ? "Play" : "Campaign Management";

View File

@@ -18,19 +18,25 @@ public sealed record CreateCampaignRequest(string Name, string RulesetId);
public sealed record CampaignSummary(Guid Id, string Name, string RulesetId, Guid GmUserId);
public sealed record CampaignDetails(Guid Id, string Name, string RulesetId, UserSummary Gm, IReadOnlyList<CharacterSummary> Characters, IReadOnlyList<SkillSummary> Skills);
public sealed record CampaignDetails(Guid Id, string Name, string RulesetId, UserSummary Gm, IReadOnlyList<CharacterSummary> Characters, IReadOnlyList<SkillGroupSummary> SkillGroups, IReadOnlyList<SkillSummary> Skills);
public sealed record CreateCharacterRequest(string Name, Guid CampaignId);
public sealed record UpdateCharacterRequest(string Name, Guid CampaignId);
public sealed record UpdateCharacterRequest(string Name, Guid CampaignId, string? OwnerUsername = null);
public sealed record CharacterSummary(Guid Id, string Name, Guid OwnerUserId, Guid CampaignId);
public sealed record CreateSkillRequest(string Name, string DiceRollDefinition, int WildDice, bool AllowFumble);
public sealed record CreateSkillRequest(string Name, string DiceRollDefinition, int WildDice, bool AllowFumble, Guid? SkillGroupId = null);
public sealed record UpdateSkillRequest(string Name, string DiceRollDefinition, int WildDice, bool AllowFumble);
public sealed record UpdateSkillRequest(string Name, string DiceRollDefinition, int WildDice, bool AllowFumble, Guid? SkillGroupId = null);
public sealed record SkillSummary(Guid Id, Guid CharacterId, string Name, string DiceRollDefinition, int WildDice, bool AllowFumble);
public sealed record SkillGroupSummary(Guid Id, Guid CharacterId, string Name);
public sealed record CreateSkillGroupRequest(string Name);
public sealed record UpdateSkillGroupRequest(string Name);
public sealed record SkillSummary(Guid Id, Guid CharacterId, Guid? SkillGroupId, string Name, string DiceRollDefinition, int WildDice, bool AllowFumble);
public sealed record RollSkillRequest(string Visibility);
@@ -38,4 +44,4 @@ 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<RollDieResult> Dice, DateTimeOffset TimestampUtc);
public sealed record CampaignLogEntry(Guid RollId, Guid CampaignId, Guid CharacterId, Guid SkillId, Guid RollerUserId, string Visibility, int Result, string Breakdown, IReadOnlyList<RollDieResult> Dice, DateTimeOffset TimestampUtc);
public sealed record CampaignLogEntry(Guid RollId, Guid CampaignId, Guid CharacterId, Guid SkillId, Guid RollerUserId, string Visibility, int Result, string Breakdown, IReadOnlyList<RollDieResult> Dice, DateTimeOffset TimestampUtc);

View File

@@ -54,6 +54,14 @@ public sealed class RpgRollerDbContext : DbContext
entity.Property(x => x.WildDice).IsRequired();
entity.Property(x => x.AllowFumble).IsRequired();
entity.HasIndex(x => x.CharacterId);
entity.HasIndex(x => x.SkillGroupId);
});
modelBuilder.Entity<SkillGroup>(entity =>
{
entity.HasKey(x => x.Id);
entity.Property(x => x.Name).IsRequired().HasMaxLength(128);
entity.HasIndex(x => x.CharacterId);
});
modelBuilder.Entity<RollLogEntry>(entity =>
@@ -75,5 +83,6 @@ public sealed class RpgRollerDbContext : DbContext
public DbSet<Campaign> Campaigns => Set<Campaign>();
public DbSet<Character> Characters => Set<Character>();
public DbSet<Skill> Skills => Set<Skill>();
public DbSet<SkillGroup> SkillGroups => Set<SkillGroup>();
public DbSet<RollLogEntry> RollLogEntries => Set<RollLogEntry>();
}
}

View File

@@ -41,15 +41,23 @@ public sealed class Campaign
public sealed class Character
{
public required Guid Id { get; init; }
public required Guid OwnerUserId { get; init; }
public required Guid OwnerUserId { get; set; }
public required Guid CampaignId { get; set; }
public required string Name { get; set; }
}
public sealed class SkillGroup
{
public required Guid Id { get; init; }
public required Guid CharacterId { get; set; }
public required string Name { get; set; }
}
public sealed class Skill
{
public required Guid Id { get; init; }
public required Guid CharacterId { get; set; }
public Guid? SkillGroupId { get; set; }
public required string Name { get; set; }
public required string DiceRollDefinition { get; set; }
public required int WildDice { get; set; }
@@ -70,4 +78,4 @@ public sealed class RollLogEntry
public required DateTimeOffset TimestampUtc { get; init; }
}
public sealed record DiceExpression(int DiceCount, int Sides, int Modifier, string Canonical);
public sealed record DiceExpression(int DiceCount, int Sides, int Modifier, string Canonical);

View File

@@ -0,0 +1,242 @@
// <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("20260226124941_AddSkillGroupsAndCharacterOwnerTransfer")]
partial class AddSkillGroupsAndCharacterOwnerTransfer
{
/// <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<Guid>("CharacterId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
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,59 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace RpgRoller.Migrations
{
/// <inheritdoc />
public partial class AddSkillGroupsAndCharacterOwnerTransfer : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "SkillGroupId",
table: "Skills",
type: "TEXT",
nullable: true);
migrationBuilder.CreateTable(
name: "SkillGroups",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
CharacterId = table.Column<Guid>(type: "TEXT", nullable: false),
Name = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_SkillGroups", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_Skills_SkillGroupId",
table: "Skills",
column: "SkillGroupId");
migrationBuilder.CreateIndex(
name: "IX_SkillGroups_CharacterId",
table: "SkillGroups",
column: "CharacterId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "SkillGroups");
migrationBuilder.DropIndex(
name: "IX_Skills_SkillGroupId",
table: "Skills");
migrationBuilder.DropColumn(
name: "SkillGroupId",
table: "Skills");
}
}
}

View File

@@ -143,6 +143,9 @@ namespace RpgRoller.Migrations
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<Guid?>("SkillGroupId")
.HasColumnType("TEXT");
b.Property<int>("WildDice")
.HasColumnType("INTEGER");
@@ -150,9 +153,32 @@ namespace RpgRoller.Migrations
b.HasIndex("CharacterId");
b.HasIndex("SkillGroupId");
b.ToTable("Skills");
});
modelBuilder.Entity("RpgRoller.Domain.SkillGroup", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("CharacterId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CharacterId");
b.ToTable("SkillGroups");
});
modelBuilder.Entity("RpgRoller.Domain.UserAccount", b =>
{
b.Property<Guid>("Id")

View File

@@ -191,9 +191,10 @@ public sealed class GameService : IGameService
var visibleCharacterIds = characters.Select(c => c.Id).ToHashSet();
var skillGroups = m_SkillGroupsById.Values.Where(g => visibleCharacterIds.Contains(g.CharacterId)).OrderBy(g => g.Name, StringComparer.OrdinalIgnoreCase).Select(ToSkillGroupSummary).ToArray();
var skills = m_SkillsById.Values.Where(s => visibleCharacterIds.Contains(s.CharacterId)).OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase).Select(ToSkillSummary).ToArray();
return ServiceResult<CampaignDetails>.Success(new(campaign.Id, campaign.Name, DiceRules.ToRulesetId(campaign.Ruleset), ToUserSummary(gm), characters, skills));
return ServiceResult<CampaignDetails>.Success(new(campaign.Id, campaign.Name, DiceRules.ToRulesetId(campaign.Ruleset), ToUserSummary(gm), characters, skillGroups, skills));
}
}
@@ -227,7 +228,7 @@ public sealed class GameService : IGameService
}
}
public ServiceResult<CharacterSummary> UpdateCharacter(string sessionToken, Guid characterId, string name, Guid campaignId)
public ServiceResult<CharacterSummary> UpdateCharacter(string sessionToken, Guid characterId, string name, Guid campaignId, string? ownerUsername = null)
{
if (string.IsNullOrWhiteSpace(name))
return ServiceResult<CharacterSummary>.Failure("invalid_character_name", "Character name is required.");
@@ -252,9 +253,29 @@ public sealed class GameService : IGameService
return ServiceResult<CharacterSummary>.Failure("forbidden", "Only the owner or GM can edit this character.");
var sourceCampaignId = character.CampaignId;
var previousOwnerUserId = character.OwnerUserId;
character.Name = name.Trim();
character.CampaignId = campaignId;
if (!string.IsNullOrWhiteSpace(ownerUsername))
{
var trimmedOwnerUsername = ownerUsername.Trim();
var normalizedOwnerUsername = NormalizeUsername(trimmedOwnerUsername);
if (!m_UserIdsByUsername.TryGetValue(normalizedOwnerUsername, out var targetOwnerUserId))
return ServiceResult<CharacterSummary>.Failure("owner_not_found", "Owner username was not found.");
if (targetOwnerUserId != character.OwnerUserId && !isSourceGm && !isTargetGm)
return ServiceResult<CharacterSummary>.Failure("forbidden", "Only the GM can change character owner.");
character.OwnerUserId = targetOwnerUserId;
if (character.OwnerUserId != previousOwnerUserId &&
m_UsersById.TryGetValue(previousOwnerUserId, out var previousOwner) &&
previousOwner.ActiveCharacterId == character.Id)
{
previousOwner.ActiveCharacterId = null;
}
}
TouchCampaignLocked(sourceCampaignId);
if (sourceCampaignId != character.CampaignId)
TouchCampaignLocked(character.CampaignId);
@@ -301,7 +322,67 @@ public sealed class GameService : IGameService
}
}
public ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble)
public ServiceResult<SkillGroupSummary> CreateSkillGroup(string sessionToken, Guid characterId, string name)
{
if (string.IsNullOrWhiteSpace(name))
return ServiceResult<SkillGroupSummary>.Failure("invalid_skill_group_name", "Skill group name is required.");
lock (m_Gate)
{
var user = ResolveUserLocked(sessionToken);
if (user is null)
return ServiceResult<SkillGroupSummary>.Failure("unauthorized", "You must be logged in.");
if (!m_CharactersById.TryGetValue(characterId, out var character))
return ServiceResult<SkillGroupSummary>.Failure("character_not_found", "Character was not found.");
var campaign = m_CampaignsById[character.CampaignId];
if (!CanEditCharacterLocked(user.Id, character, campaign))
return ServiceResult<SkillGroupSummary>.Failure("forbidden", "Only the owner or GM can manage skill groups.");
var group = new SkillGroup
{
Id = Guid.NewGuid(),
CharacterId = character.Id,
Name = name.Trim()
};
m_SkillGroupsById[group.Id] = group;
TouchCampaignLocked(campaign.Id);
PersistStateLocked();
return ServiceResult<SkillGroupSummary>.Success(ToSkillGroupSummary(group));
}
}
public ServiceResult<SkillGroupSummary> UpdateSkillGroup(string sessionToken, Guid skillGroupId, string name)
{
if (string.IsNullOrWhiteSpace(name))
return ServiceResult<SkillGroupSummary>.Failure("invalid_skill_group_name", "Skill group name is required.");
lock (m_Gate)
{
var user = ResolveUserLocked(sessionToken);
if (user is null)
return ServiceResult<SkillGroupSummary>.Failure("unauthorized", "You must be logged in.");
if (!m_SkillGroupsById.TryGetValue(skillGroupId, out var group))
return ServiceResult<SkillGroupSummary>.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<SkillGroupSummary>.Failure("forbidden", "Only the owner or GM can manage skill groups.");
group.Name = name.Trim();
TouchCampaignLocked(campaign.Id);
PersistStateLocked();
return ServiceResult<SkillGroupSummary>.Success(ToSkillGroupSummary(group));
}
}
public ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null)
{
if (string.IsNullOrWhiteSpace(name))
return ServiceResult<SkillSummary>.Failure("invalid_skill_name", "Skill name is required.");
@@ -327,10 +408,15 @@ public sealed class GameService : IGameService
if (!optionsValidation.Succeeded)
return ServiceResult<SkillSummary>.Failure(optionsValidation.Error!.Code, optionsValidation.Error.Message);
var resolvedSkillGroupId = ResolveSkillGroupForSkillChangeLocked(skillGroupId, character.Id);
if (!resolvedSkillGroupId.Succeeded)
return ServiceResult<SkillSummary>.Failure(resolvedSkillGroupId.Error!.Code, resolvedSkillGroupId.Error.Message);
var skill = new Skill
{
Id = Guid.NewGuid(),
CharacterId = character.Id,
SkillGroupId = resolvedSkillGroupId.Value,
Name = name.Trim(),
DiceRollDefinition = expressionValidation.Value!.Canonical,
WildDice = optionsValidation.Value!.WildDice,
@@ -345,7 +431,7 @@ public sealed class GameService : IGameService
}
}
public ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble)
public ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null)
{
if (string.IsNullOrWhiteSpace(name))
return ServiceResult<SkillSummary>.Failure("invalid_skill_name", "Skill name is required.");
@@ -372,10 +458,15 @@ public sealed class GameService : IGameService
if (!optionsValidation.Succeeded)
return ServiceResult<SkillSummary>.Failure(optionsValidation.Error!.Code, optionsValidation.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.SkillGroupId = resolvedSkillGroupId.Value;
TouchCampaignLocked(campaign.Id);
PersistStateLocked();
@@ -584,6 +675,20 @@ public sealed class GameService : IGameService
return $"{dicePart}{modifierPart}={total}";
}
private ServiceResult<Guid?> ResolveSkillGroupForSkillChangeLocked(Guid? requestedSkillGroupId, Guid characterId)
{
if (!requestedSkillGroupId.HasValue)
return ServiceResult<Guid?>.Success(null);
if (!m_SkillGroupsById.TryGetValue(requestedSkillGroupId.Value, out var skillGroup))
return ServiceResult<Guid?>.Failure("skill_group_not_found", "Skill group was not found.");
if (skillGroup.CharacterId != characterId)
return ServiceResult<Guid?>.Failure("invalid_skill_group", "Skill group must belong to the same character.");
return ServiceResult<Guid?>.Success(skillGroup.Id);
}
private ServiceResult<RollVisibility> ParseVisibility(string visibility)
{
if (string.Equals(visibility, "public", StringComparison.OrdinalIgnoreCase))
@@ -625,9 +730,14 @@ public sealed class GameService : IGameService
return new(character.Id, character.Name, character.OwnerUserId, character.CampaignId);
}
private static SkillGroupSummary ToSkillGroupSummary(SkillGroup skillGroup)
{
return new(skillGroup.Id, skillGroup.CharacterId, skillGroup.Name);
}
private static SkillSummary ToSkillSummary(Skill skill)
{
return new(skill.Id, skill.CharacterId, skill.Name, skill.DiceRollDefinition, skill.WildDice, skill.AllowFumble);
return new(skill.Id, skill.CharacterId, skill.SkillGroupId, skill.Name, skill.DiceRollDefinition, skill.WildDice, skill.AllowFumble);
}
private static RollResult ToRollResult(RollLogEntry entry, IReadOnlyList<RollDieResult> dice)
@@ -731,6 +841,7 @@ public sealed class GameService : IGameService
var sessions = db.Sessions.AsNoTracking().ToList();
var campaigns = db.Campaigns.AsNoTracking().ToList();
var characters = db.Characters.AsNoTracking().ToList();
var skillGroups = db.SkillGroups.AsNoTracking().ToList();
var skills = db.Skills.AsNoTracking().ToList();
var logEntries = db.RollLogEntries.AsNoTracking().ToList().OrderBy(x => x.TimestampUtc).ThenBy(x => x.Id).ToList();
@@ -741,6 +852,7 @@ public sealed class GameService : IGameService
m_SessionsByToken.Clear();
m_CampaignsById.Clear();
m_CharactersById.Clear();
m_SkillGroupsById.Clear();
m_SkillsById.Clear();
m_RollLog.Clear();
@@ -774,6 +886,9 @@ public sealed class GameService : IGameService
foreach (var character in characters)
m_CharactersById[character.Id] = CloneCharacter(character);
foreach (var skillGroup in skillGroups)
m_SkillGroupsById[skillGroup.Id] = CloneSkillGroup(skillGroup);
foreach (var skill in skills)
m_SkillsById[skill.Id] = CloneSkill(skill);
@@ -788,6 +903,7 @@ public sealed class GameService : IGameService
db.RollLogEntries.ExecuteDelete();
db.Skills.ExecuteDelete();
db.SkillGroups.ExecuteDelete();
db.Characters.ExecuteDelete();
db.Campaigns.ExecuteDelete();
db.Sessions.ExecuteDelete();
@@ -797,6 +913,7 @@ public sealed class GameService : IGameService
db.Sessions.AddRange(m_SessionsByToken.Values.Select(CloneSession));
db.Campaigns.AddRange(m_CampaignsById.Values.Select(CloneCampaign));
db.Characters.AddRange(m_CharactersById.Values.Select(CloneCharacter));
db.SkillGroups.AddRange(m_SkillGroupsById.Values.Select(CloneSkillGroup));
db.Skills.AddRange(m_SkillsById.Values.Select(CloneSkill));
db.RollLogEntries.AddRange(m_RollLog.Select(CloneRollLogEntry));
@@ -861,6 +978,7 @@ public sealed class GameService : IGameService
{
Id = skill.Id,
CharacterId = skill.CharacterId,
SkillGroupId = skill.SkillGroupId,
Name = skill.Name,
DiceRollDefinition = skill.DiceRollDefinition,
WildDice = skill.WildDice,
@@ -868,6 +986,16 @@ public sealed class GameService : IGameService
};
}
private static SkillGroup CloneSkillGroup(SkillGroup skillGroup)
{
return new()
{
Id = skillGroup.Id,
CharacterId = skillGroup.CharacterId,
Name = skillGroup.Name
};
}
private static RollLogEntry CloneRollLogEntry(RollLogEntry entry)
{
return new()
@@ -894,7 +1022,8 @@ public sealed class GameService : IGameService
private readonly IPasswordHasher<UserAccount> m_PasswordHasher;
private readonly List<RollLogEntry> m_RollLog = [];
private readonly Dictionary<string, UserSession> m_SessionsByToken = new(StringComparer.Ordinal);
private readonly Dictionary<Guid, SkillGroup> m_SkillGroupsById = [];
private readonly Dictionary<Guid, Skill> m_SkillsById = [];
private readonly Dictionary<string, Guid> m_UserIdsByUsername = new(StringComparer.Ordinal);
private readonly Dictionary<Guid, UserAccount> m_UsersById = [];
}
}

View File

@@ -17,15 +17,17 @@ public interface IGameService
ServiceResult<CampaignDetails> GetCampaign(string sessionToken, Guid campaignId);
ServiceResult<CharacterSummary> CreateCharacter(string sessionToken, string name, Guid campaignId);
ServiceResult<CharacterSummary> UpdateCharacter(string sessionToken, Guid characterId, string name, Guid campaignId);
ServiceResult<CharacterSummary> UpdateCharacter(string sessionToken, Guid characterId, string name, Guid campaignId, string? ownerUsername = null);
ServiceResult<bool> ActivateCharacter(string sessionToken, Guid characterId);
ServiceResult<IReadOnlyList<CharacterSummary>> GetCurrentCampaignCharacters(string sessionToken);
ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble);
ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble);
ServiceResult<SkillGroupSummary> CreateSkillGroup(string sessionToken, Guid characterId, string name);
ServiceResult<SkillGroupSummary> UpdateSkillGroup(string sessionToken, Guid skillGroupId, string name);
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<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility);
ServiceResult<IReadOnlyList<CampaignLogEntry>> GetCampaignLog(string sessionToken, Guid campaignId);
ServiceResult<long> GetCampaignVersion(string sessionToken, Guid campaignId);
}
}

View File

@@ -330,6 +330,18 @@ select:focus-visible {
gap: 0.35rem;
}
.skill-group-block {
display: grid;
gap: 0.35rem;
}
.skill-group-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.skill-item {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;