Cleanup campaign management ux

This commit is contained in:
2026-02-26 14:56:56 +01:00
parent 83151d81fd
commit 0cb41dd004
17 changed files with 55 additions and 113 deletions

View File

@@ -15,7 +15,7 @@ public sealed class CampaignApiTests : ApiTestBase
await RegisterAsync(gmClient, "gm", "Password123", "Game Master");
await LoginAsync(gmClient, "gm", "Password123");
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns", new("Alpha Campaign", "dnd5e"));
var campaign = await PostAsync<CreateCampaignRequest, CampaignDetails>(gmClient, "/api/campaigns", new("Alpha Campaign", "dnd5e"));
var gmCharacter = await PostAsync<CreateCharacterRequest, CharacterSummary>(gmClient, "/api/characters", new("Arin", campaign.Id));
@@ -38,14 +38,13 @@ public sealed class CampaignApiTests : ApiTestBase
var details = await GetAsync<CampaignDetails>(gmClient, $"/api/campaigns/{campaign.Id}");
Assert.Equal(campaign.Id, details.Id);
Assert.Single(details.Characters);
Assert.Single(details.Skills);
Assert.Equal(1, details.Characters);
var currentCampaignCharacters = await GetAsync<IReadOnlyList<CharacterSummary>>(gmClient, "/api/characters/current-campaign");
Assert.Single(currentCampaignCharacters);
Assert.Equal(gmCharacter.Id, currentCampaignCharacters[0].Id);
var otherCampaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns", new("Beta Campaign", "d6"));
var otherCampaign = await PostAsync<CreateCampaignRequest, CampaignDetails>(gmClient, "/api/campaigns", new("Beta Campaign", "d6"));
var updatedCharacter = await PutAsync<UpdateCharacterRequest, CharacterSummary>(gmClient, $"/api/characters/{gmCharacter.Id}", new("Arin Updated", otherCampaign.Id));
@@ -70,7 +69,7 @@ public sealed class CampaignApiTests : ApiTestBase
await RegisterAsync(receiverClient, "receiver2", "Password123", "Receiver");
await LoginAsync(receiverClient, "receiver2", "Password123");
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns", new("Grouped Campaign", "d6"));
var campaign = await PostAsync<CreateCampaignRequest, CampaignDetails>(gmClient, "/api/campaigns", new("Grouped Campaign", "d6"));
var character = await PostAsync<CreateCharacterRequest, CharacterSummary>(ownerClient, "/api/characters", new("Grouped Hero", campaign.Id));
var createdGroup = await PostAsync<CreateSkillGroupRequest, SkillGroupSummary>(ownerClient, $"/api/characters/{character.Id}/skill-groups", new("Combat", "2D+1", 1, true));
@@ -103,8 +102,5 @@ public sealed class CampaignApiTests : ApiTestBase
var receiverActivate = await receiverClient.PostAsync($"/api/characters/{character.Id}/activate", null);
Assert.Equal(HttpStatusCode.OK, receiverActivate.StatusCode);
var details = await GetAsync<CampaignDetails>(gmClient, $"/api/campaigns/{campaign.Id}");
Assert.DoesNotContain(details.SkillGroups, group => group.Id == renamedGroup.Id);
}
}

View File

@@ -17,7 +17,7 @@ public sealed class RollVisibilityApiTests : ApiTestBase
await RegisterAsync(gmClient, "gm", "Password123", "GM");
await LoginAsync(gmClient, "gm", "Password123");
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns", new("Main", "d6"));
var campaign = await PostAsync<CreateCampaignRequest, CampaignDetails>(gmClient, "/api/campaigns", new("Main", "d6"));
await RegisterAsync(playerClient, "player", "Password123", "Player");
await LoginAsync(playerClient, "player", "Password123");

View File

@@ -18,7 +18,7 @@ public sealed class SystemApiTests : ApiTestBase
await RegisterAsync(client, "sse", "Password123", "Sse User");
await LoginAsync(client, "sse", "Password123");
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(client, "/api/campaigns", new("SSE", "d6"));
var campaign = await PostAsync<CreateCampaignRequest, CampaignDetails>(client, "/api/campaigns", new("SSE", "d6"));
var sseResponse = await client.GetAsync($"/api/events/state?campaignId={campaign.Id}", HttpCompletionOption.ResponseHeadersRead);
Assert.Equal(HttpStatusCode.OK, sseResponse.StatusCode);

View File

@@ -29,7 +29,7 @@ public sealed class ServiceCampaignTests
var activateSuccess = service.ActivateCharacter(gmSession, character.Id);
Assert.True(activateSuccess.Succeeded);
var currentCharacters = service.GetCurrentCampaignCharacters(gmSession);
var currentCharacters = service.GetOwnCharacters(gmSession);
Assert.True(currentCharacters.Succeeded);
Assert.Single(ServiceTestSupport.GetValue(currentCharacters));
@@ -45,7 +45,7 @@ public sealed class ServiceCampaignTests
service.Register("user", "Password123", "User");
var sessionToken = ServiceTestSupport.GetValue(service.Login("user", "Password123")).SessionToken;
var result = service.GetCurrentCampaignCharacters(sessionToken);
var result = service.GetOwnCharacters(sessionToken);
Assert.False(result.Succeeded);
}

View File

@@ -33,7 +33,7 @@ public sealed class ServicePersistenceTests
Assert.False(service.UpdateCharacter(ownerSession, Guid.NewGuid(), "Renamed", campaign.Id).Succeeded);
Assert.False(service.ActivateCharacter(string.Empty, ownerCharacter.Id).Succeeded);
Assert.False(service.ActivateCharacter(gmSession, ownerCharacter.Id).Succeeded);
Assert.False(service.GetCurrentCampaignCharacters(string.Empty).Succeeded);
Assert.False(service.GetOwnCharacters(string.Empty).Succeeded);
Assert.False(service.CreateSkill(string.Empty, ownerCharacter.Id, "Stealth", "2D+1", 1, true).Succeeded);
Assert.False(service.CreateSkill(ownerSession, Guid.NewGuid(), "Stealth", "2D+1", 1, true).Succeeded);
Assert.False(service.CreateSkill(otherSession, ownerCharacter.Id, "Stealth", "2D+1", 1, true).Succeeded);
@@ -67,7 +67,7 @@ public sealed class ServicePersistenceTests
using var staleCurrentHarness = ServiceTestSupport.CreateHarnessFromPath(harness.DbPath, 2, 3, 4);
var staleCurrentService = staleCurrentHarness.Service;
var staleCurrentCampaign = staleCurrentService.GetCurrentCampaignCharacters(ownerSession);
var staleCurrentCampaign = staleCurrentService.GetOwnCharacters(ownerSession);
Assert.False(staleCurrentCampaign.Succeeded);
using (var db = harness.CreateDbContext())
{

View File

@@ -31,9 +31,9 @@ internal static class CharacterEndpoints
return ApiResultMapper.ToApiResult(result);
});
group.MapGet("/characters/current-campaign", (HttpContext context, IGameService game) =>
group.MapGet("/characters", (HttpContext context, IGameService game) =>
{
var result = game.GetCurrentCampaignCharacters(context.GetRequiredSessionToken());
var result = game.GetOwnCharacters(context.GetRequiredSessionToken());
return ApiResultMapper.ToApiResult(result);
});

View File

@@ -9,27 +9,15 @@
}
else
{
<label for="campaign-select">Campaign</label>
<label for="campaign-select">Current campaign</label>
<select id="campaign-select" @onchange="CampaignSelectionChanged">
@foreach (var campaign in Campaigns)
{
<option value="@campaign.Id" selected="@(campaign.Id == SelectedCampaignId)">@campaign.Name (@campaign.RulesetId)</option>
<option value="@campaign.Id" selected="@(campaign.Id == SelectedCampaignId)">@campaign.Name (@campaign.RulesetId), GM: @campaign.Gm.DisplayName, @campaign.Characters.Count characters</option>
}
</select>
}
@if (SelectedCampaign is null)
{
<p class="empty">No campaign selected.</p>
}
else
{
<p>Name: <strong>@SelectedCampaign.Name</strong></p>
<p>Ruleset: <strong>@SelectedCampaign.RulesetId</strong></p>
<p>GM: <strong>@SelectedCampaign.Gm.DisplayName</strong> <span class="muted">(@SelectedCampaign.Gm.Username)</span></p>
<p>Characters visible: <strong>@SelectedCampaign.Characters.Count</strong></p>
}
<button type="button"
class="add-row-button"
disabled="@(IsMutating || IsCreatingCampaign)"

View File

@@ -48,7 +48,7 @@ public partial class CampaignManagementPanel
IsCreatingCampaign = true;
try
{
var campaign = await ApiClient.RequestAsync<CampaignSummary>("POST", "/api/campaigns", new CreateCampaignRequest(CampaignState.Model.Name.Trim(), CampaignState.Model.RulesetId));
var campaign = await ApiClient.RequestAsync<CampaignDetails>("POST", "/api/campaigns", new CreateCampaignRequest(CampaignState.Model.Name.Trim(), CampaignState.Model.RulesetId));
CampaignState.Model.Name = string.Empty;
ShowCreateCampaignModal = false;
@@ -72,7 +72,7 @@ public partial class CampaignManagementPanel
private bool ShowCreateCampaignModal { get; set; }
[Parameter]
public IReadOnlyList<CampaignSummary> Campaigns { get; set; } = [];
public IReadOnlyList<CampaignDetails> Campaigns { get; set; } = [];
[Parameter]
public Guid? SelectedCampaignId { get; set; }

View File

@@ -96,7 +96,7 @@ public partial class CharacterFormModal
public Guid? EditingCharacterId { get; set; }
[Parameter]
public IReadOnlyList<CampaignSummary> Campaigns { get; set; } = [];
public IReadOnlyList<CampaignDetails> Campaigns { get; set; } = [];
[Parameter]
public bool IsMutating { get; set; }

View File

@@ -113,7 +113,7 @@
type="button"
class="chip-button"
title="Roll skill"
disabled="@(IsMutating || !CanRollSkill(skill))"
disabled="@(IsMutating)"
@onclick="() => RollSkillAsync(skill.Id)">
<span aria-hidden="true">⚄</span>
<span class="sr-only">Roll @skill.Name</span>
@@ -171,7 +171,7 @@
type="button"
class="chip-button"
title="Roll skill"
disabled="@(IsMutating || !CanRollSkill(skill))"
disabled="@(IsMutating)"
@onclick="() => RollSkillAsync(skill.Id)">
<span aria-hidden="true">⚄</span>
<span class="sr-only">Roll @skill.Name</span>
@@ -203,7 +203,9 @@
type="button"
class="add-row-button"
disabled="@(IsMutating || !CanEditCharacter(SelectedCharacter))"
@onclick="OpenCreateSkillGroupModal">[+] Add group
@onclick="OpenCreateSkillGroupModal">
<span class="skill-create-icon" aria-hidden="true">+</span>
<span>Add group</span>
</button>
</article>
}

View File

@@ -291,9 +291,6 @@ public partial class CharacterPanel
[Parameter]
public Func<SkillSummary, bool> CanEditSkill { get; set; } = _ => false;
[Parameter]
public Func<SkillSummary, bool> CanRollSkill { get; set; } = _ => false;
[Parameter]
public EventCallback<Guid> CharacterSelected { get; set; }

View File

@@ -78,7 +78,6 @@
SkillDefinitionLabel="SkillDefinitionLabel"
CanEditCharacter="CanEditCharacter"
CanEditSkill="CanEditSkill"
CanRollSkill="CanRollSkill"
CharacterSelected="SelectCharacterAsync"
EditCharacterRequested="OpenEditCharacterModal"
SkillCreated="OnSkillCreatedAsync"

View File

@@ -136,7 +136,7 @@ public partial class Workspace : IAsyncDisposable
private async Task ReloadCampaignsAsync(Guid? preferredCampaignId)
{
var campaigns = await ApiClient.RequestAsync<IReadOnlyList<CampaignSummary>>("GET", "/api/campaigns");
var campaigns = await ApiClient.RequestAsync<IReadOnlyList<CampaignDetails>>("GET", "/api/campaigns");
Campaigns = campaigns.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase).ToList();
if (Campaigns.Count == 0)
@@ -191,30 +191,6 @@ public partial class Workspace : IAsyncDisposable
}
}
private async Task ManualRefreshAsync()
{
if (IsMutating)
return;
IsMutating = true;
try
{
await CheckHealthAsync();
var reloaded = await ReloadAuthenticatedSessionAsync(SelectedCampaignId);
if (!reloaded)
{
await LoggedOut.InvokeAsync("Session expired. Please log in again.");
return;
}
SetStatus("Campaign data refreshed.", false);
}
finally
{
IsMutating = false;
}
}
private async Task LogoutAsync()
{
if (IsMutating)
@@ -435,23 +411,10 @@ public partial class Workspace : IAsyncDisposable
return;
}
var selectedSkill = SelectedCampaign.Skills.FirstOrDefault(skill => skill.Id == skillId);
if (selectedSkill is null)
{
SetStatus("Skill is no longer available. Refresh campaign data.", true);
return;
}
if (!CanRollSkill(selectedSkill))
{
SetStatus("You are not allowed to roll this skill.", true);
return;
}
IsMutating = true;
try
{
LastRoll = await ApiClient.RequestAsync<RollResult>("POST", $"/api/skills/{selectedSkill.Id}/roll", new RollSkillRequest(RollVisibility));
LastRoll = await ApiClient.RequestAsync<RollResult>("POST", $"/api/skills/{skillId}/roll", new RollSkillRequest(RollVisibility));
await RefreshCampaignScopeAsync();
SetStatus("Roll recorded.", false);
@@ -487,11 +450,6 @@ public partial class Workspace : IAsyncDisposable
return character is not null && CanEditCharacter(character);
}
private bool CanRollSkill(SkillSummary skill)
{
return CanEditSkill(skill);
}
[JSInvokable]
public async Task OnStateEventReceived(long _)
{
@@ -736,7 +694,7 @@ public partial class Workspace : IAsyncDisposable
private Guid? ActiveCharacterId { get; set; }
private Guid? SelectedCampaignId { get; set; }
private CampaignDetails? SelectedCampaign { get; set; }
private List<CampaignSummary> Campaigns { get; set; } = [];
private List<CampaignDetails> Campaigns { get; set; } = [];
private List<CampaignLogEntry> CampaignLog { get; set; } = [];
private List<RulesetDefinition> Rulesets { get; set; } = [];
private Guid? SelectedCharacterId { get; set; }

View File

@@ -16,8 +16,6 @@ public sealed record RulesetDefinition(string Id, string Name, string DiceSyntax
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<SkillGroupSummary> SkillGroups, IReadOnlyList<SkillSummary> Skills);
public sealed record CreateCharacterRequest(string Name, Guid CampaignId);

View File

@@ -127,20 +127,20 @@ public sealed class GameService : IGameService
}
}
public ServiceResult<CampaignSummary> CreateCampaign(string sessionToken, string name, string rulesetId)
public ServiceResult<CampaignDetails> CreateCampaign(string sessionToken, string name, string rulesetId)
{
if (string.IsNullOrWhiteSpace(name))
return ServiceResult<CampaignSummary>.Failure("invalid_campaign_name", "Campaign name is required.");
return ServiceResult<CampaignDetails>.Failure("invalid_campaign_name", "Campaign name is required.");
var ruleset = DiceRules.TryParseRulesetId(rulesetId);
if (ruleset is null)
return ServiceResult<CampaignSummary>.Failure("invalid_ruleset", "Unknown ruleset.");
return ServiceResult<CampaignDetails>.Failure("invalid_ruleset", "Unknown ruleset.");
lock (m_Gate)
{
var user = ResolveUserLocked(sessionToken);
if (user is null)
return ServiceResult<CampaignSummary>.Failure("unauthorized", "You must be logged in.");
return ServiceResult<CampaignDetails>.Failure("unauthorized", "You must be logged in.");
var campaign = new Campaign
{
@@ -153,25 +153,25 @@ public sealed class GameService : IGameService
m_CampaignsById[campaign.Id] = campaign;
PersistStateLocked();
return ServiceResult<CampaignSummary>.Success(ToCampaignSummary(campaign));
return ServiceResult<CampaignDetails>.Success(ToCampaignDetails(campaign));
}
}
public ServiceResult<IReadOnlyList<CampaignSummary>> GetCampaigns(string sessionToken)
public ServiceResult<IReadOnlyList<CampaignDetails>> GetCampaigns(string sessionToken)
{
lock (m_Gate)
{
var user = ResolveUserLocked(sessionToken);
if (user is null)
return ServiceResult<IReadOnlyList<CampaignSummary>>.Failure("unauthorized", "You must be logged in.");
return ServiceResult<IReadOnlyList<CampaignDetails>>.Failure("unauthorized", "You must be logged in.");
var campaignIds = new HashSet<Guid>(m_CampaignsById.Values.Where(c => c.GmUserId == user.Id).Select(c => c.Id));
foreach (var character in m_CharactersById.Values.Where(c => c.OwnerUserId == user.Id))
campaignIds.Add(character.CampaignId);
var results = campaignIds.Select(id => m_CampaignsById[id]).OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase).Select(ToCampaignSummary).ToArray();
var results = campaignIds.Select(id => m_CampaignsById[id]).OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase).Select(ToCampaignDetails).ToArray();
return ServiceResult<IReadOnlyList<CampaignSummary>>.Success(results);
return ServiceResult<IReadOnlyList<CampaignDetails>>.Success(results);
}
}
@@ -183,11 +183,9 @@ public sealed class GameService : IGameService
if (!context.Succeeded)
return ServiceResult<CampaignDetails>.Failure(context.Error!.Code, context.Error.Message);
var (user, campaign) = context.Value!;
var (_, campaign) = context.Value;
var gm = m_UsersById[campaign.GmUserId];
var isGm = campaign.GmUserId == user.Id;
var characters = m_CharactersById.Values.Where(c => c.CampaignId == campaign.Id).Where(c => isGm || c.OwnerUserId == user.Id).OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase).Select(ToCharacterSummary).ToArray();
var characters = m_CharactersById.Values.Where(c => c.CampaignId == campaign.Id).Select(ToCharacterSummary).ToList();
var visibleCharacterIds = characters.Select(c => c.Id).ToHashSet();
@@ -318,7 +316,7 @@ public sealed class GameService : IGameService
}
}
public ServiceResult<IReadOnlyList<CharacterSummary>> GetCurrentCampaignCharacters(string sessionToken)
public ServiceResult<IReadOnlyList<CharacterSummary>> GetOwnCharacters(string sessionToken)
{
lock (m_Gate)
{
@@ -326,10 +324,7 @@ public sealed class GameService : IGameService
if (user is null)
return ServiceResult<IReadOnlyList<CharacterSummary>>.Failure("unauthorized", "You must be logged in.");
if (!TryGetCurrentCampaignIdLocked(user, out var campaignId))
return ServiceResult<IReadOnlyList<CharacterSummary>>.Failure("no_active_character", "No active character is selected.");
var characters = m_CharactersById.Values.Where(c => c.CampaignId == campaignId && c.OwnerUserId == user.Id).OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase).Select(ToCharacterSummary).ToArray();
var characters = m_CharactersById.Values.Where(c => c.OwnerUserId == user.Id).OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase).Select(ToCharacterSummary).ToArray();
return ServiceResult<IReadOnlyList<CharacterSummary>>.Success(characters);
}
@@ -803,9 +798,18 @@ public sealed class GameService : IGameService
return new(user.Id, user.Username, user.DisplayName);
}
private static CampaignSummary ToCampaignSummary(Campaign campaign)
private CampaignDetails ToCampaignDetails(Campaign campaign)
{
return new(campaign.Id, campaign.Name, DiceRules.ToRulesetId(campaign.Ruleset), campaign.GmUserId);
lock (m_Gate)
{
var gm = m_UsersById[campaign.GmUserId];
var characters = m_CharactersById.Values.Where(c => c.CampaignId == campaign.Id).Select(ToCharacterSummary).ToList();
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 new(campaign.Id, campaign.Name, DiceRules.ToRulesetId(campaign.Ruleset), ToUserSummary(gm), characters, skillGroups, skills);
}
}
private static CharacterSummary ToCharacterSummary(Character character)

View File

@@ -12,15 +12,15 @@ public interface IGameService
UserSummary? GetUserBySession(string sessionToken);
ServiceResult<MeResponse> GetMe(string sessionToken);
ServiceResult<CampaignSummary> CreateCampaign(string sessionToken, string name, string rulesetId);
ServiceResult<IReadOnlyList<CampaignSummary>> GetCampaigns(string sessionToken);
ServiceResult<CampaignDetails> CreateCampaign(string sessionToken, string name, string rulesetId);
ServiceResult<IReadOnlyList<CampaignDetails>> GetCampaigns(string sessionToken);
ServiceResult<CampaignDetails> GetCampaign(string sessionToken, Guid campaignId);
ServiceResult<IReadOnlyList<string>> GetUsernames(string sessionToken);
ServiceResult<CharacterSummary> CreateCharacter(string sessionToken, 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<IReadOnlyList<CharacterSummary>> GetOwnCharacters(string sessionToken);
ServiceResult<SkillGroupSummary> CreateSkillGroup(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble);
ServiceResult<SkillGroupSummary> UpdateSkillGroup(string sessionToken, Guid skillGroupId, string name, string diceRollDefinition, int wildDice, bool allowFumble);

View File

@@ -444,12 +444,12 @@
}
}
},
"/api/characters/current-campaign": {
"/api/characters": {
"get": {
"operationId": "getCurrentCampaignCharacters",
"operationId": "getOwnCharacters",
"responses": {
"200": {
"description": "Current campaign characters owned by the user.",
"description": "Characters owned by the user.",
"content": {
"application/json": {
"schema": {