diff --git a/README.md b/README.md index 4bd8409..f7894e8 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ 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 the 100 most recent visible log entries. +- 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`. - 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 70cb91f..16b6272 100644 --- a/RpgRoller.Tests/Api/CampaignApiTests.cs +++ b/RpgRoller.Tests/Api/CampaignApiTests.cs @@ -309,4 +309,46 @@ public sealed class CampaignApiTests : ApiTestBase Assert.False(string.IsNullOrWhiteSpace(entry.RollerDisplayName)); }); } + + [Fact] + public async Task CampaignLogPage_ReturnsInitialAndIncrementalResults() + { + 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-page", "Password123", "GM"); + await LoginAsync(gmClient, "gm-log-page", "Password123"); + + await RegisterAsync(playerClient, "player-log-page", "Password123", "Player"); + await LoginAsync(playerClient, "player-log-page", "Password123"); + + var campaign = await PostAsync(gmClient, "/api/campaigns", new("Log Page", "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 < 5; i++) + { + var roll = await PostAsync(playerClient, $"/api/skills/{skill.Id}/roll", new("public")); + rollIds.Add(roll.RollId); + } + + var initialPage = await GetAsync(gmClient, $"/api/campaigns/{campaign.Id}/log/page?limit=3"); + Assert.Equal(3, initialPage.Entries.Count); + 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); + + 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"); + + Assert.Single(incrementalPage.Entries); + Assert.Equal(latestRoll.RollId, incrementalPage.Entries[0].RollId); + Assert.Equal(latestRoll.RollId, incrementalPage.Cursor); + Assert.False(incrementalPage.HasMore); + Assert.False(incrementalPage.ResetRequired); + } } diff --git a/RpgRoller.Tests/Api/RollVisibilityApiTests.cs b/RpgRoller.Tests/Api/RollVisibilityApiTests.cs index fc2c92b..b37047a 100644 --- a/RpgRoller.Tests/Api/RollVisibilityApiTests.cs +++ b/RpgRoller.Tests/Api/RollVisibilityApiTests.cs @@ -50,6 +50,11 @@ public sealed class RollVisibilityApiTests : ApiTestBase Assert.Equal("public", observerLog[0].Visibility); Assert.NotEmpty(observerLog[0].Dice); + var observerLogPage = await GetAsync(observerClient, $"/api/campaigns/{campaign.Id}/log/page"); + Assert.Single(observerLogPage.Entries); + Assert.Equal(publicRoll.RollId, observerLogPage.Entries[0].RollId); + Assert.Equal(publicRoll.RollId, observerLogPage.Cursor); + await RegisterAsync(outsiderClient, "outsider", "Password123", "Outsider"); await LoginAsync(outsiderClient, "outsider", "Password123"); diff --git a/RpgRoller.Tests/Services/ServiceSkillRollTests.cs b/RpgRoller.Tests/Services/ServiceSkillRollTests.cs index f04ae49..0110501 100644 --- a/RpgRoller.Tests/Services/ServiceSkillRollTests.cs +++ b/RpgRoller.Tests/Services/ServiceSkillRollTests.cs @@ -74,4 +74,72 @@ public sealed class ServiceSkillRollTests Assert.False(missingState.Succeeded); Assert.True(ServiceTestSupport.GetValue(state).LogVersion > 1); } + + [Fact] + public void CampaignLogPage_ReturnsInitialWindowAndIncrementalAppend() + { + using var harness = ServiceTestSupport.CreateHarness(6, 5, 4, 3, 2, 6, 5, 4, 3, 2, 6, 5); + var service = harness.Service; + + service.Register("gm-page", "Password123", "GM"); + service.Register("owner-page", "Password123", "Owner"); + + var gmSession = ServiceTestSupport.GetValue(service.Login("gm-page", "Password123")).SessionToken; + var ownerSession = ServiceTestSupport.GetValue(service.Login("owner-page", "Password123")).SessionToken; + + var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Paged", "d6")); + var character = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Hero", campaign.Id)); + var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, character.Id, "Stealth", "2D+1", 1, true)); + + var rollIds = new List(); + for (var i = 0; i < 5; i++) + 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(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); + + var latestRoll = ServiceTestSupport.GetValue(service.RollSkill(ownerSession, skill.Id, "public")); + var incrementalPage = ServiceTestSupport.GetValue(service.GetCampaignLogPage(gmSession, campaign.Id, initialPage.Cursor, 3)); + + Assert.Single(incrementalPage.Entries); + Assert.Equal(latestRoll.RollId, incrementalPage.Entries[0].RollId); + Assert.Equal(latestRoll.RollId, incrementalPage.Cursor); + Assert.False(incrementalPage.HasMore); + Assert.False(incrementalPage.ResetRequired); + } + + [Fact] + public void CampaignLogPage_RequestsResetWhenIncrementalGapExceedsLimit() + { + using var harness = ServiceTestSupport.CreateHarness(6, 5, 4, 3, 2, 6, 5, 4, 3, 2, 6, 5); + var service = harness.Service; + + service.Register("gm-gap", "Password123", "GM"); + service.Register("owner-gap", "Password123", "Owner"); + + var gmSession = ServiceTestSupport.GetValue(service.Login("gm-gap", "Password123")).SessionToken; + var ownerSession = ServiceTestSupport.GetValue(service.Login("owner-gap", "Password123")).SessionToken; + + var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Gap", "d6")); + var character = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Hero", campaign.Id)); + var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, character.Id, "Stealth", "2D+1", 1, true)); + + var rollIds = new List(); + for (var i = 0; i < 6; i++) + rollIds.Add(ServiceTestSupport.GetValue(service.RollSkill(ownerSession, skill.Id, "public")).RollId); + + var gapPage = ServiceTestSupport.GetValue(service.GetCampaignLogPage(gmSession, campaign.Id, rollIds[0], 3)); + + Assert.True(gapPage.ResetRequired); + Assert.True(gapPage.HasMore); + Assert.Equal(3, gapPage.Entries.Count); + Assert.Equal(rollIds[3], gapPage.Entries[0].RollId); + Assert.Equal(rollIds[^1], gapPage.Entries[^1].RollId); + Assert.Equal(rollIds[^1], gapPage.Cursor); + } } diff --git a/RpgRoller.Tests/Services/WorkspaceQueryServiceTests.cs b/RpgRoller.Tests/Services/WorkspaceQueryServiceTests.cs index 45adfe8..7a17bfd 100644 --- a/RpgRoller.Tests/Services/WorkspaceQueryServiceTests.cs +++ b/RpgRoller.Tests/Services/WorkspaceQueryServiceTests.cs @@ -96,6 +96,7 @@ public sealed class WorkspaceQueryServiceTests public ServiceResult GetCharacterSheet(string sessionToken, Guid characterId) => throw new NotSupportedException(); 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 GetCampaignStateSnapshot(string sessionToken, Guid campaignId) => throw new NotSupportedException(); } } diff --git a/RpgRoller/Api/CampaignEndpoints.cs b/RpgRoller/Api/CampaignEndpoints.cs index abc8baf..8b9e41e 100644 --- a/RpgRoller/Api/CampaignEndpoints.cs +++ b/RpgRoller/Api/CampaignEndpoints.cs @@ -37,6 +37,12 @@ internal static class CampaignEndpoints return ApiResultMapper.ToApiResult(result); }); + group.MapGet("/campaigns/{campaignId:guid}/log/page", (Guid campaignId, Guid? afterRollId, int? limit, HttpContext context, IGameService game) => + { + var result = game.GetCampaignLogPage(context.GetRequiredSessionToken(), campaignId, afterRollId, limit); + return ApiResultMapper.ToApiResult(result); + }); + group.MapDelete("/campaigns/{campaignId:guid}", (Guid campaignId, HttpContext context, IGameService game) => { var result = game.DeleteCampaign(context.GetRequiredSessionToken(), campaignId); diff --git a/RpgRoller/Components/Pages/HomeControls/CampaignLogPanel.razor.cs b/RpgRoller/Components/Pages/HomeControls/CampaignLogPanel.razor.cs index 06169e2..1962c0c 100644 --- a/RpgRoller/Components/Pages/HomeControls/CampaignLogPanel.razor.cs +++ b/RpgRoller/Components/Pages/HomeControls/CampaignLogPanel.razor.cs @@ -10,13 +10,15 @@ public partial class CampaignLogPanel { protected override async Task OnAfterRenderAsync(bool firstRender) { + var currentLastRollId = CampaignLog.LastOrDefault()?.RollId; if (IsCampaignDataLoading || CampaignLog.Count == 0) { LastRenderedLogCount = CampaignLog.Count; + LastRenderedLogRollId = currentLastRollId; return; } - if (firstRender || CampaignLog.Count > LastRenderedLogCount) + if (firstRender || CampaignLog.Count > LastRenderedLogCount || currentLastRollId != LastRenderedLogRollId) { try { @@ -31,6 +33,7 @@ public partial class CampaignLogPanel } LastRenderedLogCount = CampaignLog.Count; + LastRenderedLogRollId = currentLastRollId; } [Inject] @@ -38,6 +41,7 @@ public partial class CampaignLogPanel private ElementReference LogPanelRef { get; set; } private int LastRenderedLogCount { get; set; } + private Guid? LastRenderedLogRollId { get; set; } [Parameter] public bool IsCampaignDataLoading { get; set; } diff --git a/RpgRoller/Components/Pages/Workspace.razor.cs b/RpgRoller/Components/Pages/Workspace.razor.cs index 38698c9..9c39cdb 100644 --- a/RpgRoller/Components/Pages/Workspace.razor.cs +++ b/RpgRoller/Components/Pages/Workspace.razor.cs @@ -171,15 +171,28 @@ public partial class Workspace : IAsyncDisposable await EnsureSelectedCharacterActiveAsync(); } - private async Task RefreshCampaignLogAsync() + private async Task RefreshCampaignLogAsync(Guid? afterRollId = null) { if (!SelectedCampaignId.HasValue || !IsPlayScreen) { CampaignLog = []; + CampaignLogCursor = null; return; } - CampaignLog = (await WorkspaceQuery.GetCampaignLogAsync(SelectedCampaignId.Value)).ToList(); + var page = await WorkspaceQuery.GetCampaignLogPageAsync(SelectedCampaignId.Value, afterRollId, CampaignLogWindowSize); + if (!afterRollId.HasValue || page.ResetRequired) + { + CampaignLog = page.Entries.ToList(); + } + else if (page.Entries.Count > 0) + { + CampaignLog.AddRange(page.Entries); + if (CampaignLog.Count > CampaignLogWindowSize) + CampaignLog = CampaignLog.TakeLast(CampaignLogWindowSize).ToList(); + } + + CampaignLogCursor = page.Cursor ?? afterRollId; } private async Task RefreshCampaignScopeAsync() @@ -193,6 +206,7 @@ public partial class Workspace : IAsyncDisposable SelectedCharacterId = null; ConnectionState = "offline"; CurrentCampaignState = null; + CampaignLogCursor = null; return; } @@ -661,7 +675,7 @@ public partial class Workspace : IAsyncDisposable { LastRoll = await ApiClient.RequestAsync("POST", $"/api/skills/{skillId}/roll", new RollSkillRequest(RollVisibility)); - await RefreshCampaignLogAsync(); + await RefreshCampaignLogAsync(CampaignLogCursor); ResetCampaignStateTracking(); SetStatus("Roll recorded.", false); Announce("Roll result updated."); @@ -760,7 +774,7 @@ public partial class Workspace : IAsyncDisposable await RefreshSelectedCharacterSheetAsync(); if (logChanged) - await RefreshCampaignLogAsync(); + await RefreshCampaignLogAsync(CampaignLogCursor); CurrentCampaignState = state; } @@ -936,6 +950,7 @@ public partial class Workspace : IAsyncDisposable SelectedCharacterSkills = []; SelectedCharacterSkillGroups = []; CampaignLog = []; + CampaignLogCursor = null; SelectedCharacterId = null; LastRoll = null; KnownUsernames = []; @@ -1059,6 +1074,7 @@ public partial class Workspace : IAsyncDisposable private bool HasInteractiveRenderStarted { get; set; } private DotNetObjectReference? DotNetRef { get; set; } private CampaignStateSnapshot? CurrentCampaignState { get; set; } + private Guid? CampaignLogCursor { get; set; } [Parameter] public EventCallback LoggedOut { get; set; } @@ -1194,6 +1210,7 @@ public partial class Workspace : IAsyncDisposable private const string CampaignSessionKey = "campaign"; private const string MobilePanelSessionKey = "play-panel"; private const string RollVisibilitySessionKey = "roll-visibility"; + private const int CampaignLogWindowSize = 100; private const int ToastDurationMs = 3200; private sealed record WorkspaceToast(Guid Id, string Message, bool IsError); diff --git a/RpgRoller/Components/WorkspaceQueryService.cs b/RpgRoller/Components/WorkspaceQueryService.cs index 0d66ef3..d9102d3 100644 --- a/RpgRoller/Components/WorkspaceQueryService.cs +++ b/RpgRoller/Components/WorkspaceQueryService.cs @@ -51,6 +51,11 @@ public sealed class WorkspaceQueryService return Task.FromResult(GetValue(m_GameService.GetCampaignLog(GetRequiredSessionToken(), campaignId))); } + public Task GetCampaignLogPageAsync(Guid campaignId, Guid? afterRollId = null, int? limit = null) + { + return Task.FromResult(GetValue(m_GameService.GetCampaignLogPage(GetRequiredSessionToken(), campaignId, afterRollId, limit))); + } + public Task> GetAdminUsersAsync() { return Task.FromResult(GetValue(m_GameService.GetUsers(GetRequiredSessionToken()))); diff --git a/RpgRoller/Contracts/ApiContracts.cs b/RpgRoller/Contracts/ApiContracts.cs index bf43af0..0f0571d 100644 --- a/RpgRoller/Contracts/ApiContracts.cs +++ b/RpgRoller/Contracts/ApiContracts.cs @@ -57,3 +57,5 @@ public sealed record CampaignLogEntry(Guid RollId, Guid CampaignId, Guid Charact public sealed record CharacterStateVersion(Guid CharacterId, long Version); public sealed record CampaignStateSnapshot(Guid CampaignId, long TotalVersion, long RosterVersion, long LogVersion, IReadOnlyList CharacterVersions); + +public sealed record CampaignLogPage(IReadOnlyList Entries, Guid? Cursor, bool HasMore, bool ResetRequired); diff --git a/RpgRoller/Services/GameService.cs b/RpgRoller/Services/GameService.cs index 7957a85..47ecd19 100644 --- a/RpgRoller/Services/GameService.cs +++ b/RpgRoller/Services/GameService.cs @@ -796,14 +796,8 @@ 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) - .OrderByDescending(r => r.TimestampUtc) - .ThenByDescending(r => r.Id) - .Take(CampaignLogPageSize) - .OrderBy(r => r.TimestampUtc) - .ThenBy(r => r.Id) + var entries = GetVisibleCampaignLogEntriesLocked(user, campaign) + .TakeLast(CampaignLogPageSize) .Select(ToLogEntry) .ToArray(); @@ -811,6 +805,46 @@ public sealed class GameService : IGameService } } + public ServiceResult GetCampaignLogPage(string sessionToken, Guid campaignId, Guid? afterRollId = null, int? limit = null) + { + lock (m_Gate) + { + var context = ResolveContextLocked(sessionToken, campaignId); + if (!context.Succeeded) + return ServiceResult.Failure(context.Error!.Code, context.Error.Message); + + var (user, campaign) = context.Value!; + var pageSize = NormalizeCampaignLogPageSize(limit); + var visibleEntries = GetVisibleCampaignLogEntriesLocked(user, campaign).ToArray(); + + if (!afterRollId.HasValue) + { + var initialEntries = visibleEntries.TakeLast(pageSize).Select(ToLogEntry).ToArray(); + return ServiceResult.Success(new CampaignLogPage(initialEntries, initialEntries.LastOrDefault()?.RollId, visibleEntries.Length > pageSize, false)); + } + + var afterIndex = Array.FindIndex(visibleEntries, entry => entry.Id == afterRollId.Value); + if (afterIndex < 0) + { + var replacementEntries = visibleEntries.TakeLast(pageSize).Select(ToLogEntry).ToArray(); + return ServiceResult.Success(new CampaignLogPage(replacementEntries, replacementEntries.LastOrDefault()?.RollId, visibleEntries.Length > pageSize, true)); + } + + var newEntries = visibleEntries.Skip(afterIndex + 1).ToArray(); + if (newEntries.Length == 0) + return ServiceResult.Success(new CampaignLogPage([], afterRollId, false, false)); + + if (newEntries.Length > pageSize) + { + var replacementEntries = newEntries.TakeLast(pageSize).Select(ToLogEntry).ToArray(); + return ServiceResult.Success(new CampaignLogPage(replacementEntries, replacementEntries[^1].RollId, true, true)); + } + + var appendedEntries = newEntries.Select(ToLogEntry).ToArray(); + return ServiceResult.Success(new CampaignLogPage(appendedEntries, appendedEntries[^1].RollId, false, false)); + } + } + public ServiceResult GetCampaignStateSnapshot(string sessionToken, Guid campaignId) { lock (m_Gate) @@ -1070,6 +1104,15 @@ public sealed class GameService : IGameService return new CampaignStateSnapshot(campaign.Id, state.TotalVersion, state.RosterVersion, state.LogVersion, characterVersions); } + private IEnumerable GetVisibleCampaignLogEntriesLocked(UserAccount user, Campaign campaign) + { + return 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); + } + private static SkillGroupSummary ToSkillGroupSummary(SkillGroup skillGroup) { return new(skillGroup.Id, skillGroup.CharacterId, skillGroup.Name, skillGroup.DiceRollDefinition, skillGroup.WildDice, skillGroup.AllowFumble); @@ -1437,6 +1480,14 @@ public sealed class GameService : IGameService return username.ToUpperInvariant(); } + private static int NormalizeCampaignLogPageSize(int? limit) + { + if (!limit.HasValue) + return CampaignLogPageSize; + + return Math.Clamp(limit.Value, 1, CampaignLogPageSize); + } + private static UserAccount CloneUser(UserAccount user) { return new() diff --git a/RpgRoller/Services/IGameService.cs b/RpgRoller/Services/IGameService.cs index b9b9493..053d73e 100644 --- a/RpgRoller/Services/IGameService.cs +++ b/RpgRoller/Services/IGameService.cs @@ -38,6 +38,7 @@ public interface IGameService ServiceResult RollSkill(string sessionToken, Guid skillId, string visibility); ServiceResult> GetCampaignLog(string sessionToken, Guid campaignId); + ServiceResult GetCampaignLogPage(string sessionToken, Guid campaignId, Guid? afterRollId = null, int? limit = null); ServiceResult GetCampaignStateSnapshot(string sessionToken, Guid campaignId); }