Support character unlinking and global campaign options

This commit is contained in:
2026-02-26 17:50:08 +01:00
parent 6f94b1ba95
commit ac5acd77f0
12 changed files with 176 additions and 31 deletions

View File

@@ -19,6 +19,12 @@ internal static class CampaignEndpoints
return ApiResultMapper.ToApiResult(result);
});
group.MapGet("/campaigns/options", (HttpContext context, IGameService game) =>
{
var result = game.GetCharacterCampaignOptions(context.GetRequiredSessionToken());
return ApiResultMapper.ToApiResult(result);
});
group.MapGet("/campaigns/{campaignId:guid}", (Guid campaignId, HttpContext context, IGameService game) =>
{
var result = game.GetCampaign(context.GetRequiredSessionToken(), campaignId);

View File

@@ -16,8 +16,8 @@
}
<label for="@CampaignInputId">Campaign</label>
<select id="@CampaignInputId" @bind="FormState.Model.CampaignId">
<option value="">Select campaign</option>
@foreach (var campaign in Campaigns)
<option value="">@(EditingCharacterId.HasValue ? "No campaign" : "Select campaign")</option>
@foreach (var campaign in CampaignOptions)
{
<option value="@campaign.Id">@campaign.Name</option>
}

View File

@@ -26,7 +26,16 @@ public partial class CharacterFormModal
if (string.IsNullOrWhiteSpace(FormState.Model.Name))
FormState.Errors["name"] = "Character name is required.";
if (!Guid.TryParse(FormState.Model.CampaignId, out var campaignId))
Guid? campaignId = null;
if (!string.IsNullOrWhiteSpace(FormState.Model.CampaignId))
{
if (!Guid.TryParse(FormState.Model.CampaignId, out var parsedCampaignId))
FormState.Errors["campaignId"] = "Campaign selection is invalid.";
else
campaignId = parsedCampaignId;
}
if (!EditingCharacterId.HasValue && !campaignId.HasValue)
FormState.Errors["campaignId"] = "Campaign is required.";
if (FormState.Errors.Count > 0)
@@ -46,16 +55,10 @@ public partial class CharacterFormModal
}
else
{
character = await ApiClient.RequestAsync<CharacterSummary>("POST", "/api/characters", new CreateCharacterRequest(FormState.Model.Name.Trim(), campaignId));
character = await ApiClient.RequestAsync<CharacterSummary>("POST", "/api/characters", new CreateCharacterRequest(FormState.Model.Name.Trim(), campaignId!.Value));
}
if (!character.CampaignId.HasValue)
{
FormState.ErrorMessage = "Character must belong to a campaign.";
return;
}
await CharacterSaved.InvokeAsync(character.CampaignId.Value);
await CharacterSaved.InvokeAsync(character.CampaignId);
}
catch (ApiRequestException ex)
{
@@ -102,7 +105,7 @@ public partial class CharacterFormModal
public Guid? EditingCharacterId { get; set; }
[Parameter]
public IReadOnlyList<CampaignDetails> Campaigns { get; set; } = [];
public IReadOnlyList<CampaignOption> CampaignOptions { get; set; } = [];
[Parameter]
public bool IsMutating { get; set; }
@@ -114,7 +117,7 @@ public partial class CharacterFormModal
public IReadOnlyList<string> AvailableUsernames { get; set; } = [];
[Parameter]
public EventCallback<Guid> CharacterSaved { get; set; }
public EventCallback<Guid?> CharacterSaved { get; set; }
[Parameter]
public EventCallback CancelRequested { get; set; }

View File

@@ -159,7 +159,7 @@
InitialModel="CreateCharacterInitialModel"
FormVersion="CreateCharacterFormVersion"
EditingCharacterId="null"
Campaigns="Campaigns"
CampaignOptions="CharacterCampaignOptions"
IsMutating="IsMutating"
AllowOwnerEdit="false"
AvailableUsernames="KnownUsernames"
@@ -176,7 +176,7 @@
InitialModel="EditCharacterInitialModel"
FormVersion="EditCharacterFormVersion"
EditingCharacterId="EditingCharacterId"
Campaigns="Campaigns"
CampaignOptions="CharacterCampaignOptions"
IsMutating="IsMutating"
AllowOwnerEdit="CanEditCharacterOwner"
AvailableUsernames="KnownUsernames"

View File

@@ -118,6 +118,7 @@ public partial class Workspace : IAsyncDisposable
ActiveCharacterId = me.ActiveCharacterId;
await ReloadCampaignsAsync(preferredCampaignId ?? me.CurrentCampaignId);
await ReloadCharacterCampaignOptionsAsync();
await RefreshCampaignScopeAsync();
await SyncStateEventsAsync();
return true;
@@ -156,6 +157,12 @@ public partial class Workspace : IAsyncDisposable
await JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", CampaignSessionKey, SelectedCampaignId?.ToString());
}
private async Task ReloadCharacterCampaignOptionsAsync()
{
var campaignOptions = await ApiClient.RequestAsync<IReadOnlyList<CampaignOption>>("GET", "/api/campaigns/options");
CharacterCampaignOptions = campaignOptions.OrderBy(campaign => campaign.Name, StringComparer.OrdinalIgnoreCase).ToList();
}
private async Task RefreshCampaignScopeAsync()
{
if (!SelectedCampaignId.HasValue)
@@ -269,6 +276,7 @@ public partial class Workspace : IAsyncDisposable
private async Task OnCampaignCreatedAsync(Guid campaignId)
{
await ReloadCampaignsAsync(campaignId);
await ReloadCharacterCampaignOptionsAsync();
await RefreshCampaignScopeAsync();
await SyncStateEventsAsync();
SetStatus("Campaign created.", false);
@@ -279,7 +287,7 @@ public partial class Workspace : IAsyncDisposable
CreateCharacterInitialModel = new()
{
Name = string.Empty,
CampaignId = SelectedCampaignId?.ToString() ?? string.Empty,
CampaignId = SelectedCampaignId?.ToString() ?? CharacterCampaignOptions.FirstOrDefault()?.Id.ToString() ?? string.Empty,
OwnerUsername = string.Empty
};
@@ -290,7 +298,7 @@ public partial class Workspace : IAsyncDisposable
private async Task OpenEditCharacterModal(CharacterSummary character)
{
if (IsCurrentUserGm)
if (IsCurrentUserGm || IsCurrentUserAdmin)
await LoadKnownUsernamesAsync();
EditingCharacterId = character.Id;
@@ -302,7 +310,7 @@ public partial class Workspace : IAsyncDisposable
};
EditCharacterFormVersion++;
CanEditCharacterOwner = IsCurrentUserGm;
CanEditCharacterOwner = IsCurrentUserGm || IsCurrentUserAdmin;
ShowEditCharacterModal = true;
}
@@ -314,22 +322,24 @@ public partial class Workspace : IAsyncDisposable
EditingCharacterId = null;
}
private async Task OnCharacterCreatedAsync(Guid campaignId)
private async Task OnCharacterCreatedAsync(Guid? campaignId)
{
CloseCharacterModals();
await ReloadCampaignsAsync(campaignId);
await ReloadCharacterCampaignOptionsAsync();
await RefreshCampaignScopeAsync();
await SyncStateEventsAsync();
SetStatus("Character created.", false);
}
private async Task OnCharacterUpdatedAsync(Guid campaignId)
private async Task OnCharacterUpdatedAsync(Guid? campaignId)
{
CloseCharacterModals();
await ReloadCampaignsAsync(campaignId);
await ReloadCharacterCampaignOptionsAsync();
await RefreshCampaignScopeAsync();
await SyncStateEventsAsync();
SetStatus("Character updated.", false);
SetStatus(campaignId.HasValue ? "Character updated." : "Character unlinked from campaign.", false);
}
private async Task DeleteSelectedCampaignAsync()
@@ -346,6 +356,7 @@ public partial class Workspace : IAsyncDisposable
{
_ = await ApiClient.RequestAsync<bool>("DELETE", $"/api/campaigns/{SelectedCampaign.Id}");
await ReloadCampaignsAsync(null);
await ReloadCharacterCampaignOptionsAsync();
await RefreshCampaignScopeAsync();
await SyncStateEventsAsync();
SetStatus("Campaign deleted.", false);
@@ -368,7 +379,7 @@ public partial class Workspace : IAsyncDisposable
private bool CanEditCharacter(CharacterSummary character)
{
return User is not null && (character.OwnerUserId == User.Id || IsCurrentUserGm);
return User is not null && (character.OwnerUserId == User.Id || IsCurrentUserGm || IsCurrentUserAdmin);
}
private static bool CanActivateCharacter(CharacterSummary character, UserSummary? user)
@@ -674,6 +685,7 @@ public partial class Workspace : IAsyncDisposable
SelectedCampaignId = null;
SelectedCampaign = null;
Campaigns = [];
CharacterCampaignOptions = [];
CampaignLog = [];
SelectedCharacterId = null;
LastRoll = null;
@@ -738,6 +750,7 @@ public partial class Workspace : IAsyncDisposable
private Guid? SelectedCampaignId { get; set; }
private CampaignDetails? SelectedCampaign { get; set; }
private List<CampaignDetails> Campaigns { get; set; } = [];
private List<CampaignOption> CharacterCampaignOptions { get; set; } = [];
private List<CampaignLogEntry> CampaignLog { get; set; } = [];
private List<RulesetDefinition> Rulesets { get; set; } = [];
private Guid? SelectedCharacterId { get; set; }

View File

@@ -22,9 +22,11 @@ public sealed record CreateCampaignRequest(string Name, string RulesetId);
public sealed record CampaignDetails(Guid Id, string Name, string RulesetId, UserSummary Gm, IReadOnlyList<CharacterSummary> Characters, IReadOnlyList<SkillGroupSummary> SkillGroups, IReadOnlyList<SkillSummary> Skills);
public sealed record CampaignOption(Guid Id, string Name);
public sealed record CreateCharacterRequest(string Name, Guid CampaignId);
public sealed record UpdateCharacterRequest(string Name, Guid CampaignId, string? OwnerUsername = null);
public sealed record UpdateCharacterRequest(string Name, Guid? CampaignId, string? OwnerUsername = null);
public sealed record CharacterSummary(Guid Id, string Name, Guid OwnerUserId, Guid? CampaignId, string OwnerDisplayName);

View File

@@ -186,6 +186,23 @@ public sealed class GameService : IGameService
}
}
public ServiceResult<IReadOnlyList<CampaignOption>> GetCharacterCampaignOptions(string sessionToken)
{
lock (m_Gate)
{
var user = ResolveUserLocked(sessionToken);
if (user is null)
return ServiceResult<IReadOnlyList<CampaignOption>>.Failure("unauthorized", "You must be logged in.");
var options = m_CampaignsById.Values
.OrderBy(campaign => campaign.Name, StringComparer.OrdinalIgnoreCase)
.Select(ToCampaignOption)
.ToArray();
return ServiceResult<IReadOnlyList<CampaignOption>>.Success(options);
}
}
public ServiceResult<CampaignDetails> GetCampaign(string sessionToken, Guid campaignId)
{
lock (m_Gate)
@@ -355,7 +372,7 @@ public sealed class GameService : IGameService
}
}
public ServiceResult<CharacterSummary> UpdateCharacter(string sessionToken, Guid characterId, string name, Guid campaignId, string? ownerUsername = null)
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.");
@@ -369,16 +386,18 @@ public sealed class GameService : IGameService
if (!m_CharactersById.TryGetValue(characterId, out var character))
return ServiceResult<CharacterSummary>.Failure("character_not_found", "Character was not found.");
if (!m_CampaignsById.TryGetValue(campaignId, out var targetCampaign))
Campaign? targetCampaign = null;
if (campaignId.HasValue && !m_CampaignsById.TryGetValue(campaignId.Value, out targetCampaign))
return ServiceResult<CharacterSummary>.Failure("campaign_not_found", "Campaign was not found.");
var isOwner = character.OwnerUserId == user.Id;
var isAdmin = UserHasRoleLocked(user, UserRoles.Admin);
var isSourceGm = character.CampaignId.HasValue &&
m_CampaignsById.TryGetValue(character.CampaignId.Value, out var sourceCampaign) &&
sourceCampaign.GmUserId == user.Id;
var isTargetGm = targetCampaign.GmUserId == user.Id;
if (!isOwner && !isSourceGm && !isTargetGm)
return ServiceResult<CharacterSummary>.Failure("forbidden", "Only the owner or GM can edit this character.");
var isTargetGm = targetCampaign is not null && targetCampaign.GmUserId == user.Id;
if (!isOwner && !isAdmin && !isSourceGm && !isTargetGm)
return ServiceResult<CharacterSummary>.Failure("forbidden", "Only the owner, GM, or admin can edit this character.");
var sourceCampaignId = character.CampaignId;
var previousOwnerUserId = character.OwnerUserId;
@@ -392,8 +411,8 @@ public sealed class GameService : IGameService
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.");
if (targetOwnerUserId != character.OwnerUserId && !isAdmin && !isSourceGm && !isTargetGm)
return ServiceResult<CharacterSummary>.Failure("forbidden", "Only the GM or admin can change character owner.");
character.OwnerUserId = targetOwnerUserId;
if (character.OwnerUserId != previousOwnerUserId &&
@@ -934,6 +953,11 @@ public sealed class GameService : IGameService
return new(user.Id, user.Username, user.DisplayName, ParseRoles(user.Roles));
}
private static CampaignOption ToCampaignOption(Campaign campaign)
{
return new(campaign.Id, campaign.Name);
}
private CampaignDetails ToCampaignDetails(Campaign campaign)
{
lock (m_Gate)

View File

@@ -14,6 +14,7 @@ public interface IGameService
ServiceResult<CampaignDetails> CreateCampaign(string sessionToken, string name, string rulesetId);
ServiceResult<IReadOnlyList<CampaignDetails>> GetCampaigns(string sessionToken);
ServiceResult<IReadOnlyList<CampaignOption>> GetCharacterCampaignOptions(string sessionToken);
ServiceResult<CampaignDetails> GetCampaign(string sessionToken, Guid campaignId);
ServiceResult<bool> DeleteCampaign(string sessionToken, Guid campaignId);
ServiceResult<IReadOnlyList<string>> GetUsernames(string sessionToken);
@@ -22,7 +23,7 @@ public interface IGameService
ServiceResult<bool> DeleteUser(string sessionToken, Guid userId);
ServiceResult<CharacterSummary> CreateCharacter(string sessionToken, string name, Guid campaignId);
ServiceResult<CharacterSummary> UpdateCharacter(string sessionToken, Guid characterId, string name, Guid campaignId, string? ownerUsername = null);
ServiceResult<CharacterSummary> UpdateCharacter(string sessionToken, Guid characterId, string name, Guid? campaignId, string? ownerUsername = null);
ServiceResult<bool> ActivateCharacter(string sessionToken, Guid characterId);
ServiceResult<IReadOnlyList<CharacterSummary>> GetOwnCharacters(string sessionToken);