Support character unlinking and global campaign options
This commit is contained in:
@@ -54,6 +54,7 @@ Gameplay capabilities now include:
|
|||||||
- Role-aware authorization with admin role support (including admin user/role management)
|
- Role-aware authorization with admin role support (including admin user/role management)
|
||||||
- Campaign deletion by campaign owner or admin (unlinks characters from the campaign and clears campaign log entries)
|
- Campaign deletion by campaign owner or admin (unlinks characters from the campaign and clears campaign log entries)
|
||||||
- Campaign management owner labels use account display names (no GUID fallback rendering)
|
- Campaign management owner labels use account display names (no GUID fallback rendering)
|
||||||
|
- Character edit flow supports unlinking from campaigns (owner/GM/admin) and assigning to any existing campaign via expanded campaign options
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
|
|||||||
@@ -159,4 +159,33 @@ public sealed class CampaignApiTests : ApiTestBase
|
|||||||
Assert.DoesNotContain(usersAfterDelete, user => user.Id == player.Id);
|
Assert.DoesNotContain(usersAfterDelete, user => user.Id == player.Id);
|
||||||
Assert.Contains(usersAfterDelete, user => user.Id == gm.Id);
|
Assert.Contains(usersAfterDelete, user => user.Id == gm.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CampaignOptionsEndpoint_ReturnsCampaignsBeyondVisibleCampaignList()
|
||||||
|
{
|
||||||
|
using var factory = CreateFactory(6, 5, 4);
|
||||||
|
using var gmClient = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||||
|
using var otherGmClient = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||||
|
using var playerClient = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||||
|
|
||||||
|
await RegisterAsync(gmClient, "gm-options-1", "Password123", "GM One");
|
||||||
|
await LoginAsync(gmClient, "gm-options-1", "Password123");
|
||||||
|
|
||||||
|
await RegisterAsync(otherGmClient, "gm-options-2", "Password123", "GM Two");
|
||||||
|
await LoginAsync(otherGmClient, "gm-options-2", "Password123");
|
||||||
|
|
||||||
|
await RegisterAsync(playerClient, "player-options", "Password123", "Player");
|
||||||
|
await LoginAsync(playerClient, "player-options", "Password123");
|
||||||
|
|
||||||
|
var firstCampaign = await PostAsync<CreateCampaignRequest, CampaignDetails>(gmClient, "/api/campaigns", new("Alpha Visible", "d6"));
|
||||||
|
var secondCampaign = await PostAsync<CreateCampaignRequest, CampaignDetails>(otherGmClient, "/api/campaigns", new("Beta Available", "d6"));
|
||||||
|
|
||||||
|
var playerVisibleCampaigns = await GetAsync<IReadOnlyList<CampaignDetails>>(playerClient, "/api/campaigns");
|
||||||
|
Assert.Empty(playerVisibleCampaigns);
|
||||||
|
|
||||||
|
var playerCampaignOptions = await GetAsync<IReadOnlyList<CampaignOption>>(playerClient, "/api/campaigns/options");
|
||||||
|
Assert.Equal(2, playerCampaignOptions.Count);
|
||||||
|
Assert.Contains(playerCampaignOptions, option => option.Id == firstCampaign.Id);
|
||||||
|
Assert.Contains(playerCampaignOptions, option => option.Id == secondCampaign.Id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,6 +71,30 @@ public sealed class ServiceCampaignTests
|
|||||||
Assert.Equal(gmCampaign.Id, campaigns[0].Id);
|
Assert.Equal(gmCampaign.Id, campaigns[0].Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetCharacterCampaignOptions_ReturnsAllCampaignsForCharacterAssignment()
|
||||||
|
{
|
||||||
|
using var harness = ServiceTestSupport.CreateHarness();
|
||||||
|
var service = harness.Service;
|
||||||
|
service.Register("gm1", "Password123", "GM One");
|
||||||
|
service.Register("gm2", "Password123", "GM Two");
|
||||||
|
service.Register("player", "Password123", "Player");
|
||||||
|
var gmSession = ServiceTestSupport.GetValue(service.Login("gm1", "Password123")).SessionToken;
|
||||||
|
var gmTwoSession = ServiceTestSupport.GetValue(service.Login("gm2", "Password123")).SessionToken;
|
||||||
|
var playerSession = ServiceTestSupport.GetValue(service.Login("player", "Password123")).SessionToken;
|
||||||
|
|
||||||
|
var firstCampaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Alpha", "d6"));
|
||||||
|
var secondCampaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmTwoSession, "Beta", "d6"));
|
||||||
|
|
||||||
|
var visibleCampaigns = ServiceTestSupport.GetValue(service.GetCampaigns(playerSession));
|
||||||
|
Assert.Empty(visibleCampaigns);
|
||||||
|
|
||||||
|
var options = ServiceTestSupport.GetValue(service.GetCharacterCampaignOptions(playerSession));
|
||||||
|
Assert.Equal(2, options.Count);
|
||||||
|
Assert.Contains(options, option => option.Id == firstCampaign.Id);
|
||||||
|
Assert.Contains(options, option => option.Id == secondCampaign.Id);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void GetCampaign_ForNonGmParticipant_ReturnsCampaignCharactersAndSkills()
|
public void GetCampaign_ForNonGmParticipant_ReturnsCampaignCharactersAndSkills()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -94,4 +94,46 @@ public sealed class ServiceSkillGroupAndOwnershipTests
|
|||||||
Assert.False(service.ActivateCharacter(ownerSession, character.Id).Succeeded);
|
Assert.False(service.ActivateCharacter(ownerSession, character.Id).Succeeded);
|
||||||
Assert.True(service.ActivateCharacter(receiverSession, character.Id).Succeeded);
|
Assert.True(service.ActivateCharacter(receiverSession, character.Id).Succeeded);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CharacterUnlink_AllowsOwnerGmAndAdmin()
|
||||||
|
{
|
||||||
|
using var harness = ServiceTestSupport.CreateHarness();
|
||||||
|
var service = harness.Service;
|
||||||
|
|
||||||
|
service.Register("gm", "Password123", "GM");
|
||||||
|
service.Register("owner", "Password123", "Owner");
|
||||||
|
service.Register("outsider", "Password123", "Outsider");
|
||||||
|
service.Register("admin2", "Password123", "Admin Two");
|
||||||
|
|
||||||
|
var gmSession = ServiceTestSupport.GetValue(service.Login("gm", "Password123")).SessionToken;
|
||||||
|
var ownerSession = ServiceTestSupport.GetValue(service.Login("owner", "Password123")).SessionToken;
|
||||||
|
var outsiderSession = ServiceTestSupport.GetValue(service.Login("outsider", "Password123")).SessionToken;
|
||||||
|
var adminTwoSession = ServiceTestSupport.GetValue(service.Login("admin2", "Password123")).SessionToken;
|
||||||
|
|
||||||
|
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Main", "d6"));
|
||||||
|
var character = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Unlink Me", campaign.Id));
|
||||||
|
|
||||||
|
var outsiderUnlink = service.UpdateCharacter(outsiderSession, character.Id, "Unlink Me", null);
|
||||||
|
Assert.False(outsiderUnlink.Succeeded);
|
||||||
|
|
||||||
|
var ownerUnlink = ServiceTestSupport.GetValue(service.UpdateCharacter(ownerSession, character.Id, "Owner Unlink", null));
|
||||||
|
Assert.Null(ownerUnlink.CampaignId);
|
||||||
|
|
||||||
|
var relinkByOwner = ServiceTestSupport.GetValue(service.UpdateCharacter(ownerSession, character.Id, "Relink", campaign.Id));
|
||||||
|
Assert.Equal(campaign.Id, relinkByOwner.CampaignId);
|
||||||
|
|
||||||
|
var gmUnlink = ServiceTestSupport.GetValue(service.UpdateCharacter(gmSession, character.Id, "Gm Unlink", null));
|
||||||
|
Assert.Null(gmUnlink.CampaignId);
|
||||||
|
|
||||||
|
var relinkByGm = ServiceTestSupport.GetValue(service.UpdateCharacter(gmSession, character.Id, "Relink Again", campaign.Id));
|
||||||
|
Assert.Equal(campaign.Id, relinkByGm.CampaignId);
|
||||||
|
|
||||||
|
var adminTwo = service.GetUserBySession(adminTwoSession);
|
||||||
|
Assert.NotNull(adminTwo);
|
||||||
|
_ = ServiceTestSupport.GetValue(service.UpdateUserRoles(gmSession, adminTwo!.Id, [ "admin" ]));
|
||||||
|
|
||||||
|
var adminUnlink = ServiceTestSupport.GetValue(service.UpdateCharacter(adminTwoSession, character.Id, "Admin Unlink", null));
|
||||||
|
Assert.Null(adminUnlink.CampaignId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ internal static class CampaignEndpoints
|
|||||||
return ApiResultMapper.ToApiResult(result);
|
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) =>
|
group.MapGet("/campaigns/{campaignId:guid}", (Guid campaignId, HttpContext context, IGameService game) =>
|
||||||
{
|
{
|
||||||
var result = game.GetCampaign(context.GetRequiredSessionToken(), campaignId);
|
var result = game.GetCampaign(context.GetRequiredSessionToken(), campaignId);
|
||||||
|
|||||||
@@ -16,8 +16,8 @@
|
|||||||
}
|
}
|
||||||
<label for="@CampaignInputId">Campaign</label>
|
<label for="@CampaignInputId">Campaign</label>
|
||||||
<select id="@CampaignInputId" @bind="FormState.Model.CampaignId">
|
<select id="@CampaignInputId" @bind="FormState.Model.CampaignId">
|
||||||
<option value="">Select campaign</option>
|
<option value="">@(EditingCharacterId.HasValue ? "No campaign" : "Select campaign")</option>
|
||||||
@foreach (var campaign in Campaigns)
|
@foreach (var campaign in CampaignOptions)
|
||||||
{
|
{
|
||||||
<option value="@campaign.Id">@campaign.Name</option>
|
<option value="@campaign.Id">@campaign.Name</option>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,16 @@ public partial class CharacterFormModal
|
|||||||
if (string.IsNullOrWhiteSpace(FormState.Model.Name))
|
if (string.IsNullOrWhiteSpace(FormState.Model.Name))
|
||||||
FormState.Errors["name"] = "Character name is required.";
|
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.";
|
FormState.Errors["campaignId"] = "Campaign is required.";
|
||||||
|
|
||||||
if (FormState.Errors.Count > 0)
|
if (FormState.Errors.Count > 0)
|
||||||
@@ -46,16 +55,10 @@ public partial class CharacterFormModal
|
|||||||
}
|
}
|
||||||
else
|
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)
|
await CharacterSaved.InvokeAsync(character.CampaignId);
|
||||||
{
|
|
||||||
FormState.ErrorMessage = "Character must belong to a campaign.";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await CharacterSaved.InvokeAsync(character.CampaignId.Value);
|
|
||||||
}
|
}
|
||||||
catch (ApiRequestException ex)
|
catch (ApiRequestException ex)
|
||||||
{
|
{
|
||||||
@@ -102,7 +105,7 @@ public partial class CharacterFormModal
|
|||||||
public Guid? EditingCharacterId { get; set; }
|
public Guid? EditingCharacterId { get; set; }
|
||||||
|
|
||||||
[Parameter]
|
[Parameter]
|
||||||
public IReadOnlyList<CampaignDetails> Campaigns { get; set; } = [];
|
public IReadOnlyList<CampaignOption> CampaignOptions { get; set; } = [];
|
||||||
|
|
||||||
[Parameter]
|
[Parameter]
|
||||||
public bool IsMutating { get; set; }
|
public bool IsMutating { get; set; }
|
||||||
@@ -114,7 +117,7 @@ public partial class CharacterFormModal
|
|||||||
public IReadOnlyList<string> AvailableUsernames { get; set; } = [];
|
public IReadOnlyList<string> AvailableUsernames { get; set; } = [];
|
||||||
|
|
||||||
[Parameter]
|
[Parameter]
|
||||||
public EventCallback<Guid> CharacterSaved { get; set; }
|
public EventCallback<Guid?> CharacterSaved { get; set; }
|
||||||
|
|
||||||
[Parameter]
|
[Parameter]
|
||||||
public EventCallback CancelRequested { get; set; }
|
public EventCallback CancelRequested { get; set; }
|
||||||
|
|||||||
@@ -159,7 +159,7 @@
|
|||||||
InitialModel="CreateCharacterInitialModel"
|
InitialModel="CreateCharacterInitialModel"
|
||||||
FormVersion="CreateCharacterFormVersion"
|
FormVersion="CreateCharacterFormVersion"
|
||||||
EditingCharacterId="null"
|
EditingCharacterId="null"
|
||||||
Campaigns="Campaigns"
|
CampaignOptions="CharacterCampaignOptions"
|
||||||
IsMutating="IsMutating"
|
IsMutating="IsMutating"
|
||||||
AllowOwnerEdit="false"
|
AllowOwnerEdit="false"
|
||||||
AvailableUsernames="KnownUsernames"
|
AvailableUsernames="KnownUsernames"
|
||||||
@@ -176,7 +176,7 @@
|
|||||||
InitialModel="EditCharacterInitialModel"
|
InitialModel="EditCharacterInitialModel"
|
||||||
FormVersion="EditCharacterFormVersion"
|
FormVersion="EditCharacterFormVersion"
|
||||||
EditingCharacterId="EditingCharacterId"
|
EditingCharacterId="EditingCharacterId"
|
||||||
Campaigns="Campaigns"
|
CampaignOptions="CharacterCampaignOptions"
|
||||||
IsMutating="IsMutating"
|
IsMutating="IsMutating"
|
||||||
AllowOwnerEdit="CanEditCharacterOwner"
|
AllowOwnerEdit="CanEditCharacterOwner"
|
||||||
AvailableUsernames="KnownUsernames"
|
AvailableUsernames="KnownUsernames"
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ public partial class Workspace : IAsyncDisposable
|
|||||||
ActiveCharacterId = me.ActiveCharacterId;
|
ActiveCharacterId = me.ActiveCharacterId;
|
||||||
|
|
||||||
await ReloadCampaignsAsync(preferredCampaignId ?? me.CurrentCampaignId);
|
await ReloadCampaignsAsync(preferredCampaignId ?? me.CurrentCampaignId);
|
||||||
|
await ReloadCharacterCampaignOptionsAsync();
|
||||||
await RefreshCampaignScopeAsync();
|
await RefreshCampaignScopeAsync();
|
||||||
await SyncStateEventsAsync();
|
await SyncStateEventsAsync();
|
||||||
return true;
|
return true;
|
||||||
@@ -156,6 +157,12 @@ public partial class Workspace : IAsyncDisposable
|
|||||||
await JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", CampaignSessionKey, SelectedCampaignId?.ToString());
|
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()
|
private async Task RefreshCampaignScopeAsync()
|
||||||
{
|
{
|
||||||
if (!SelectedCampaignId.HasValue)
|
if (!SelectedCampaignId.HasValue)
|
||||||
@@ -269,6 +276,7 @@ public partial class Workspace : IAsyncDisposable
|
|||||||
private async Task OnCampaignCreatedAsync(Guid campaignId)
|
private async Task OnCampaignCreatedAsync(Guid campaignId)
|
||||||
{
|
{
|
||||||
await ReloadCampaignsAsync(campaignId);
|
await ReloadCampaignsAsync(campaignId);
|
||||||
|
await ReloadCharacterCampaignOptionsAsync();
|
||||||
await RefreshCampaignScopeAsync();
|
await RefreshCampaignScopeAsync();
|
||||||
await SyncStateEventsAsync();
|
await SyncStateEventsAsync();
|
||||||
SetStatus("Campaign created.", false);
|
SetStatus("Campaign created.", false);
|
||||||
@@ -279,7 +287,7 @@ public partial class Workspace : IAsyncDisposable
|
|||||||
CreateCharacterInitialModel = new()
|
CreateCharacterInitialModel = new()
|
||||||
{
|
{
|
||||||
Name = string.Empty,
|
Name = string.Empty,
|
||||||
CampaignId = SelectedCampaignId?.ToString() ?? string.Empty,
|
CampaignId = SelectedCampaignId?.ToString() ?? CharacterCampaignOptions.FirstOrDefault()?.Id.ToString() ?? string.Empty,
|
||||||
OwnerUsername = string.Empty
|
OwnerUsername = string.Empty
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -290,7 +298,7 @@ public partial class Workspace : IAsyncDisposable
|
|||||||
|
|
||||||
private async Task OpenEditCharacterModal(CharacterSummary character)
|
private async Task OpenEditCharacterModal(CharacterSummary character)
|
||||||
{
|
{
|
||||||
if (IsCurrentUserGm)
|
if (IsCurrentUserGm || IsCurrentUserAdmin)
|
||||||
await LoadKnownUsernamesAsync();
|
await LoadKnownUsernamesAsync();
|
||||||
|
|
||||||
EditingCharacterId = character.Id;
|
EditingCharacterId = character.Id;
|
||||||
@@ -302,7 +310,7 @@ public partial class Workspace : IAsyncDisposable
|
|||||||
};
|
};
|
||||||
|
|
||||||
EditCharacterFormVersion++;
|
EditCharacterFormVersion++;
|
||||||
CanEditCharacterOwner = IsCurrentUserGm;
|
CanEditCharacterOwner = IsCurrentUserGm || IsCurrentUserAdmin;
|
||||||
ShowEditCharacterModal = true;
|
ShowEditCharacterModal = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,22 +322,24 @@ public partial class Workspace : IAsyncDisposable
|
|||||||
EditingCharacterId = null;
|
EditingCharacterId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task OnCharacterCreatedAsync(Guid campaignId)
|
private async Task OnCharacterCreatedAsync(Guid? campaignId)
|
||||||
{
|
{
|
||||||
CloseCharacterModals();
|
CloseCharacterModals();
|
||||||
await ReloadCampaignsAsync(campaignId);
|
await ReloadCampaignsAsync(campaignId);
|
||||||
|
await ReloadCharacterCampaignOptionsAsync();
|
||||||
await RefreshCampaignScopeAsync();
|
await RefreshCampaignScopeAsync();
|
||||||
await SyncStateEventsAsync();
|
await SyncStateEventsAsync();
|
||||||
SetStatus("Character created.", false);
|
SetStatus("Character created.", false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task OnCharacterUpdatedAsync(Guid campaignId)
|
private async Task OnCharacterUpdatedAsync(Guid? campaignId)
|
||||||
{
|
{
|
||||||
CloseCharacterModals();
|
CloseCharacterModals();
|
||||||
await ReloadCampaignsAsync(campaignId);
|
await ReloadCampaignsAsync(campaignId);
|
||||||
|
await ReloadCharacterCampaignOptionsAsync();
|
||||||
await RefreshCampaignScopeAsync();
|
await RefreshCampaignScopeAsync();
|
||||||
await SyncStateEventsAsync();
|
await SyncStateEventsAsync();
|
||||||
SetStatus("Character updated.", false);
|
SetStatus(campaignId.HasValue ? "Character updated." : "Character unlinked from campaign.", false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task DeleteSelectedCampaignAsync()
|
private async Task DeleteSelectedCampaignAsync()
|
||||||
@@ -346,6 +356,7 @@ public partial class Workspace : IAsyncDisposable
|
|||||||
{
|
{
|
||||||
_ = await ApiClient.RequestAsync<bool>("DELETE", $"/api/campaigns/{SelectedCampaign.Id}");
|
_ = await ApiClient.RequestAsync<bool>("DELETE", $"/api/campaigns/{SelectedCampaign.Id}");
|
||||||
await ReloadCampaignsAsync(null);
|
await ReloadCampaignsAsync(null);
|
||||||
|
await ReloadCharacterCampaignOptionsAsync();
|
||||||
await RefreshCampaignScopeAsync();
|
await RefreshCampaignScopeAsync();
|
||||||
await SyncStateEventsAsync();
|
await SyncStateEventsAsync();
|
||||||
SetStatus("Campaign deleted.", false);
|
SetStatus("Campaign deleted.", false);
|
||||||
@@ -368,7 +379,7 @@ public partial class Workspace : IAsyncDisposable
|
|||||||
|
|
||||||
private bool CanEditCharacter(CharacterSummary character)
|
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)
|
private static bool CanActivateCharacter(CharacterSummary character, UserSummary? user)
|
||||||
@@ -674,6 +685,7 @@ public partial class Workspace : IAsyncDisposable
|
|||||||
SelectedCampaignId = null;
|
SelectedCampaignId = null;
|
||||||
SelectedCampaign = null;
|
SelectedCampaign = null;
|
||||||
Campaigns = [];
|
Campaigns = [];
|
||||||
|
CharacterCampaignOptions = [];
|
||||||
CampaignLog = [];
|
CampaignLog = [];
|
||||||
SelectedCharacterId = null;
|
SelectedCharacterId = null;
|
||||||
LastRoll = null;
|
LastRoll = null;
|
||||||
@@ -738,6 +750,7 @@ public partial class Workspace : IAsyncDisposable
|
|||||||
private Guid? SelectedCampaignId { get; set; }
|
private Guid? SelectedCampaignId { get; set; }
|
||||||
private CampaignDetails? SelectedCampaign { get; set; }
|
private CampaignDetails? SelectedCampaign { get; set; }
|
||||||
private List<CampaignDetails> Campaigns { get; set; } = [];
|
private List<CampaignDetails> Campaigns { get; set; } = [];
|
||||||
|
private List<CampaignOption> CharacterCampaignOptions { get; set; } = [];
|
||||||
private List<CampaignLogEntry> CampaignLog { get; set; } = [];
|
private List<CampaignLogEntry> CampaignLog { get; set; } = [];
|
||||||
private List<RulesetDefinition> Rulesets { get; set; } = [];
|
private List<RulesetDefinition> Rulesets { get; set; } = [];
|
||||||
private Guid? SelectedCharacterId { get; set; }
|
private Guid? SelectedCharacterId { get; set; }
|
||||||
|
|||||||
@@ -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 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 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);
|
public sealed record CharacterSummary(Guid Id, string Name, Guid OwnerUserId, Guid? CampaignId, string OwnerDisplayName);
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
public ServiceResult<CampaignDetails> GetCampaign(string sessionToken, Guid campaignId)
|
||||||
{
|
{
|
||||||
lock (m_Gate)
|
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))
|
if (string.IsNullOrWhiteSpace(name))
|
||||||
return ServiceResult<CharacterSummary>.Failure("invalid_character_name", "Character name is required.");
|
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))
|
if (!m_CharactersById.TryGetValue(characterId, out var character))
|
||||||
return ServiceResult<CharacterSummary>.Failure("character_not_found", "Character was not found.");
|
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.");
|
return ServiceResult<CharacterSummary>.Failure("campaign_not_found", "Campaign was not found.");
|
||||||
|
|
||||||
var isOwner = character.OwnerUserId == user.Id;
|
var isOwner = character.OwnerUserId == user.Id;
|
||||||
|
var isAdmin = UserHasRoleLocked(user, UserRoles.Admin);
|
||||||
var isSourceGm = character.CampaignId.HasValue &&
|
var isSourceGm = character.CampaignId.HasValue &&
|
||||||
m_CampaignsById.TryGetValue(character.CampaignId.Value, out var sourceCampaign) &&
|
m_CampaignsById.TryGetValue(character.CampaignId.Value, out var sourceCampaign) &&
|
||||||
sourceCampaign.GmUserId == user.Id;
|
sourceCampaign.GmUserId == user.Id;
|
||||||
var isTargetGm = targetCampaign.GmUserId == user.Id;
|
var isTargetGm = targetCampaign is not null && targetCampaign.GmUserId == user.Id;
|
||||||
if (!isOwner && !isSourceGm && !isTargetGm)
|
if (!isOwner && !isAdmin && !isSourceGm && !isTargetGm)
|
||||||
return ServiceResult<CharacterSummary>.Failure("forbidden", "Only the owner or GM can edit this character.");
|
return ServiceResult<CharacterSummary>.Failure("forbidden", "Only the owner, GM, or admin can edit this character.");
|
||||||
|
|
||||||
var sourceCampaignId = character.CampaignId;
|
var sourceCampaignId = character.CampaignId;
|
||||||
var previousOwnerUserId = character.OwnerUserId;
|
var previousOwnerUserId = character.OwnerUserId;
|
||||||
@@ -392,8 +411,8 @@ public sealed class GameService : IGameService
|
|||||||
if (!m_UserIdsByUsername.TryGetValue(normalizedOwnerUsername, out var targetOwnerUserId))
|
if (!m_UserIdsByUsername.TryGetValue(normalizedOwnerUsername, out var targetOwnerUserId))
|
||||||
return ServiceResult<CharacterSummary>.Failure("owner_not_found", "Owner username was not found.");
|
return ServiceResult<CharacterSummary>.Failure("owner_not_found", "Owner username was not found.");
|
||||||
|
|
||||||
if (targetOwnerUserId != character.OwnerUserId && !isSourceGm && !isTargetGm)
|
if (targetOwnerUserId != character.OwnerUserId && !isAdmin && !isSourceGm && !isTargetGm)
|
||||||
return ServiceResult<CharacterSummary>.Failure("forbidden", "Only the GM can change character owner.");
|
return ServiceResult<CharacterSummary>.Failure("forbidden", "Only the GM or admin can change character owner.");
|
||||||
|
|
||||||
character.OwnerUserId = targetOwnerUserId;
|
character.OwnerUserId = targetOwnerUserId;
|
||||||
if (character.OwnerUserId != previousOwnerUserId &&
|
if (character.OwnerUserId != previousOwnerUserId &&
|
||||||
@@ -934,6 +953,11 @@ public sealed class GameService : IGameService
|
|||||||
return new(user.Id, user.Username, user.DisplayName, ParseRoles(user.Roles));
|
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)
|
private CampaignDetails ToCampaignDetails(Campaign campaign)
|
||||||
{
|
{
|
||||||
lock (m_Gate)
|
lock (m_Gate)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ public interface IGameService
|
|||||||
|
|
||||||
ServiceResult<CampaignDetails> CreateCampaign(string sessionToken, string name, string rulesetId);
|
ServiceResult<CampaignDetails> CreateCampaign(string sessionToken, string name, string rulesetId);
|
||||||
ServiceResult<IReadOnlyList<CampaignDetails>> GetCampaigns(string sessionToken);
|
ServiceResult<IReadOnlyList<CampaignDetails>> GetCampaigns(string sessionToken);
|
||||||
|
ServiceResult<IReadOnlyList<CampaignOption>> GetCharacterCampaignOptions(string sessionToken);
|
||||||
ServiceResult<CampaignDetails> GetCampaign(string sessionToken, Guid campaignId);
|
ServiceResult<CampaignDetails> GetCampaign(string sessionToken, Guid campaignId);
|
||||||
ServiceResult<bool> DeleteCampaign(string sessionToken, Guid campaignId);
|
ServiceResult<bool> DeleteCampaign(string sessionToken, Guid campaignId);
|
||||||
ServiceResult<IReadOnlyList<string>> GetUsernames(string sessionToken);
|
ServiceResult<IReadOnlyList<string>> GetUsernames(string sessionToken);
|
||||||
@@ -22,7 +23,7 @@ public interface IGameService
|
|||||||
ServiceResult<bool> DeleteUser(string sessionToken, Guid userId);
|
ServiceResult<bool> DeleteUser(string sessionToken, Guid userId);
|
||||||
|
|
||||||
ServiceResult<CharacterSummary> CreateCharacter(string sessionToken, string name, Guid campaignId);
|
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<bool> ActivateCharacter(string sessionToken, Guid characterId);
|
||||||
ServiceResult<IReadOnlyList<CharacterSummary>> GetOwnCharacters(string sessionToken);
|
ServiceResult<IReadOnlyList<CharacterSummary>> GetOwnCharacters(string sessionToken);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user