From 8561c6643a51d80d37f6ea3b6e065c680b7d3034 Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Wed, 1 Apr 2026 23:06:46 +0200 Subject: [PATCH] Refactor campaign payload loading --- README.md | 1 + RpgRoller.Tests/Api/CampaignApiTests.cs | 66 +++++++-- RpgRoller.Tests/Api/FrontendHostTests.cs | 23 ---- RpgRoller.Tests/Api/RollVisibilityApiTests.cs | 4 +- RpgRoller.Tests/Api/SystemApiTests.cs | 4 +- .../Services/ServiceCampaignTests.cs | 10 +- .../ServiceSkillGroupAndOwnershipTests.cs | 14 +- RpgRoller/Api/CharacterEndpoints.cs | 6 + .../Pages/HomeControls/CampaignLogPanel.razor | 4 +- .../HomeControls/CampaignLogPanel.razor.cs | 6 - .../CampaignManagementPanel.razor | 2 +- .../CampaignManagementPanel.razor.cs | 6 +- .../HomeControls/CharacterPanel.razor.cs | 2 +- RpgRoller/Components/Pages/Workspace.razor | 2 - RpgRoller/Components/Pages/Workspace.razor.cs | 103 +++++++------- RpgRoller/Contracts/ApiContracts.cs | 8 +- RpgRoller/Program.cs | 4 +- RpgRoller/Services/GameService.cs | 126 +++++++++++++----- RpgRoller/Services/IGameService.cs | 7 +- 19 files changed, 246 insertions(+), 152 deletions(-) diff --git a/README.md b/README.md index 70bf617..67ffb62 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,7 @@ dotnet dotnet-ef migrations add --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 diff --git a/RpgRoller.Tests/Api/CampaignApiTests.cs b/RpgRoller.Tests/Api/CampaignApiTests.cs index cd0fba5..70cb91f 100644 --- a/RpgRoller.Tests/Api/CampaignApiTests.cs +++ b/RpgRoller.Tests/Api/CampaignApiTests.cs @@ -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(gmClient, "/api/campaigns", new("Alpha Campaign", "dnd5e")); + var campaign = await PostAsync(gmClient, "/api/campaigns", new("Alpha Campaign", "dnd5e")); var gmCharacter = await PostAsync(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(gmClient, $"/api/campaigns/{campaign.Id}"); + var details = await GetAsync(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>(gmClient, "/api/campaigns"); + Assert.Single(summaries); + Assert.Equal(1, summaries[0].CharacterCount); + + var sheet = await GetAsync(gmClient, $"/api/characters/{gmCharacter.Id}/sheet"); + Assert.Single(sheet.Skills); + Assert.Equal(updatedSkill.Id, sheet.Skills[0].Id); + var currentCampaignCharacters = await GetAsync>(gmClient, "/api/characters"); Assert.Single(currentCampaignCharacters); Assert.Equal(gmCharacter.Id, currentCampaignCharacters[0].Id); Assert.Equal("Game Master", currentCampaignCharacters[0].OwnerDisplayName); - var otherCampaign = await PostAsync(gmClient, "/api/campaigns", new("Beta Campaign", "d6")); + var otherCampaign = await PostAsync(gmClient, "/api/campaigns", new("Beta Campaign", "d6")); var updatedCharacter = await PutAsync(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(gmClient, "/api/campaigns", new("Grouped Campaign", "d6")); + var campaign = await PostAsync(gmClient, "/api/campaigns", new("Grouped Campaign", "d6")); var character = await PostAsync(ownerClient, "/api/characters", new("Grouped Hero", campaign.Id)); var createdGroup = await PostAsync(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(gmClient, $"/api/campaigns/{campaign.Id}"); + var gmCampaignView = await GetAsync(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(adminClient, $"/api/admin/users/{player.Id}/roles", new([ "admin" ])); Assert.Contains(promotedPlayer.Roles, role => string.Equals(role, "admin", StringComparison.OrdinalIgnoreCase)); - var campaign = await PostAsync(gmClient, "/api/campaigns", new("Disposable Campaign", "d6")); + var campaign = await PostAsync(gmClient, "/api/campaigns", new("Disposable Campaign", "d6")); var character = await PostAsync(playerClient, "/api/characters", new("Disposable Hero", campaign.Id)); var skill = await PostAsync(playerClient, $"/api/characters/{character.Id}/skills", new("Stealth", "2D+1", 1, true)); _ = await PostAsync(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(gmClient, "/api/campaigns", new("Alpha Visible", "d6")); - var secondCampaign = await PostAsync(otherGmClient, "/api/campaigns", new("Beta Available", "d6")); + var firstCampaign = await PostAsync(gmClient, "/api/campaigns", new("Alpha Visible", "d6")); + var secondCampaign = await PostAsync(otherGmClient, "/api/campaigns", new("Beta Available", "d6")); - var playerVisibleCampaigns = await GetAsync>(playerClient, "/api/campaigns"); + var playerVisibleCampaigns = await GetAsync>(playerClient, "/api/campaigns"); Assert.Empty(playerVisibleCampaigns); var playerCampaignOptions = await GetAsync>(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(gmClient, "/api/campaigns", new("Deletion Campaign", "d6")); + var campaign = await PostAsync(gmClient, "/api/campaigns", new("Deletion Campaign", "d6")); var ownerCharacter = await PostAsync(ownerClient, "/api/characters", new("Owner Character", campaign.Id)); var otherCharacter = await PostAsync(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(gmClient, $"/api/campaigns/{campaign.Id}"); + var campaignAfterDeletes = await GetAsync(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(gmClient, "/api/campaigns", new("Log Cap", "d6")); + var character = await PostAsync(playerClient, "/api/characters", new("Roller", campaign.Id)); + var skill = await PostAsync(playerClient, $"/api/characters/{character.Id}/skills", new("Stealth", "2D+1", 1, true)); + + var rollIds = new List(); + for (var i = 0; i < 105; i++) + { + var roll = await PostAsync(playerClient, $"/api/skills/{skill.Id}/roll", new("public")); + rollIds.Add(roll.RollId); + } + + var log = await GetAsync>(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)); + }); + } } diff --git a/RpgRoller.Tests/Api/FrontendHostTests.cs b/RpgRoller.Tests/Api/FrontendHostTests.cs index 291497d..b7801c3 100644 --- a/RpgRoller.Tests/Api/FrontendHostTests.cs +++ b/RpgRoller.Tests/Api/FrontendHostTests.cs @@ -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); - } } diff --git a/RpgRoller.Tests/Api/RollVisibilityApiTests.cs b/RpgRoller.Tests/Api/RollVisibilityApiTests.cs index 62a1c87..fc2c92b 100644 --- a/RpgRoller.Tests/Api/RollVisibilityApiTests.cs +++ b/RpgRoller.Tests/Api/RollVisibilityApiTests.cs @@ -17,7 +17,7 @@ public sealed class RollVisibilityApiTests : ApiTestBase await RegisterAsync(gmClient, "gm", "Password123", "GM"); await LoginAsync(gmClient, "gm", "Password123"); - var campaign = await PostAsync(gmClient, "/api/campaigns", new("Main", "d6")); + var campaign = await PostAsync(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); } -} \ No newline at end of file +} diff --git a/RpgRoller.Tests/Api/SystemApiTests.cs b/RpgRoller.Tests/Api/SystemApiTests.cs index 5ea25e0..db14a29 100644 --- a/RpgRoller.Tests/Api/SystemApiTests.cs +++ b/RpgRoller.Tests/Api/SystemApiTests.cs @@ -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(client, "/api/campaigns", new("SSE", "d6")); + var campaign = await PostAsync(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); } -} \ No newline at end of file +} diff --git a/RpgRoller.Tests/Services/ServiceCampaignTests.cs b/RpgRoller.Tests/Services/ServiceCampaignTests.cs index c13ab96..476bd98 100644 --- a/RpgRoller.Tests/Services/ServiceCampaignTests.cs +++ b/RpgRoller.Tests/Services/ServiceCampaignTests.cs @@ -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); } } diff --git a/RpgRoller.Tests/Services/ServiceSkillGroupAndOwnershipTests.cs b/RpgRoller.Tests/Services/ServiceSkillGroupAndOwnershipTests.cs index 8db0ff7..d43b673 100644 --- a/RpgRoller.Tests/Services/ServiceSkillGroupAndOwnershipTests.cs +++ b/RpgRoller.Tests/Services/ServiceSkillGroupAndOwnershipTests.cs @@ -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); } diff --git a/RpgRoller/Api/CharacterEndpoints.cs b/RpgRoller/Api/CharacterEndpoints.cs index d912265..8b6164e 100644 --- a/RpgRoller/Api/CharacterEndpoints.cs +++ b/RpgRoller/Api/CharacterEndpoints.cs @@ -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()); diff --git a/RpgRoller/Components/Pages/HomeControls/CampaignLogPanel.razor b/RpgRoller/Components/Pages/HomeControls/CampaignLogPanel.razor index 67acbbd..1bd0c48 100644 --- a/RpgRoller/Components/Pages/HomeControls/CampaignLogPanel.razor +++ b/RpgRoller/Components/Pages/HomeControls/CampaignLogPanel.razor @@ -18,8 +18,8 @@ @foreach (var entry in CampaignLog) {
  • -

    @RollerLabel(entry) rolled @SkillLabel(entry.SkillId) with - @CharacterLabel(entry.CharacterId)

    +

    @RollerLabel(entry) rolled @entry.SkillName with + @entry.CharacterName

    @entry.Result

    @entry.Breakdown

    diff --git a/RpgRoller/Components/Pages/HomeControls/CampaignLogPanel.razor.cs b/RpgRoller/Components/Pages/HomeControls/CampaignLogPanel.razor.cs index 439c564..06169e2 100644 --- a/RpgRoller/Components/Pages/HomeControls/CampaignLogPanel.razor.cs +++ b/RpgRoller/Components/Pages/HomeControls/CampaignLogPanel.razor.cs @@ -48,12 +48,6 @@ public partial class CampaignLogPanel [Parameter] public Func RollerLabel { get; set; } = _ => string.Empty; - [Parameter] - public Func SkillLabel { get; set; } = _ => string.Empty; - - [Parameter] - public Func CharacterLabel { get; set; } = _ => string.Empty; - [Parameter] public Func LogEntryCssClass { get; set; } = _ => string.Empty; diff --git a/RpgRoller/Components/Pages/HomeControls/CampaignManagementPanel.razor b/RpgRoller/Components/Pages/HomeControls/CampaignManagementPanel.razor index 3aa014b..12dbf07 100644 --- a/RpgRoller/Components/Pages/HomeControls/CampaignManagementPanel.razor +++ b/RpgRoller/Components/Pages/HomeControls/CampaignManagementPanel.razor @@ -13,7 +13,7 @@ } diff --git a/RpgRoller/Components/Pages/HomeControls/CampaignManagementPanel.razor.cs b/RpgRoller/Components/Pages/HomeControls/CampaignManagementPanel.razor.cs index ff530e6..b1f22b1 100644 --- a/RpgRoller/Components/Pages/HomeControls/CampaignManagementPanel.razor.cs +++ b/RpgRoller/Components/Pages/HomeControls/CampaignManagementPanel.razor.cs @@ -48,7 +48,7 @@ public partial class CampaignManagementPanel IsCreatingCampaign = true; try { - var campaign = await ApiClient.RequestAsync("POST", "/api/campaigns", new CreateCampaignRequest(CampaignState.Model.Name.Trim(), CampaignState.Model.RulesetId)); + var campaign = await ApiClient.RequestAsync("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 Campaigns { get; set; } = []; + public IReadOnlyList Campaigns { get; set; } = []; [Parameter] public Guid? SelectedCampaignId { get; set; } [Parameter] - public CampaignDetails? SelectedCampaign { get; set; } + public CampaignRoster? SelectedCampaign { get; set; } [Parameter] public IReadOnlyList Rulesets { get; set; } = []; diff --git a/RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor.cs b/RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor.cs index 7b98400..6f7d466 100644 --- a/RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor.cs +++ b/RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor.cs @@ -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; } diff --git a/RpgRoller/Components/Pages/Workspace.razor b/RpgRoller/Components/Pages/Workspace.razor index 4e8b2e5..4209c92 100644 --- a/RpgRoller/Components/Pages/Workspace.razor +++ b/RpgRoller/Components/Pages/Workspace.razor @@ -61,8 +61,6 @@ IsCampaignDataLoading="IsCampaignDataLoading" CampaignLog="PlayVisibleCampaignLog" RollerLabel="RollerLabel" - SkillLabel="SkillLabel" - CharacterLabel="CharacterLabel" LogEntryCssClass="LogEntryCssClass" VisibilityLabel="VisibilityLabel" VisibilityBadgeCssClass="VisibilityBadgeCssClass"/> diff --git a/RpgRoller/Components/Pages/Workspace.razor.cs b/RpgRoller/Components/Pages/Workspace.razor.cs index a8dc79b..3a86dc7 100644 --- a/RpgRoller/Components/Pages/Workspace.razor.cs +++ b/RpgRoller/Components/Pages/Workspace.razor.cs @@ -143,7 +143,7 @@ public partial class Workspace : IAsyncDisposable private async Task ReloadCampaignsAsync(Guid? preferredCampaignId) { - var campaigns = await ApiClient.RequestAsync>("GET", "/api/campaigns"); + var campaigns = await ApiClient.RequestAsync>("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("GET", $"/api/campaigns/{campaignId}"); - CampaignLog = (await ApiClient.RequestAsync>("GET", $"/api/campaigns/{campaignId}/log")).ToList(); + SelectedCampaign = await ApiClient.RequestAsync("GET", $"/api/campaigns/{campaignId}"); SyncSelectedCharacter(); + + if (IsPlayScreen && PlaySelectedCharacterId.HasValue && SelectedCharacterId != PlaySelectedCharacterId) + SelectedCharacterId = PlaySelectedCharacterId; + + await RefreshSelectedCharacterSheetAsync(); + + CampaignLog = IsPlayScreen + ? (await ApiClient.RequestAsync>("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("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 Campaigns { get; set; } = []; + private CampaignRoster? SelectedCampaign { get; set; } + private List Campaigns { get; set; } = []; private List CharacterCampaignOptions { get; set; } = []; + private List SelectedCharacterSkills { get; set; } = []; + private List SelectedCharacterSkillGroups { get; set; } = []; private List CampaignLog { get; set; } = []; private List Rulesets { get; set; } = []; private List AdminUsers { get; set; } = []; @@ -973,12 +1003,12 @@ public partial class Workspace : IAsyncDisposable [Parameter] public EventCallback 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 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 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 PlayVisibleCampaignLog => User is null @@ -1079,12 +1090,6 @@ public partial class Workspace : IAsyncDisposable return user.Roles.Contains(UserRoles.Admin, StringComparer.OrdinalIgnoreCase); } - private List SelectedCharacterSkills => - SelectedCampaign is null || !SelectedCharacterId.HasValue ? [] : SelectedCampaign.Skills.Where(skill => skill.CharacterId == SelectedCharacterId.Value).OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase).ToList(); - - private List 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); diff --git a/RpgRoller/Contracts/ApiContracts.cs b/RpgRoller/Contracts/ApiContracts.cs index d54a955..d1426a7 100644 --- a/RpgRoller/Contracts/ApiContracts.cs +++ b/RpgRoller/Contracts/ApiContracts.cs @@ -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 Characters, IReadOnlyList SkillGroups, IReadOnlyList 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 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 Dice, DateTimeOffset TimestampUtc); -public sealed record CampaignLogEntry(Guid RollId, Guid CampaignId, Guid CharacterId, Guid SkillId, Guid RollerUserId, string Visibility, int Result, string Breakdown, IReadOnlyList Dice, DateTimeOffset TimestampUtc); +public sealed record CharacterSheet(Guid CharacterId, IReadOnlyList SkillGroups, IReadOnlyList 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 Dice, DateTimeOffset TimestampUtc); diff --git a/RpgRoller/Program.cs b/RpgRoller/Program.cs index 1e192ab..8733b17 100644 --- a/RpgRoller/Program.cs +++ b/RpgRoller/Program.cs @@ -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(); var app = builder.Build(); diff --git a/RpgRoller/Services/GameService.cs b/RpgRoller/Services/GameService.cs index dc7141f..a1843a5 100644 --- a/RpgRoller/Services/GameService.cs +++ b/RpgRoller/Services/GameService.cs @@ -128,20 +128,20 @@ public sealed class GameService : IGameService } } - public ServiceResult CreateCampaign(string sessionToken, string name, string rulesetId) + public ServiceResult CreateCampaign(string sessionToken, string name, string rulesetId) { if (string.IsNullOrWhiteSpace(name)) - return ServiceResult.Failure("invalid_campaign_name", "Campaign name is required."); + return ServiceResult.Failure("invalid_campaign_name", "Campaign name is required."); var ruleset = DiceRules.TryParseRulesetId(rulesetId); if (ruleset is null) - return ServiceResult.Failure("invalid_ruleset", "Unknown ruleset."); + return ServiceResult.Failure("invalid_ruleset", "Unknown ruleset."); lock (m_Gate) { var user = ResolveUserLocked(sessionToken); if (user is null) - return ServiceResult.Failure("unauthorized", "You must be logged in."); + return ServiceResult.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.Success(ToCampaignDetails(campaign)); + return ServiceResult.Success(ToCampaignSummary(campaign)); } } - public ServiceResult> GetCampaigns(string sessionToken) + public ServiceResult> GetCampaigns(string sessionToken) { lock (m_Gate) { var user = ResolveUserLocked(sessionToken); if (user is null) - return ServiceResult>.Failure("unauthorized", "You must be logged in."); + return ServiceResult>.Failure("unauthorized", "You must be logged in."); IEnumerable 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>.Success(results); + return ServiceResult>.Success(results); } } @@ -203,24 +203,16 @@ public sealed class GameService : IGameService } } - public ServiceResult GetCampaign(string sessionToken, Guid campaignId) + public ServiceResult GetCampaign(string sessionToken, Guid campaignId) { lock (m_Gate) { var context = ResolveContextLocked(sessionToken, campaignId); if (!context.Succeeded) - return ServiceResult.Failure(context.Error!.Code, context.Error.Message); + return ServiceResult.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.Success(new(campaign.Id, campaign.Name, DiceRules.ToRulesetId(campaign.Ruleset), ToUserSummary(gm), characters, skillGroups, skills)); + return ServiceResult.Success(ToCampaignRoster(campaign)); } } @@ -718,6 +710,27 @@ public sealed class GameService : IGameService } } + public ServiceResult GetCharacterSheet(string sessionToken, Guid characterId) + { + lock (m_Gate) + { + var user = ResolveUserLocked(sessionToken); + if (user is null) + return ServiceResult.Failure("unauthorized", "You must be logged in."); + + if (!m_CharactersById.TryGetValue(characterId, out var character)) + return ServiceResult.Failure("character_not_found", "Character was not found."); + + if (!TryResolveCharacterCampaignLocked(character, out var campaign, out var campaignError)) + return ServiceResult.Failure(campaignError!.Code, campaignError.Message); + + if (!CanViewCampaignLocked(user.Id, campaign.Id)) + return ServiceResult.Failure("forbidden", "You are not a participant in this campaign."); + + return ServiceResult.Success(ToCharacterSheet(character.Id)); + } + } + public ServiceResult RollSkill(string sessionToken, Guid skillId, string visibility) { lock (m_Gate) @@ -776,7 +789,16 @@ public sealed class GameService : IGameService return ServiceResult>.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>.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 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 m_CampaignsById = []; private readonly Dictionary m_CharactersById = []; diff --git a/RpgRoller/Services/IGameService.cs b/RpgRoller/Services/IGameService.cs index a541ccf..dd7a5da 100644 --- a/RpgRoller/Services/IGameService.cs +++ b/RpgRoller/Services/IGameService.cs @@ -12,10 +12,10 @@ public interface IGameService UserSummary? GetUserBySession(string sessionToken); ServiceResult GetMe(string sessionToken); - ServiceResult CreateCampaign(string sessionToken, string name, string rulesetId); - ServiceResult> GetCampaigns(string sessionToken); + ServiceResult CreateCampaign(string sessionToken, string name, string rulesetId); + ServiceResult> GetCampaigns(string sessionToken); ServiceResult> GetCharacterCampaignOptions(string sessionToken); - ServiceResult GetCampaign(string sessionToken, Guid campaignId); + ServiceResult GetCampaign(string sessionToken, Guid campaignId); ServiceResult DeleteCampaign(string sessionToken, Guid campaignId); ServiceResult> GetUsernames(string sessionToken); ServiceResult> GetUsers(string sessionToken); @@ -34,6 +34,7 @@ public interface IGameService ServiceResult CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null); ServiceResult UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null); ServiceResult DeleteSkill(string sessionToken, Guid skillId); + ServiceResult GetCharacterSheet(string sessionToken, Guid characterId); ServiceResult RollSkill(string sessionToken, Guid skillId, string visibility); ServiceResult> GetCampaignLog(string sessionToken, Guid campaignId);