Refactor campaign payload loading

This commit is contained in:
2026-04-01 23:06:46 +02:00
parent 1c8cb71cb4
commit 8561c6643a
19 changed files with 246 additions and 152 deletions

View File

@@ -99,6 +99,7 @@ dotnet dotnet-ef migrations add <MigrationName> --project RpgRoller/RpgRoller.cs
- Runtime frontend is Blazor Server with interactive components.
- Browser interop is in `RpgRoller/wwwroot/js/rpgroller-api.js`.
- Workspace campaign data is loaded in bounded slices: visible campaign summaries, a selected campaign roster, a selected character sheet, and the 100 most recent visible log entries.
- OpenAPI contract source remains at `openapi/RpgRoller.json`.
## Test and Coverage

View File

@@ -17,7 +17,7 @@ public sealed class CampaignApiTests : ApiTestBase
await RegisterAsync(gmClient, "gm", "Password123", "Game Master");
await LoginAsync(gmClient, "gm", "Password123");
var campaign = await PostAsync<CreateCampaignRequest, CampaignDetails>(gmClient, "/api/campaigns", new("Alpha Campaign", "dnd5e"));
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns", new("Alpha Campaign", "dnd5e"));
var gmCharacter = await PostAsync<CreateCharacterRequest, CharacterSummary>(gmClient, "/api/characters", new("Arin", campaign.Id));
Assert.Equal("Game Master", gmCharacter.OwnerDisplayName);
@@ -39,17 +39,25 @@ public sealed class CampaignApiTests : ApiTestBase
var invalidSkill = await gmClient.PostAsJsonAsync($"/api/characters/{gmCharacter.Id}/skills", new CreateSkillRequest("Broken", "5D+4", 0, false));
Assert.Equal(HttpStatusCode.BadRequest, invalidSkill.StatusCode);
var details = await GetAsync<CampaignDetails>(gmClient, $"/api/campaigns/{campaign.Id}");
var details = await GetAsync<CampaignRoster>(gmClient, $"/api/campaigns/{campaign.Id}");
Assert.Equal(campaign.Id, details.Id);
Assert.Single(details.Characters);
Assert.Equal("Game Master", details.Characters[0].OwnerDisplayName);
var summaries = await GetAsync<IReadOnlyList<CampaignSummary>>(gmClient, "/api/campaigns");
Assert.Single(summaries);
Assert.Equal(1, summaries[0].CharacterCount);
var sheet = await GetAsync<CharacterSheet>(gmClient, $"/api/characters/{gmCharacter.Id}/sheet");
Assert.Single(sheet.Skills);
Assert.Equal(updatedSkill.Id, sheet.Skills[0].Id);
var currentCampaignCharacters = await GetAsync<IReadOnlyList<CharacterSummary>>(gmClient, "/api/characters");
Assert.Single(currentCampaignCharacters);
Assert.Equal(gmCharacter.Id, currentCampaignCharacters[0].Id);
Assert.Equal("Game Master", currentCampaignCharacters[0].OwnerDisplayName);
var otherCampaign = await PostAsync<CreateCampaignRequest, CampaignDetails>(gmClient, "/api/campaigns", new("Beta Campaign", "d6"));
var otherCampaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns", new("Beta Campaign", "d6"));
var updatedCharacter = await PutAsync<UpdateCharacterRequest, CharacterSummary>(gmClient, $"/api/characters/{gmCharacter.Id}", new("Arin Updated", otherCampaign.Id));
@@ -74,7 +82,7 @@ public sealed class CampaignApiTests : ApiTestBase
await RegisterAsync(receiverClient, "receiver2", "Password123", "Receiver");
await LoginAsync(receiverClient, "receiver2", "Password123");
var campaign = await PostAsync<CreateCampaignRequest, CampaignDetails>(gmClient, "/api/campaigns", new("Grouped Campaign", "d6"));
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(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,7 +111,7 @@ public sealed class CampaignApiTests : ApiTestBase
Assert.Equal("Grouped Hero", transferResult.Name);
Assert.Equal("Receiver", transferResult.OwnerDisplayName);
var gmCampaignView = await GetAsync<CampaignDetails>(gmClient, $"/api/campaigns/{campaign.Id}");
var gmCampaignView = await GetAsync<CampaignRoster>(gmClient, $"/api/campaigns/{campaign.Id}");
var gmViewedCharacter = Assert.Single(gmCampaignView.Characters, c => c.Id == character.Id);
Assert.Equal("Receiver", gmViewedCharacter.OwnerDisplayName);
@@ -139,7 +147,7 @@ public sealed class CampaignApiTests : ApiTestBase
var promotedPlayer = await PutAsync<UpdateUserRolesRequest, AdminUserSummary>(adminClient, $"/api/admin/users/{player.Id}/roles", new([ "admin" ]));
Assert.Contains(promotedPlayer.Roles, role => string.Equals(role, "admin", StringComparison.OrdinalIgnoreCase));
var campaign = await PostAsync<CreateCampaignRequest, CampaignDetails>(gmClient, "/api/campaigns", new("Disposable Campaign", "d6"));
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns", new("Disposable Campaign", "d6"));
var character = await PostAsync<CreateCharacterRequest, CharacterSummary>(playerClient, "/api/characters", new("Disposable Hero", campaign.Id));
var skill = await PostAsync<CreateSkillRequest, SkillSummary>(playerClient, $"/api/characters/{character.Id}/skills", new("Stealth", "2D+1", 1, true));
_ = await PostAsync<RollSkillRequest, RollResult>(playerClient, $"/api/skills/{skill.Id}/roll", new("public"));
@@ -213,10 +221,10 @@ public sealed class CampaignApiTests : ApiTestBase
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 firstCampaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns", new("Alpha Visible", "d6"));
var secondCampaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(otherGmClient, "/api/campaigns", new("Beta Available", "d6"));
var playerVisibleCampaigns = await GetAsync<IReadOnlyList<CampaignDetails>>(playerClient, "/api/campaigns");
var playerVisibleCampaigns = await GetAsync<IReadOnlyList<CampaignSummary>>(playerClient, "/api/campaigns");
Assert.Empty(playerVisibleCampaigns);
var playerCampaignOptions = await GetAsync<IReadOnlyList<CampaignOption>>(playerClient, "/api/campaigns/options");
@@ -246,7 +254,7 @@ public sealed class CampaignApiTests : ApiTestBase
await RegisterAsync(otherClient, "other-delete", "Password123", "Other");
await LoginAsync(otherClient, "other-delete", "Password123");
var campaign = await PostAsync<CreateCampaignRequest, CampaignDetails>(gmClient, "/api/campaigns", new("Deletion Campaign", "d6"));
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns", new("Deletion Campaign", "d6"));
var ownerCharacter = await PostAsync<CreateCharacterRequest, CharacterSummary>(ownerClient, "/api/characters", new("Owner Character", campaign.Id));
var otherCharacter = await PostAsync<CreateCharacterRequest, CharacterSummary>(otherClient, "/api/characters", new("Other Character", campaign.Id));
@@ -262,7 +270,43 @@ public sealed class CampaignApiTests : ApiTestBase
var adminDelete = await adminClient.DeleteAsync($"/api/characters/{otherCharacter.Id}");
Assert.Equal(HttpStatusCode.OK, adminDelete.StatusCode);
var campaignAfterDeletes = await GetAsync<CampaignDetails>(gmClient, $"/api/campaigns/{campaign.Id}");
var campaignAfterDeletes = await GetAsync<CampaignRoster>(gmClient, $"/api/campaigns/{campaign.Id}");
Assert.Empty(campaignAfterDeletes.Characters);
}
[Fact]
public async Task CampaignLog_ReturnsMostRecentHundredEntries()
{
using var factory = CreateFactory();
using var gmClient = factory.CreateClient(new() { AllowAutoRedirect = false });
using var playerClient = factory.CreateClient(new() { AllowAutoRedirect = false });
await RegisterAsync(gmClient, "gm-log-cap", "Password123", "GM");
await LoginAsync(gmClient, "gm-log-cap", "Password123");
await RegisterAsync(playerClient, "player-log-cap", "Password123", "Player");
await LoginAsync(playerClient, "player-log-cap", "Password123");
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns", new("Log Cap", "d6"));
var character = await PostAsync<CreateCharacterRequest, CharacterSummary>(playerClient, "/api/characters", new("Roller", campaign.Id));
var skill = await PostAsync<CreateSkillRequest, SkillSummary>(playerClient, $"/api/characters/{character.Id}/skills", new("Stealth", "2D+1", 1, true));
var rollIds = new List<Guid>();
for (var i = 0; i < 105; i++)
{
var roll = await PostAsync<RollSkillRequest, RollResult>(playerClient, $"/api/skills/{skill.Id}/roll", new("public"));
rollIds.Add(roll.RollId);
}
var log = await GetAsync<IReadOnlyList<CampaignLogEntry>>(gmClient, $"/api/campaigns/{campaign.Id}/log");
Assert.Equal(100, log.Count);
Assert.Equal(rollIds[5], log[0].RollId);
Assert.Equal(rollIds[^1], log[^1].RollId);
Assert.All(log, entry =>
{
Assert.False(string.IsNullOrWhiteSpace(entry.CharacterName));
Assert.False(string.IsNullOrWhiteSpace(entry.SkillName));
Assert.False(string.IsNullOrWhiteSpace(entry.RollerDisplayName));
});
}
}

View File

@@ -1,6 +1,3 @@
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Options;
namespace RpgRoller.Tests;
public sealed class FrontendHostTests : ApiTestBase
@@ -21,24 +18,4 @@ public sealed class FrontendHostTests : ApiTestBase
Assert.Contains("_framework/blazor.web.js", html);
Assert.Contains("Connecting...", html);
}
[Fact]
public void BlazorHub_AllowsLargerInteropPayloads()
{
using var factory = CreateFactory();
var componentHubType = Type.GetType("Microsoft.AspNetCore.Components.Server.ComponentHub, Microsoft.AspNetCore.Components.Server");
Assert.NotNull(componentHubType);
var hubOptionsType = typeof(HubOptions<>).MakeGenericType(componentHubType);
var optionsType = typeof(IOptions<>).MakeGenericType(hubOptionsType);
var options = factory.Services.GetService(optionsType);
Assert.NotNull(options);
var value = optionsType.GetProperty("Value")!.GetValue(options);
Assert.NotNull(value);
var maximumReceiveMessageSize = (long?)hubOptionsType.GetProperty("MaximumReceiveMessageSize")!.GetValue(value);
Assert.Equal(256 * 1024, maximumReceiveMessageSize);
}
}

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, CampaignDetails>(gmClient, "/api/campaigns", new("Main", "d6"));
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns", new("Main", "d6"));
await RegisterAsync(playerClient, "player", "Password123", "Player");
await LoginAsync(playerClient, "player", "Password123");
@@ -68,4 +68,4 @@ public sealed class RollVisibilityApiTests : ApiTestBase
var unauthorizedWithInvalidSession = await anonymousClient.SendAsync(invalidSessionRequest);
Assert.Equal(HttpStatusCode.Unauthorized, unauthorizedWithInvalidSession.StatusCode);
}
}
}

View File

@@ -18,10 +18,10 @@ public sealed class SystemApiTests : ApiTestBase
await RegisterAsync(client, "sse", "Password123", "Sse User");
await LoginAsync(client, "sse", "Password123");
var campaign = await PostAsync<CreateCampaignRequest, CampaignDetails>(client, "/api/campaigns", new("SSE", "d6"));
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(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);
Assert.Equal("text/event-stream", sseResponse.Content.Headers.ContentType?.MediaType);
}
}
}

View File

@@ -96,7 +96,7 @@ public sealed class ServiceCampaignTests
}
[Fact]
public void GetCampaign_ForNonGmParticipant_ReturnsCampaignCharactersAndSkills()
public void GetCampaignAndCharacterSheet_ForNonGmParticipant_ReturnCampaignRosterAndSheet()
{
using var harness = ServiceTestSupport.CreateHarness();
var service = harness.Service;
@@ -120,7 +120,11 @@ public sealed class ServiceCampaignTests
Assert.Equal(2, ownerView.Characters.Count);
Assert.Contains(ownerView.Characters, character => character.Id == ownerCharacter.Id);
Assert.Contains(ownerView.Characters, character => character.Id == otherCharacter.Id);
Assert.Equal(2, ownerView.Skills.Count);
Assert.Contains(ownerView.Skills, skill => skill.Id == ownerSkill.Id);
var ownerSheet = ServiceTestSupport.GetValue(service.GetCharacterSheet(ownerSession, ownerCharacter.Id));
var otherSheet = ServiceTestSupport.GetValue(service.GetCharacterSheet(ownerSession, otherCharacter.Id));
Assert.Single(ownerSheet.Skills);
Assert.Contains(ownerSheet.Skills, skill => skill.Id == ownerSkill.Id);
Assert.Single(otherSheet.Skills);
}
}

View File

@@ -45,17 +45,19 @@ public sealed class ServiceSkillGroupAndOwnershipTests
var deletedGroup = ServiceTestSupport.GetValue(service.DeleteSkillGroup(ownerSession, renamedGroup.Id));
Assert.True(deletedGroup);
var afterGroupDelete = ServiceTestSupport.GetValue(service.GetCampaign(ownerSession, campaign.Id));
Assert.DoesNotContain(afterGroupDelete.SkillGroups, group => group.Id == renamedGroup.Id);
Assert.Contains(afterGroupDelete.SkillGroups, group => group.Id == otherGroup.Id);
Assert.Null(afterGroupDelete.Skills.Single(s => s.Id == regroupedSkill.Id).SkillGroupId);
var ownerSheetAfterGroupDelete = ServiceTestSupport.GetValue(service.GetCharacterSheet(ownerSession, ownerCharacter.Id));
var otherSheetAfterGroupDelete = ServiceTestSupport.GetValue(service.GetCharacterSheet(ownerSession, otherCharacter.Id));
Assert.DoesNotContain(ownerSheetAfterGroupDelete.SkillGroups, group => group.Id == renamedGroup.Id);
Assert.Contains(otherSheetAfterGroupDelete.SkillGroups, group => group.Id == otherGroup.Id);
Assert.Null(ownerSheetAfterGroupDelete.Skills.Single(s => s.Id == regroupedSkill.Id).SkillGroupId);
var deletedSkill = ServiceTestSupport.GetValue(service.DeleteSkill(ownerSession, regroupedSkill.Id));
Assert.True(deletedSkill);
var ownerView = ServiceTestSupport.GetValue(service.GetCampaign(ownerSession, campaign.Id));
var ownerView = ServiceTestSupport.GetValue(service.GetCharacterSheet(ownerSession, ownerCharacter.Id));
var otherView = ServiceTestSupport.GetValue(service.GetCharacterSheet(ownerSession, otherCharacter.Id));
Assert.DoesNotContain(ownerView.SkillGroups, group => group.Id == renamedGroup.Id);
Assert.Contains(ownerView.SkillGroups, group => group.Id == otherGroup.Id);
Assert.Contains(otherView.SkillGroups, group => group.Id == otherGroup.Id);
Assert.DoesNotContain(ownerView.Skills, skillSummary => skillSummary.Id == regroupedSkill.Id);
}

View File

@@ -31,6 +31,12 @@ internal static class CharacterEndpoints
return ApiResultMapper.ToApiResult(result);
});
group.MapGet("/characters/{characterId:guid}/sheet", (Guid characterId, HttpContext context, IGameService game) =>
{
var result = game.GetCharacterSheet(context.GetRequiredSessionToken(), characterId);
return ApiResultMapper.ToApiResult(result);
});
group.MapGet("/users/usernames", (HttpContext context, IGameService game) =>
{
var result = game.GetUsernames(context.GetRequiredSessionToken());

View File

@@ -18,8 +18,8 @@
@foreach (var entry in CampaignLog)
{
<li class="log-entry @LogEntryCssClass(entry)">
<p><strong>@RollerLabel(entry)</strong> rolled <strong>@SkillLabel(entry.SkillId)</strong> with
<strong>@CharacterLabel(entry.CharacterId)</strong></p>
<p><strong>@RollerLabel(entry)</strong> rolled <strong>@entry.SkillName</strong> with
<strong>@entry.CharacterName</strong></p>
<p class="roll-total inline">@entry.Result</p>
<RollDiceStrip Dice="entry.Dice" AriaLabel="Log roll dice"/>
<p>@entry.Breakdown</p>

View File

@@ -48,12 +48,6 @@ public partial class CampaignLogPanel
[Parameter]
public Func<CampaignLogEntry, string> RollerLabel { get; set; } = _ => string.Empty;
[Parameter]
public Func<Guid, string> SkillLabel { get; set; } = _ => string.Empty;
[Parameter]
public Func<Guid, string> CharacterLabel { get; set; } = _ => string.Empty;
[Parameter]
public Func<CampaignLogEntry, string> LogEntryCssClass { get; set; } = _ => string.Empty;

View File

@@ -13,7 +13,7 @@
<select id="campaign-select" @onchange="CampaignSelectionChanged">
@foreach (var campaign in Campaigns)
{
<option value="@campaign.Id" selected="@(campaign.Id == SelectedCampaignId)">@campaign.Name (@campaign.RulesetId), GM: @campaign.Gm.DisplayName, @campaign.Characters.Count characters</option>
<option value="@campaign.Id" selected="@(campaign.Id == SelectedCampaignId)">@campaign.Name (@campaign.RulesetId), GM: @campaign.Gm.DisplayName, @campaign.CharacterCount characters</option>
}
</select>
}

View File

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

View File

@@ -285,7 +285,7 @@ public partial class CharacterPanel
public bool IsCampaignDataLoading { get; set; }
[Parameter]
public CampaignDetails? SelectedCampaign { get; set; }
public CampaignRoster? SelectedCampaign { get; set; }
[Parameter]
public Guid? SelectedCharacterId { get; set; }

View File

@@ -61,8 +61,6 @@
IsCampaignDataLoading="IsCampaignDataLoading"
CampaignLog="PlayVisibleCampaignLog"
RollerLabel="RollerLabel"
SkillLabel="SkillLabel"
CharacterLabel="CharacterLabel"
LogEntryCssClass="LogEntryCssClass"
VisibilityLabel="VisibilityLabel"
VisibilityBadgeCssClass="VisibilityBadgeCssClass"/>

View File

@@ -143,7 +143,7 @@ public partial class Workspace : IAsyncDisposable
private async Task ReloadCampaignsAsync(Guid? preferredCampaignId)
{
var campaigns = await ApiClient.RequestAsync<IReadOnlyList<CampaignDetails>>("GET", "/api/campaigns");
var campaigns = await ApiClient.RequestAsync<IReadOnlyList<CampaignSummary>>("GET", "/api/campaigns");
Campaigns = campaigns.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase).ToList();
if (Campaigns.Count == 0)
@@ -173,6 +173,8 @@ public partial class Workspace : IAsyncDisposable
if (!SelectedCampaignId.HasValue)
{
SelectedCampaign = null;
SelectedCharacterSkills = [];
SelectedCharacterSkillGroups = [];
CampaignLog = [];
SelectedCharacterId = null;
ConnectionState = "offline";
@@ -183,9 +185,18 @@ public partial class Workspace : IAsyncDisposable
try
{
var campaignId = SelectedCampaignId.Value;
SelectedCampaign = await ApiClient.RequestAsync<CampaignDetails>("GET", $"/api/campaigns/{campaignId}");
CampaignLog = (await ApiClient.RequestAsync<IReadOnlyList<CampaignLogEntry>>("GET", $"/api/campaigns/{campaignId}/log")).ToList();
SelectedCampaign = await ApiClient.RequestAsync<CampaignRoster>("GET", $"/api/campaigns/{campaignId}");
SyncSelectedCharacter();
if (IsPlayScreen && PlaySelectedCharacterId.HasValue && SelectedCharacterId != PlaySelectedCharacterId)
SelectedCharacterId = PlaySelectedCharacterId;
await RefreshSelectedCharacterSheetAsync();
CampaignLog = IsPlayScreen
? (await ApiClient.RequestAsync<IReadOnlyList<CampaignLogEntry>>("GET", $"/api/campaigns/{campaignId}/log")).ToList()
: [];
await EnsureSelectedCharacterActiveAsync();
}
catch (ApiRequestException ex) when (ex.StatusCode == 401)
@@ -238,6 +249,12 @@ public partial class Workspace : IAsyncDisposable
await PersistScreenPreferenceAsync(CurrentScreen);
await InvokeAsync(StateHasChanged);
if (User is not null)
{
await RefreshCampaignScopeAsync();
await SyncStateEventsAsync();
}
if (IsAdminScreen)
{
await EnsureAdminUsersLoadedAsync();
@@ -521,6 +538,7 @@ public partial class Workspace : IAsyncDisposable
private async Task SelectCharacterAsync(Guid characterId)
{
SelectedCharacterId = characterId;
await RefreshSelectedCharacterSheetAsync();
await EnsureSelectedCharacterActiveAsync();
}
@@ -559,6 +577,24 @@ public partial class Workspace : IAsyncDisposable
}
}
private async Task RefreshSelectedCharacterSheetAsync()
{
if (!SelectedCharacterId.HasValue || SelectedCampaign is null || !IsPlayScreen)
{
SelectedCharacterSkills = [];
SelectedCharacterSkillGroups = [];
return;
}
var sheet = await ApiClient.RequestAsync<CharacterSheet>("GET", $"/api/characters/{SelectedCharacterId.Value}/sheet");
SelectedCharacterSkillGroups = sheet.SkillGroups
.OrderBy(group => group.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
SelectedCharacterSkills = sheet.Skills
.OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
}
private async Task OnSkillCreatedAsync(Guid _)
{
await RefreshCampaignScopeAsync();
@@ -715,7 +751,7 @@ public partial class Workspace : IAsyncDisposable
private async Task SyncStateEventsAsync()
{
if (User is null || !SelectedCampaignId.HasValue)
if (User is null || !SelectedCampaignId.HasValue || IsAdminScreen)
{
await StopStateEventsAsync();
ConnectionState = "offline";
@@ -795,16 +831,6 @@ public partial class Workspace : IAsyncDisposable
return string.IsNullOrWhiteSpace(ownerDisplayName) ? "Unknown owner" : ownerDisplayName;
}
private string CharacterLabel(Guid characterId)
{
return SelectedCampaign?.Characters.FirstOrDefault(c => c.Id == characterId)?.Name ?? "Hidden character";
}
private string SkillLabel(Guid skillId)
{
return SelectedCampaign?.Skills.FirstOrDefault(s => s.Id == skillId)?.Name ?? "Hidden skill";
}
private string SkillDefinitionLabel(SkillSummary skill)
{
if (!string.Equals(SelectedCampaign?.RulesetId, "d6", StringComparison.OrdinalIgnoreCase))
@@ -822,7 +848,7 @@ public partial class Workspace : IAsyncDisposable
if (SelectedCampaign is not null && entry.RollerUserId == SelectedCampaign.Gm.Id)
return "GM";
return "Participant";
return entry.RollerDisplayName;
}
private string VisibilityLabel(CampaignLogEntry entry)
@@ -866,6 +892,8 @@ public partial class Workspace : IAsyncDisposable
SelectedCampaign = null;
Campaigns = [];
CharacterCampaignOptions = [];
SelectedCharacterSkills = [];
SelectedCharacterSkillGroups = [];
CampaignLog = [];
SelectedCharacterId = null;
LastRoll = null;
@@ -934,9 +962,11 @@ public partial class Workspace : IAsyncDisposable
private UserSummary? User { get; set; }
private Guid? ActiveCharacterId { get; set; }
private Guid? SelectedCampaignId { get; set; }
private CampaignDetails? SelectedCampaign { get; set; }
private List<CampaignDetails> Campaigns { get; set; } = [];
private CampaignRoster? SelectedCampaign { get; set; }
private List<CampaignSummary> Campaigns { get; set; } = [];
private List<CampaignOption> CharacterCampaignOptions { get; set; } = [];
private List<SkillSummary> SelectedCharacterSkills { get; set; } = [];
private List<SkillGroupSummary> SelectedCharacterSkillGroups { get; set; } = [];
private List<CampaignLogEntry> CampaignLog { get; set; } = [];
private List<RulesetDefinition> Rulesets { get; set; } = [];
private List<AdminUserSummary> AdminUsers { get; set; } = [];
@@ -973,12 +1003,12 @@ public partial class Workspace : IAsyncDisposable
[Parameter]
public EventCallback<string?> LoggedOut { get; set; }
private string? SelectedCampaignName => SelectedCampaign?.Name;
private string? SelectedCampaignName => SelectedCampaign?.Name ?? Campaigns.FirstOrDefault(campaign => campaign.Id == SelectedCampaignId)?.Name;
private CharacterSummary? SelectedCharacter =>
SelectedCampaign?.Characters.FirstOrDefault(c => c.Id == SelectedCharacterId);
private CampaignDetails? PlaySelectedCampaign
private CampaignRoster? PlaySelectedCampaign
{
get
{
@@ -986,27 +1016,18 @@ public partial class Workspace : IAsyncDisposable
return null;
if (User is null)
return new CampaignDetails(SelectedCampaign.Id, SelectedCampaign.Name, SelectedCampaign.RulesetId, SelectedCampaign.Gm, [], [], []);
return new CampaignRoster(SelectedCampaign.Id, SelectedCampaign.Name, SelectedCampaign.RulesetId, SelectedCampaign.Gm, []);
var ownedCharacters = SelectedCampaign.Characters
.Where(character => character.OwnerUserId == User.Id)
.ToList();
var ownedCharacterIds = ownedCharacters.Select(character => character.Id).ToHashSet();
var ownedSkillGroups = SelectedCampaign.SkillGroups
.Where(group => ownedCharacterIds.Contains(group.CharacterId))
.ToList();
var ownedSkills = SelectedCampaign.Skills
.Where(skill => ownedCharacterIds.Contains(skill.CharacterId))
.ToList();
return new CampaignDetails(
return new CampaignRoster(
SelectedCampaign.Id,
SelectedCampaign.Name,
SelectedCampaign.RulesetId,
SelectedCampaign.Gm,
ownedCharacters,
ownedSkillGroups,
ownedSkills);
ownedCharacters);
}
}
@@ -1038,20 +1059,10 @@ public partial class Workspace : IAsyncDisposable
private Guid? PlaySelectedCharacterId => PlaySelectedCharacter?.Id;
private List<SkillSummary> PlaySelectedCharacterSkills =>
PlaySelectedCampaign is null || !PlaySelectedCharacterId.HasValue
? []
: PlaySelectedCampaign.Skills
.Where(skill => skill.CharacterId == PlaySelectedCharacterId.Value)
.OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
PlaySelectedCampaign is null || !PlaySelectedCharacterId.HasValue ? [] : SelectedCharacterSkills;
private List<SkillGroupSummary> PlaySelectedCharacterSkillGroups =>
PlaySelectedCampaign is null || !PlaySelectedCharacterId.HasValue
? []
: PlaySelectedCampaign.SkillGroups
.Where(group => group.CharacterId == PlaySelectedCharacterId.Value)
.OrderBy(group => group.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
PlaySelectedCampaign is null || !PlaySelectedCharacterId.HasValue ? [] : SelectedCharacterSkillGroups;
private List<CampaignLogEntry> PlayVisibleCampaignLog =>
User is null
@@ -1079,12 +1090,6 @@ public partial class Workspace : IAsyncDisposable
return user.Roles.Contains(UserRoles.Admin, StringComparer.OrdinalIgnoreCase);
}
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, ScreenPlay, StringComparison.OrdinalIgnoreCase);
private bool IsManagementScreen => string.Equals(CurrentScreen, ScreenManagement, StringComparison.OrdinalIgnoreCase);
private bool IsAdminScreen => string.Equals(CurrentScreen, ScreenAdmin, StringComparison.OrdinalIgnoreCase);

View File

@@ -20,7 +20,9 @@ public sealed record RulesetDefinition(string Id, string Name, string DiceSyntax
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 CampaignSummary(Guid Id, string Name, string RulesetId, UserSummary Gm, int CharacterCount);
public sealed record CampaignRoster(Guid Id, string Name, string RulesetId, UserSummary Gm, IReadOnlyList<CharacterSummary> Characters);
public sealed record CampaignOption(Guid Id, string Name);
@@ -48,4 +50,6 @@ 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 CharacterSheet(Guid CharacterId, IReadOnlyList<SkillGroupSummary> SkillGroups, IReadOnlyList<SkillSummary> Skills);
public sealed record CampaignLogEntry(Guid RollId, Guid CampaignId, Guid CharacterId, string CharacterName, Guid SkillId, string SkillName, Guid RollerUserId, string RollerDisplayName, string Visibility, int Result, string Breakdown, IReadOnlyList<RollDieResult> Dice, DateTimeOffset TimestampUtc);

View File

@@ -4,9 +4,7 @@ using RpgRoller.Hosting;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRpgRollerCore(builder.Configuration, builder.Environment);
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents()
.AddHubOptions(options => options.MaximumReceiveMessageSize = 256 * 1024);
builder.Services.AddRazorComponents().AddInteractiveServerComponents();
builder.Services.AddScoped<RpgRollerApiClient>();
var app = builder.Build();

View File

@@ -128,20 +128,20 @@ public sealed class GameService : IGameService
}
}
public ServiceResult<CampaignDetails> CreateCampaign(string sessionToken, string name, string rulesetId)
public ServiceResult<CampaignSummary> CreateCampaign(string sessionToken, string name, string rulesetId)
{
if (string.IsNullOrWhiteSpace(name))
return ServiceResult<CampaignDetails>.Failure("invalid_campaign_name", "Campaign name is required.");
return ServiceResult<CampaignSummary>.Failure("invalid_campaign_name", "Campaign name is required.");
var ruleset = DiceRules.TryParseRulesetId(rulesetId);
if (ruleset is null)
return ServiceResult<CampaignDetails>.Failure("invalid_ruleset", "Unknown ruleset.");
return ServiceResult<CampaignSummary>.Failure("invalid_ruleset", "Unknown ruleset.");
lock (m_Gate)
{
var user = ResolveUserLocked(sessionToken);
if (user is null)
return ServiceResult<CampaignDetails>.Failure("unauthorized", "You must be logged in.");
return ServiceResult<CampaignSummary>.Failure("unauthorized", "You must be logged in.");
var campaign = new Campaign
{
@@ -154,17 +154,17 @@ public sealed class GameService : IGameService
m_CampaignsById[campaign.Id] = campaign;
PersistStateLocked();
return ServiceResult<CampaignDetails>.Success(ToCampaignDetails(campaign));
return ServiceResult<CampaignSummary>.Success(ToCampaignSummary(campaign));
}
}
public ServiceResult<IReadOnlyList<CampaignDetails>> GetCampaigns(string sessionToken)
public ServiceResult<IReadOnlyList<CampaignSummary>> GetCampaigns(string sessionToken)
{
lock (m_Gate)
{
var user = ResolveUserLocked(sessionToken);
if (user is null)
return ServiceResult<IReadOnlyList<CampaignDetails>>.Failure("unauthorized", "You must be logged in.");
return ServiceResult<IReadOnlyList<CampaignSummary>>.Failure("unauthorized", "You must be logged in.");
IEnumerable<Campaign> visibleCampaigns;
if (UserHasRoleLocked(user, UserRoles.Admin))
@@ -180,9 +180,9 @@ public sealed class GameService : IGameService
visibleCampaigns = campaignIds.Select(campaignId => m_CampaignsById[campaignId]);
}
var results = visibleCampaigns.OrderBy(campaign => campaign.Name, StringComparer.OrdinalIgnoreCase).Select(ToCampaignDetails).ToArray();
var results = visibleCampaigns.OrderBy(campaign => campaign.Name, StringComparer.OrdinalIgnoreCase).Select(ToCampaignSummary).ToArray();
return ServiceResult<IReadOnlyList<CampaignDetails>>.Success(results);
return ServiceResult<IReadOnlyList<CampaignSummary>>.Success(results);
}
}
@@ -203,24 +203,16 @@ public sealed class GameService : IGameService
}
}
public ServiceResult<CampaignDetails> GetCampaign(string sessionToken, Guid campaignId)
public ServiceResult<CampaignRoster> GetCampaign(string sessionToken, Guid campaignId)
{
lock (m_Gate)
{
var context = ResolveContextLocked(sessionToken, campaignId);
if (!context.Succeeded)
return ServiceResult<CampaignDetails>.Failure(context.Error!.Code, context.Error.Message);
return ServiceResult<CampaignRoster>.Failure(context.Error!.Code, context.Error.Message);
var (_, campaign) = context.Value;
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 ServiceResult<CampaignDetails>.Success(new(campaign.Id, campaign.Name, DiceRules.ToRulesetId(campaign.Ruleset), ToUserSummary(gm), characters, skillGroups, skills));
return ServiceResult<CampaignRoster>.Success(ToCampaignRoster(campaign));
}
}
@@ -718,6 +710,27 @@ public sealed class GameService : IGameService
}
}
public ServiceResult<CharacterSheet> GetCharacterSheet(string sessionToken, Guid characterId)
{
lock (m_Gate)
{
var user = ResolveUserLocked(sessionToken);
if (user is null)
return ServiceResult<CharacterSheet>.Failure("unauthorized", "You must be logged in.");
if (!m_CharactersById.TryGetValue(characterId, out var character))
return ServiceResult<CharacterSheet>.Failure("character_not_found", "Character was not found.");
if (!TryResolveCharacterCampaignLocked(character, out var campaign, out var campaignError))
return ServiceResult<CharacterSheet>.Failure(campaignError!.Code, campaignError.Message);
if (!CanViewCampaignLocked(user.Id, campaign.Id))
return ServiceResult<CharacterSheet>.Failure("forbidden", "You are not a participant in this campaign.");
return ServiceResult<CharacterSheet>.Success(ToCharacterSheet(character.Id));
}
}
public ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility)
{
lock (m_Gate)
@@ -776,7 +789,16 @@ public sealed class GameService : IGameService
return ServiceResult<IReadOnlyList<CampaignLogEntry>>.Failure(context.Error!.Code, context.Error.Message);
var (user, campaign) = context.Value!;
var entries = m_RollLog.Where(r => r.CampaignId == campaign.Id).Where(r => r.Visibility == RollVisibility.Public || r.RollerUserId == user.Id || campaign.GmUserId == user.Id).OrderBy(r => r.TimestampUtc).ThenBy(r => r.Id).Select(ToLogEntry).ToArray();
var entries = m_RollLog
.Where(r => r.CampaignId == campaign.Id)
.Where(r => r.Visibility == RollVisibility.Public || r.RollerUserId == user.Id || campaign.GmUserId == user.Id)
.OrderByDescending(r => r.TimestampUtc)
.ThenByDescending(r => r.Id)
.Take(CampaignLogPageSize)
.OrderBy(r => r.TimestampUtc)
.ThenBy(r => r.Id)
.Select(ToLogEntry)
.ToArray();
return ServiceResult<IReadOnlyList<CampaignLogEntry>>.Success(entries);
}
@@ -989,18 +1011,39 @@ public sealed class GameService : IGameService
return new(campaign.Id, campaign.Name);
}
private CampaignDetails ToCampaignDetails(Campaign campaign)
private CampaignSummary ToCampaignSummary(Campaign campaign)
{
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();
var gm = m_UsersById[campaign.GmUserId];
var characterCount = m_CharactersById.Values.Count(character => character.CampaignId == campaign.Id);
return new(campaign.Id, campaign.Name, DiceRules.ToRulesetId(campaign.Ruleset), ToUserSummary(gm), characterCount);
}
return new(campaign.Id, campaign.Name, DiceRules.ToRulesetId(campaign.Ruleset), ToUserSummary(gm), characters, skillGroups, skills);
}
private CampaignRoster ToCampaignRoster(Campaign campaign)
{
var gm = m_UsersById[campaign.GmUserId];
var characters = m_CharactersById.Values
.Where(character => character.CampaignId == campaign.Id)
.OrderBy(character => character.Name, StringComparer.OrdinalIgnoreCase)
.Select(ToCharacterSummary)
.ToArray();
return new(campaign.Id, campaign.Name, DiceRules.ToRulesetId(campaign.Ruleset), ToUserSummary(gm), characters);
}
private CharacterSheet ToCharacterSheet(Guid characterId)
{
var skillGroups = m_SkillGroupsById.Values
.Where(group => group.CharacterId == characterId)
.OrderBy(group => group.Name, StringComparer.OrdinalIgnoreCase)
.Select(ToSkillGroupSummary)
.ToArray();
var skills = m_SkillsById.Values
.Where(skill => skill.CharacterId == characterId)
.OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase)
.Select(ToSkillSummary)
.ToArray();
return new(characterId, skillGroups, skills);
}
private CharacterSummary ToCharacterSummary(Character character)
@@ -1024,11 +1067,27 @@ public sealed class GameService : IGameService
return new(entry.Id, entry.CampaignId, entry.CharacterId, entry.SkillId, entry.RollerUserId, entry.Visibility == RollVisibility.Public ? "public" : "private", entry.Result, entry.Breakdown, dice, entry.TimestampUtc);
}
private static CampaignLogEntry ToLogEntry(RollLogEntry entry)
private CampaignLogEntry ToLogEntry(RollLogEntry entry)
{
var dice = DeserializeDice(entry.Dice);
var characterName = m_CharactersById.TryGetValue(entry.CharacterId, out var character) ? character.Name : "Unknown character";
var skillName = m_SkillsById.TryGetValue(entry.SkillId, out var skill) ? skill.Name : "Unknown skill";
var rollerDisplayName = ResolveOwnerDisplayName(entry.RollerUserId);
return new(entry.Id, entry.CampaignId, entry.CharacterId, entry.SkillId, entry.RollerUserId, entry.Visibility == RollVisibility.Public ? "public" : "private", entry.Result, entry.Breakdown, dice, entry.TimestampUtc);
return new(
entry.Id,
entry.CampaignId,
entry.CharacterId,
characterName,
entry.SkillId,
skillName,
entry.RollerUserId,
rollerDisplayName,
entry.Visibility == RollVisibility.Public ? "public" : "private",
entry.Result,
entry.Breakdown,
dice,
entry.TimestampUtc);
}
private static string SerializeDice(IReadOnlyList<RollDieResult> dice)
@@ -1385,6 +1444,7 @@ public sealed class GameService : IGameService
};
}
private const int CampaignLogPageSize = 100;
private static readonly JsonSerializerOptions DiceJsonOptions = new(JsonSerializerDefaults.Web);
private readonly Dictionary<Guid, Campaign> m_CampaignsById = [];
private readonly Dictionary<Guid, Character> m_CharactersById = [];

View File

@@ -12,10 +12,10 @@ public interface IGameService
UserSummary? GetUserBySession(string sessionToken);
ServiceResult<MeResponse> GetMe(string sessionToken);
ServiceResult<CampaignDetails> CreateCampaign(string sessionToken, string name, string rulesetId);
ServiceResult<IReadOnlyList<CampaignDetails>> GetCampaigns(string sessionToken);
ServiceResult<CampaignSummary> CreateCampaign(string sessionToken, string name, string rulesetId);
ServiceResult<IReadOnlyList<CampaignSummary>> GetCampaigns(string sessionToken);
ServiceResult<IReadOnlyList<CampaignOption>> GetCharacterCampaignOptions(string sessionToken);
ServiceResult<CampaignDetails> GetCampaign(string sessionToken, Guid campaignId);
ServiceResult<CampaignRoster> GetCampaign(string sessionToken, Guid campaignId);
ServiceResult<bool> DeleteCampaign(string sessionToken, Guid campaignId);
ServiceResult<IReadOnlyList<string>> GetUsernames(string sessionToken);
ServiceResult<IReadOnlyList<AdminUserSummary>> GetUsers(string sessionToken);
@@ -34,6 +34,7 @@ public interface IGameService
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<bool> DeleteSkill(string sessionToken, Guid skillId);
ServiceResult<CharacterSheet> GetCharacterSheet(string sessionToken, Guid characterId);
ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility);
ServiceResult<IReadOnlyList<CampaignLogEntry>> GetCampaignLog(string sessionToken, Guid campaignId);