diff --git a/README.md b/README.md index f7894e8..5ddb8af 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,8 @@ dotnet dotnet-ef migrations add --project RpgRoller/RpgRoller.cs - Browser interop is in `RpgRoller/wwwroot/js/rpgroller-api.js`. - Workspace reads are resolved server-side through scoped query services; browser interop remains for browser-only concerns such as session storage, SSE wiring, and DOM helpers. - Live workspace refreshes now compare separate roster, per-character sheet, and log versions so unrelated state changes do not force a full roster + sheet + log reload. -- Workspace campaign data is loaded in bounded slices: visible campaign summaries, a selected campaign roster, a selected character sheet, and an incremental log window backed by `/api/campaigns/{campaignId}/log/page`. +- Workspace campaign data is loaded in bounded slices: visible campaign summaries, a selected campaign roster, a selected character sheet, and a 50-row incremental log window backed by `/api/campaigns/{campaignId}/log/page`. +- Campaign log rows now ship compact summary data first and lazy-load dice + breakdown detail through `/api/rolls/{rollId}` only when a row is expanded. - 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 16b6272..3659c6b 100644 --- a/RpgRoller.Tests/Api/CampaignApiTests.cs +++ b/RpgRoller.Tests/Api/CampaignApiTests.cs @@ -335,12 +335,17 @@ public sealed class CampaignApiTests : ApiTestBase } var initialPage = await GetAsync(gmClient, $"/api/campaigns/{campaign.Id}/log/page?limit=3"); - Assert.Equal(3, initialPage.Entries.Count); + Assert.Equal(3, initialPage.Entries.Length); Assert.Equal(rollIds[2], initialPage.Entries[0].RollId); Assert.Equal(rollIds[^1], initialPage.Entries[^1].RollId); Assert.Equal(rollIds[^1], initialPage.Cursor); Assert.True(initialPage.HasMore); Assert.False(initialPage.ResetRequired); + Assert.All(initialPage.Entries, entry => + { + Assert.False(string.IsNullOrWhiteSpace(entry.SummaryText)); + Assert.False(string.IsNullOrWhiteSpace(entry.VisibilityLabel)); + }); var latestRoll = await PostAsync(playerClient, $"/api/skills/{skill.Id}/roll", new("public")); var incrementalPage = await GetAsync(gmClient, $"/api/campaigns/{campaign.Id}/log/page?afterRollId={initialPage.Cursor}&limit=3"); @@ -350,5 +355,10 @@ public sealed class CampaignApiTests : ApiTestBase Assert.Equal(latestRoll.RollId, incrementalPage.Cursor); Assert.False(incrementalPage.HasMore); Assert.False(incrementalPage.ResetRequired); + + var detail = await GetAsync(gmClient, $"/api/rolls/{latestRoll.RollId}"); + Assert.Equal(latestRoll.RollId, detail.RollId); + Assert.Equal(latestRoll.Breakdown, detail.Breakdown); + Assert.NotEmpty(detail.Dice); } } diff --git a/RpgRoller.Tests/Api/RollVisibilityApiTests.cs b/RpgRoller.Tests/Api/RollVisibilityApiTests.cs index b37047a..ecf2859 100644 --- a/RpgRoller.Tests/Api/RollVisibilityApiTests.cs +++ b/RpgRoller.Tests/Api/RollVisibilityApiTests.cs @@ -54,6 +54,14 @@ public sealed class RollVisibilityApiTests : ApiTestBase Assert.Single(observerLogPage.Entries); Assert.Equal(publicRoll.RollId, observerLogPage.Entries[0].RollId); Assert.Equal(publicRoll.RollId, observerLogPage.Cursor); + Assert.Equal("Public", observerLogPage.Entries[0].VisibilityLabel); + + var observerPublicDetail = await GetAsync(observerClient, $"/api/rolls/{publicRoll.RollId}"); + Assert.Equal(publicRoll.RollId, observerPublicDetail.RollId); + Assert.NotEmpty(observerPublicDetail.Dice); + + var observerPrivateDetail = await observerClient.GetAsync($"/api/rolls/{privateRoll.RollId}"); + Assert.Equal(HttpStatusCode.BadRequest, observerPrivateDetail.StatusCode); await RegisterAsync(outsiderClient, "outsider", "Password123", "Outsider"); await LoginAsync(outsiderClient, "outsider", "Password123"); @@ -61,6 +69,9 @@ public sealed class RollVisibilityApiTests : ApiTestBase var forbiddenCampaign = await outsiderClient.GetAsync($"/api/campaigns/{campaign.Id}"); Assert.Equal(HttpStatusCode.BadRequest, forbiddenCampaign.StatusCode); + var outsiderPublicDetail = await outsiderClient.GetAsync($"/api/rolls/{publicRoll.RollId}"); + Assert.Equal(HttpStatusCode.BadRequest, outsiderPublicDetail.StatusCode); + var invalidVisibility = await playerClient.PostAsJsonAsync($"/api/skills/{skill.Id}/roll", new RollSkillRequest("hidden")); Assert.Equal(HttpStatusCode.BadRequest, invalidVisibility.StatusCode); diff --git a/RpgRoller.Tests/Services/ServiceCampaignTests.cs b/RpgRoller.Tests/Services/ServiceCampaignTests.cs index 88d84d9..e18a127 100644 --- a/RpgRoller.Tests/Services/ServiceCampaignTests.cs +++ b/RpgRoller.Tests/Services/ServiceCampaignTests.cs @@ -117,7 +117,7 @@ public sealed class ServiceCampaignTests _ = ServiceTestSupport.GetValue(service.CreateSkill(otherSession, otherCharacter.Id, "Perception", "1D+2", 1, true)); var ownerView = ServiceTestSupport.GetValue(service.GetCampaign(ownerSession, campaign.Id)); - Assert.Equal(2, ownerView.Characters.Count); + Assert.Equal(2, ownerView.Characters.Length); Assert.Contains(ownerView.Characters, character => character.Id == ownerCharacter.Id); Assert.Contains(ownerView.Characters, character => character.Id == otherCharacter.Id); diff --git a/RpgRoller.Tests/Services/ServiceSkillRollTests.cs b/RpgRoller.Tests/Services/ServiceSkillRollTests.cs index 0110501..ce09cf0 100644 --- a/RpgRoller.Tests/Services/ServiceSkillRollTests.cs +++ b/RpgRoller.Tests/Services/ServiceSkillRollTests.cs @@ -96,12 +96,17 @@ public sealed class ServiceSkillRollTests rollIds.Add(ServiceTestSupport.GetValue(service.RollSkill(ownerSession, skill.Id, "public")).RollId); var initialPage = ServiceTestSupport.GetValue(service.GetCampaignLogPage(gmSession, campaign.Id, limit: 3)); - Assert.Equal(3, initialPage.Entries.Count); + Assert.Equal(3, initialPage.Entries.Length); Assert.Equal(rollIds[2], initialPage.Entries[0].RollId); Assert.Equal(rollIds[^1], initialPage.Entries[^1].RollId); Assert.Equal(rollIds[^1], initialPage.Cursor); Assert.True(initialPage.HasMore); Assert.False(initialPage.ResetRequired); + Assert.All(initialPage.Entries, entry => + { + Assert.False(string.IsNullOrWhiteSpace(entry.SummaryText)); + Assert.False(string.IsNullOrWhiteSpace(entry.RollerLabel)); + }); var latestRoll = ServiceTestSupport.GetValue(service.RollSkill(ownerSession, skill.Id, "public")); var incrementalPage = ServiceTestSupport.GetValue(service.GetCampaignLogPage(gmSession, campaign.Id, initialPage.Cursor, 3)); @@ -137,9 +142,49 @@ public sealed class ServiceSkillRollTests Assert.True(gapPage.ResetRequired); Assert.True(gapPage.HasMore); - Assert.Equal(3, gapPage.Entries.Count); + Assert.Equal(3, gapPage.Entries.Length); Assert.Equal(rollIds[3], gapPage.Entries[0].RollId); Assert.Equal(rollIds[^1], gapPage.Entries[^1].RollId); Assert.Equal(rollIds[^1], gapPage.Cursor); } + + [Fact] + public void RollDetail_ReturnsVisibleDetailAndHidesPrivateRoll() + { + using var harness = ServiceTestSupport.CreateHarness(6, 5, 4, 3, 2, 6); + var service = harness.Service; + + service.Register("gm-detail", "Password123", "GM"); + service.Register("owner-detail", "Password123", "Owner"); + service.Register("observer-detail", "Password123", "Observer"); + service.Register("outsider-detail", "Password123", "Outsider"); + + var gmSession = ServiceTestSupport.GetValue(service.Login("gm-detail", "Password123")).SessionToken; + var ownerSession = ServiceTestSupport.GetValue(service.Login("owner-detail", "Password123")).SessionToken; + var observerSession = ServiceTestSupport.GetValue(service.Login("observer-detail", "Password123")).SessionToken; + var outsiderSession = ServiceTestSupport.GetValue(service.Login("outsider-detail", "Password123")).SessionToken; + + var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Detail", "d6")); + var ownerCharacter = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Hero", campaign.Id)); + _ = ServiceTestSupport.GetValue(service.CreateCharacter(observerSession, "Watcher", campaign.Id)); + var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, ownerCharacter.Id, "Stealth", "2D+1", 1, true)); + + var privateRoll = ServiceTestSupport.GetValue(service.RollSkill(ownerSession, skill.Id, "private")); + var publicRoll = ServiceTestSupport.GetValue(service.RollSkill(ownerSession, skill.Id, "public")); + + var gmDetail = ServiceTestSupport.GetValue(service.GetRollDetail(gmSession, privateRoll.RollId)); + var ownerDetail = ServiceTestSupport.GetValue(service.GetRollDetail(ownerSession, privateRoll.RollId)); + var observerPublicDetail = ServiceTestSupport.GetValue(service.GetRollDetail(observerSession, publicRoll.RollId)); + var observerPrivateDetail = service.GetRollDetail(observerSession, privateRoll.RollId); + var outsiderPublicDetail = service.GetRollDetail(outsiderSession, publicRoll.RollId); + + Assert.NotEmpty(gmDetail.Dice); + Assert.Equal(privateRoll.RollId, gmDetail.RollId); + Assert.Equal(privateRoll.Breakdown, ownerDetail.Breakdown); + Assert.Equal(publicRoll.RollId, observerPublicDetail.RollId); + Assert.False(observerPrivateDetail.Succeeded); + Assert.Equal("roll_not_found", observerPrivateDetail.Error!.Code); + Assert.False(outsiderPublicDetail.Succeeded); + Assert.Equal("roll_not_found", outsiderPublicDetail.Error!.Code); + } } diff --git a/RpgRoller.Tests/Services/WorkspaceQueryServiceTests.cs b/RpgRoller.Tests/Services/WorkspaceQueryServiceTests.cs index 7a17bfd..f10370b 100644 --- a/RpgRoller.Tests/Services/WorkspaceQueryServiceTests.cs +++ b/RpgRoller.Tests/Services/WorkspaceQueryServiceTests.cs @@ -27,7 +27,7 @@ public sealed class WorkspaceQueryServiceTests GetCampaignsHandler = sessionToken => { Assert.Equal("server-session", sessionToken); - return ServiceResult>.Success([new CampaignSummary(Guid.NewGuid(), "Alpha", "d6", new UserSummary(Guid.NewGuid(), "gm", "GM", []), 1)]); + return ServiceResult>.Success([new CampaignSummary(Guid.NewGuid(), "Alpha", "d6", new CampaignGmSummary(Guid.NewGuid(), "GM"), 1)]); } }; @@ -97,6 +97,7 @@ public sealed class WorkspaceQueryServiceTests public ServiceResult RollSkill(string sessionToken, Guid skillId, string visibility) => throw new NotSupportedException(); public ServiceResult> GetCampaignLog(string sessionToken, Guid campaignId) => throw new NotSupportedException(); public ServiceResult GetCampaignLogPage(string sessionToken, Guid campaignId, Guid? afterRollId = null, int? limit = null) => throw new NotSupportedException(); + public ServiceResult GetRollDetail(string sessionToken, Guid rollId) => throw new NotSupportedException(); public ServiceResult GetCampaignStateSnapshot(string sessionToken, Guid campaignId) => throw new NotSupportedException(); } } diff --git a/RpgRoller/Api/ApiEndpointRegistration.cs b/RpgRoller/Api/ApiEndpointRegistration.cs index 944e12b..8c0e300 100644 --- a/RpgRoller/Api/ApiEndpointRegistration.cs +++ b/RpgRoller/Api/ApiEndpointRegistration.cs @@ -15,6 +15,7 @@ public static class ApiEndpointRegistration authenticatedApi.MapCharacterEndpoints(); authenticatedApi.MapAdminEndpoints(); authenticatedApi.MapSkillEndpoints(); + authenticatedApi.MapRollEndpoints(); authenticatedApi.MapStateEventEndpoints(); } } diff --git a/RpgRoller/Api/RollEndpoints.cs b/RpgRoller/Api/RollEndpoints.cs new file mode 100644 index 0000000..b05266a --- /dev/null +++ b/RpgRoller/Api/RollEndpoints.cs @@ -0,0 +1,18 @@ +using RpgRoller.Contracts; +using RpgRoller.Services; + +namespace RpgRoller.Api; + +internal static class RollEndpoints +{ + public static RouteGroupBuilder MapRollEndpoints(this RouteGroupBuilder group) + { + group.MapGet("/rolls/{rollId:guid}", (Guid rollId, HttpContext context, IGameService game) => + { + var result = game.GetRollDetail(context.GetRequiredSessionToken(), rollId); + return ApiResultMapper.ToApiResult(result); + }); + + return group; + } +} diff --git a/RpgRoller/Components/Pages/HomeControls/CampaignLogPanel.razor b/RpgRoller/Components/Pages/HomeControls/CampaignLogPanel.razor index 1bd0c48..c2941c0 100644 --- a/RpgRoller/Components/Pages/HomeControls/CampaignLogPanel.razor +++ b/RpgRoller/Components/Pages/HomeControls/CampaignLogPanel.razor @@ -18,16 +18,37 @@ @foreach (var entry in CampaignLog) {
  • -

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

    -

    @entry.Result

    - -

    @entry.Breakdown

    -

    @VisibilityLabel(entry) - -

    + + @if (ExpandedRollId == entry.RollId) + { +
    + @if (IsRollDetailLoading(entry.RollId)) + { +

    Loading roll detail...

    + } + else if (!string.IsNullOrWhiteSpace(GetRollDetailError(entry.RollId))) + { +

    @GetRollDetailError(entry.RollId)

    + } + else if (ResolveRollDetail(entry.RollId) is { } detail) + { + +

    @detail.Breakdown

    + } +
    + }
  • } diff --git a/RpgRoller/Components/Pages/HomeControls/CampaignLogPanel.razor.cs b/RpgRoller/Components/Pages/HomeControls/CampaignLogPanel.razor.cs index 1962c0c..829cbe3 100644 --- a/RpgRoller/Components/Pages/HomeControls/CampaignLogPanel.razor.cs +++ b/RpgRoller/Components/Pages/HomeControls/CampaignLogPanel.razor.cs @@ -47,17 +47,25 @@ public partial class CampaignLogPanel public bool IsCampaignDataLoading { get; set; } [Parameter] - public IReadOnlyList CampaignLog { get; set; } = []; + public IReadOnlyList CampaignLog { get; set; } = []; [Parameter] - public Func RollerLabel { get; set; } = _ => string.Empty; + public Guid? ExpandedRollId { get; set; } [Parameter] - public Func LogEntryCssClass { get; set; } = _ => string.Empty; + public EventCallback ToggleRollDetailRequested { get; set; } [Parameter] - public Func VisibilityLabel { get; set; } = _ => string.Empty; + public Func ResolveRollDetail { get; set; } = _ => null; [Parameter] - public Func VisibilityBadgeCssClass { get; set; } = _ => string.Empty; + public Func IsRollDetailLoading { get; set; } = _ => false; + + [Parameter] + public Func GetRollDetailError { get; set; } = _ => null; + + private static string LogEntryCssClass(CampaignLogListEntry entry) + { + return entry.VisibilityStyle; + } } diff --git a/RpgRoller/Components/Pages/HomeControls/CampaignManagementPanel.razor b/RpgRoller/Components/Pages/HomeControls/CampaignManagementPanel.razor index 12dbf07..600e407 100644 --- a/RpgRoller/Components/Pages/HomeControls/CampaignManagementPanel.razor +++ b/RpgRoller/Components/Pages/HomeControls/CampaignManagementPanel.razor @@ -44,7 +44,7 @@ } else { - @if (SelectedCampaign.Characters.Count == 0) + @if (SelectedCampaign.Characters.Length == 0) {

    No characters in this campaign yet.

    } diff --git a/RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor b/RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor index 517cfbb..2923d5a 100644 --- a/RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor +++ b/RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor @@ -11,7 +11,7 @@ {

    No campaign selected. Choose one in Campaign Management.

    } - else if (SelectedCampaign.Characters.Count == 0) + else if (SelectedCampaign.Characters.Length == 0) {

    No characters in this campaign yet.

    } diff --git a/RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor.cs b/RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor.cs index 6f7d466..d8671ef 100644 --- a/RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor.cs +++ b/RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor.cs @@ -26,7 +26,7 @@ public partial class CharacterPanel ShowCreateSkillModal = true; } - private void OpenEditSkillModal(SkillSummary skill) + private void OpenEditSkillModal(CharacterSheetSkill skill) { EditingSkillId = skill.Id; EditSkillInitialModel = new() @@ -78,7 +78,7 @@ public partial class CharacterPanel return Task.CompletedTask; } - private Task OnEditSkillRequestedAsync(SkillSummary skill) + private Task OnEditSkillRequestedAsync(CharacterSheetSkill skill) { OpenEditSkillModal(skill); return Task.CompletedTask; @@ -103,7 +103,7 @@ public partial class CharacterPanel ShowCreateSkillGroupModal = true; } - private void OpenEditSkillGroupModal(SkillGroupSummary skillGroup) + private void OpenEditSkillGroupModal(CharacterSheetSkillGroup skillGroup) { EditingSkillGroupId = skillGroup.Id; SkillGroupState.Model.Name = skillGroup.Name; @@ -242,7 +242,7 @@ public partial class CharacterPanel } } - private bool SkillMatchesFilter(SkillSummary skill) + private bool SkillMatchesFilter(CharacterSheetSkill skill) { if (string.IsNullOrWhiteSpace(SkillFilterText)) return true; @@ -297,10 +297,10 @@ public partial class CharacterPanel public bool IsMutating { get; set; } [Parameter] - public IReadOnlyList SelectedCharacterSkills { get; set; } = []; + public IReadOnlyList SelectedCharacterSkills { get; set; } = []; [Parameter] - public IReadOnlyList SelectedCharacterSkillGroups { get; set; } = []; + public IReadOnlyList SelectedCharacterSkillGroups { get; set; } = []; [Parameter] public bool IsD6 { get; set; } @@ -315,13 +315,13 @@ public partial class CharacterPanel public Func OwnerLabel { get; set; } = _ => string.Empty; [Parameter] - public Func SkillDefinitionLabel { get; set; } = _ => string.Empty; + public Func SkillDefinitionLabel { get; set; } = _ => string.Empty; [Parameter] public Func CanEditCharacter { get; set; } = _ => false; [Parameter] - public Func CanEditSkill { get; set; } = _ => false; + public Func CanEditSkill { get; set; } = _ => false; [Parameter] public EventCallback CharacterSelected { get; set; } diff --git a/RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor.cs b/RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor.cs index 58eb4db..de2f369 100644 --- a/RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor.cs +++ b/RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor.cs @@ -140,7 +140,7 @@ public partial class SkillFormModal public Guid? EditingSkillId { get; set; } [Parameter] - public IReadOnlyList AvailableSkillGroups { get; set; } = []; + public IReadOnlyList AvailableSkillGroups { get; set; } = []; [Parameter] public bool IsMutating { get; set; } diff --git a/RpgRoller/Components/Pages/HomeControls/SkillGroupBlock.razor.cs b/RpgRoller/Components/Pages/HomeControls/SkillGroupBlock.razor.cs index 0e38a93..ac3f4b4 100644 --- a/RpgRoller/Components/Pages/HomeControls/SkillGroupBlock.razor.cs +++ b/RpgRoller/Components/Pages/HomeControls/SkillGroupBlock.razor.cs @@ -14,7 +14,7 @@ public partial class SkillGroupBlock public Guid? SkillGroupId { get; set; } [Parameter] - public IReadOnlyList Skills { get; set; } = []; + public IReadOnlyList Skills { get; set; } = []; [Parameter] public bool IsMutating { get; set; } @@ -35,16 +35,16 @@ public partial class SkillGroupBlock public bool ShowGroupActions { get; set; } [Parameter] - public Func CanEditSkill { get; set; } = _ => false; + public Func CanEditSkill { get; set; } = _ => false; [Parameter] - public Func SkillDefinitionLabel { get; set; } = _ => string.Empty; + public Func SkillDefinitionLabel { get; set; } = _ => string.Empty; [Parameter] public EventCallback AddSkillRequested { get; set; } [Parameter] - public EventCallback EditSkillRequested { get; set; } + public EventCallback EditSkillRequested { get; set; } [Parameter] public EventCallback RollSkillRequested { get; set; } diff --git a/RpgRoller/Components/Pages/Workspace.razor b/RpgRoller/Components/Pages/Workspace.razor index 4209c92..603cad5 100644 --- a/RpgRoller/Components/Pages/Workspace.razor +++ b/RpgRoller/Components/Pages/Workspace.razor @@ -60,11 +60,12 @@ - + ExpandedRollId="ExpandedCampaignLogRollId" + ToggleRollDetailRequested="ToggleRollDetailAsync" + ResolveRollDetail="ResolveRollDetail" + IsRollDetailLoading="IsRollDetailLoading" + GetRollDetailError="GetRollDetailError"/> +