Add skill groups and GM character owner transfer across stack
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
242
RpgRoller/Migrations/20260226124941_AddSkillGroupsAndCharacterOwnerTransfer.Designer.cs
generated
Normal file
242
RpgRoller/Migrations/20260226124941_AddSkillGroupsAndCharacterOwnerTransfer.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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 = [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user