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

@@ -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);