Load campaign logs incrementally

This commit is contained in:
2026-04-02 00:03:54 +02:00
parent 6ea91ee565
commit e42c0fb9ba
12 changed files with 216 additions and 14 deletions

View File

@@ -101,7 +101,7 @@ dotnet dotnet-ef migrations add <MigrationName> --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

View File

@@ -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<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns", new("Log Page", "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 < 5; i++)
{
var roll = await PostAsync<RollSkillRequest, RollResult>(playerClient, $"/api/skills/{skill.Id}/roll", new("public"));
rollIds.Add(roll.RollId);
}
var initialPage = await GetAsync<CampaignLogPage>(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<RollSkillRequest, RollResult>(playerClient, $"/api/skills/{skill.Id}/roll", new("public"));
var incrementalPage = await GetAsync<CampaignLogPage>(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);
}
}

View File

@@ -50,6 +50,11 @@ public sealed class RollVisibilityApiTests : ApiTestBase
Assert.Equal("public", observerLog[0].Visibility);
Assert.NotEmpty(observerLog[0].Dice);
var observerLogPage = await GetAsync<CampaignLogPage>(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");

View File

@@ -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<Guid>();
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<Guid>();
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);
}
}

View File

@@ -96,6 +96,7 @@ public sealed class WorkspaceQueryServiceTests
public ServiceResult<CharacterSheet> GetCharacterSheet(string sessionToken, Guid characterId) => throw new NotSupportedException();
public ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility) => throw new NotSupportedException();
public ServiceResult<IReadOnlyList<CampaignLogEntry>> GetCampaignLog(string sessionToken, Guid campaignId) => throw new NotSupportedException();
public ServiceResult<CampaignLogPage> GetCampaignLogPage(string sessionToken, Guid campaignId, Guid? afterRollId = null, int? limit = null) => throw new NotSupportedException();
public ServiceResult<CampaignStateSnapshot> GetCampaignStateSnapshot(string sessionToken, Guid campaignId) => throw new NotSupportedException();
}
}

View File

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

View File

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

View File

@@ -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<RollResult>("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<Workspace>? DotNetRef { get; set; }
private CampaignStateSnapshot? CurrentCampaignState { get; set; }
private Guid? CampaignLogCursor { get; set; }
[Parameter]
public EventCallback<string?> 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);

View File

@@ -51,6 +51,11 @@ public sealed class WorkspaceQueryService
return Task.FromResult(GetValue(m_GameService.GetCampaignLog(GetRequiredSessionToken(), campaignId)));
}
public Task<CampaignLogPage> GetCampaignLogPageAsync(Guid campaignId, Guid? afterRollId = null, int? limit = null)
{
return Task.FromResult(GetValue(m_GameService.GetCampaignLogPage(GetRequiredSessionToken(), campaignId, afterRollId, limit)));
}
public Task<IReadOnlyList<AdminUserSummary>> GetAdminUsersAsync()
{
return Task.FromResult(GetValue(m_GameService.GetUsers(GetRequiredSessionToken())));

View File

@@ -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<CharacterStateVersion> CharacterVersions);
public sealed record CampaignLogPage(IReadOnlyList<CampaignLogEntry> Entries, Guid? Cursor, bool HasMore, bool ResetRequired);

View File

@@ -796,14 +796,8 @@ 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)
.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<CampaignLogPage> GetCampaignLogPage(string sessionToken, Guid campaignId, Guid? afterRollId = null, int? limit = null)
{
lock (m_Gate)
{
var context = ResolveContextLocked(sessionToken, campaignId);
if (!context.Succeeded)
return ServiceResult<CampaignLogPage>.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<CampaignLogPage>.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<CampaignLogPage>.Success(new CampaignLogPage(replacementEntries, replacementEntries.LastOrDefault()?.RollId, visibleEntries.Length > pageSize, true));
}
var newEntries = visibleEntries.Skip(afterIndex + 1).ToArray();
if (newEntries.Length == 0)
return ServiceResult<CampaignLogPage>.Success(new CampaignLogPage([], afterRollId, false, false));
if (newEntries.Length > pageSize)
{
var replacementEntries = newEntries.TakeLast(pageSize).Select(ToLogEntry).ToArray();
return ServiceResult<CampaignLogPage>.Success(new CampaignLogPage(replacementEntries, replacementEntries[^1].RollId, true, true));
}
var appendedEntries = newEntries.Select(ToLogEntry).ToArray();
return ServiceResult<CampaignLogPage>.Success(new CampaignLogPage(appendedEntries, appendedEntries[^1].RollId, false, false));
}
}
public ServiceResult<CampaignStateSnapshot> 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<RollLogEntry> 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()

View File

@@ -38,6 +38,7 @@ public interface IGameService
ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility);
ServiceResult<IReadOnlyList<CampaignLogEntry>> GetCampaignLog(string sessionToken, Guid campaignId);
ServiceResult<CampaignLogPage> GetCampaignLogPage(string sessionToken, Guid campaignId, Guid? afterRollId = null, int? limit = null);
ServiceResult<CampaignStateSnapshot> GetCampaignStateSnapshot(string sessionToken, Guid campaignId);
}