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`. - 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. - 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. - 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`. - OpenAPI contract source remains at `openapi/RpgRoller.json`.
## Test and Coverage ## Test and Coverage

View File

@@ -309,4 +309,46 @@ public sealed class CampaignApiTests : ApiTestBase
Assert.False(string.IsNullOrWhiteSpace(entry.RollerDisplayName)); 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.Equal("public", observerLog[0].Visibility);
Assert.NotEmpty(observerLog[0].Dice); 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 RegisterAsync(outsiderClient, "outsider", "Password123", "Outsider");
await LoginAsync(outsiderClient, "outsider", "Password123"); await LoginAsync(outsiderClient, "outsider", "Password123");

View File

@@ -74,4 +74,72 @@ public sealed class ServiceSkillRollTests
Assert.False(missingState.Succeeded); Assert.False(missingState.Succeeded);
Assert.True(ServiceTestSupport.GetValue(state).LogVersion > 1); 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<CharacterSheet> GetCharacterSheet(string sessionToken, Guid characterId) => throw new NotSupportedException();
public ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility) => 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<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(); 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); 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) => group.MapDelete("/campaigns/{campaignId:guid}", (Guid campaignId, HttpContext context, IGameService game) =>
{ {
var result = game.DeleteCampaign(context.GetRequiredSessionToken(), campaignId); var result = game.DeleteCampaign(context.GetRequiredSessionToken(), campaignId);

View File

@@ -10,13 +10,15 @@ public partial class CampaignLogPanel
{ {
protected override async Task OnAfterRenderAsync(bool firstRender) protected override async Task OnAfterRenderAsync(bool firstRender)
{ {
var currentLastRollId = CampaignLog.LastOrDefault()?.RollId;
if (IsCampaignDataLoading || CampaignLog.Count == 0) if (IsCampaignDataLoading || CampaignLog.Count == 0)
{ {
LastRenderedLogCount = CampaignLog.Count; LastRenderedLogCount = CampaignLog.Count;
LastRenderedLogRollId = currentLastRollId;
return; return;
} }
if (firstRender || CampaignLog.Count > LastRenderedLogCount) if (firstRender || CampaignLog.Count > LastRenderedLogCount || currentLastRollId != LastRenderedLogRollId)
{ {
try try
{ {
@@ -31,6 +33,7 @@ public partial class CampaignLogPanel
} }
LastRenderedLogCount = CampaignLog.Count; LastRenderedLogCount = CampaignLog.Count;
LastRenderedLogRollId = currentLastRollId;
} }
[Inject] [Inject]
@@ -38,6 +41,7 @@ public partial class CampaignLogPanel
private ElementReference LogPanelRef { get; set; } private ElementReference LogPanelRef { get; set; }
private int LastRenderedLogCount { get; set; } private int LastRenderedLogCount { get; set; }
private Guid? LastRenderedLogRollId { get; set; }
[Parameter] [Parameter]
public bool IsCampaignDataLoading { get; set; } public bool IsCampaignDataLoading { get; set; }

View File

@@ -171,15 +171,28 @@ public partial class Workspace : IAsyncDisposable
await EnsureSelectedCharacterActiveAsync(); await EnsureSelectedCharacterActiveAsync();
} }
private async Task RefreshCampaignLogAsync() private async Task RefreshCampaignLogAsync(Guid? afterRollId = null)
{ {
if (!SelectedCampaignId.HasValue || !IsPlayScreen) if (!SelectedCampaignId.HasValue || !IsPlayScreen)
{ {
CampaignLog = []; CampaignLog = [];
CampaignLogCursor = null;
return; 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() private async Task RefreshCampaignScopeAsync()
@@ -193,6 +206,7 @@ public partial class Workspace : IAsyncDisposable
SelectedCharacterId = null; SelectedCharacterId = null;
ConnectionState = "offline"; ConnectionState = "offline";
CurrentCampaignState = null; CurrentCampaignState = null;
CampaignLogCursor = null;
return; return;
} }
@@ -661,7 +675,7 @@ public partial class Workspace : IAsyncDisposable
{ {
LastRoll = await ApiClient.RequestAsync<RollResult>("POST", $"/api/skills/{skillId}/roll", new RollSkillRequest(RollVisibility)); LastRoll = await ApiClient.RequestAsync<RollResult>("POST", $"/api/skills/{skillId}/roll", new RollSkillRequest(RollVisibility));
await RefreshCampaignLogAsync(); await RefreshCampaignLogAsync(CampaignLogCursor);
ResetCampaignStateTracking(); ResetCampaignStateTracking();
SetStatus("Roll recorded.", false); SetStatus("Roll recorded.", false);
Announce("Roll result updated."); Announce("Roll result updated.");
@@ -760,7 +774,7 @@ public partial class Workspace : IAsyncDisposable
await RefreshSelectedCharacterSheetAsync(); await RefreshSelectedCharacterSheetAsync();
if (logChanged) if (logChanged)
await RefreshCampaignLogAsync(); await RefreshCampaignLogAsync(CampaignLogCursor);
CurrentCampaignState = state; CurrentCampaignState = state;
} }
@@ -936,6 +950,7 @@ public partial class Workspace : IAsyncDisposable
SelectedCharacterSkills = []; SelectedCharacterSkills = [];
SelectedCharacterSkillGroups = []; SelectedCharacterSkillGroups = [];
CampaignLog = []; CampaignLog = [];
CampaignLogCursor = null;
SelectedCharacterId = null; SelectedCharacterId = null;
LastRoll = null; LastRoll = null;
KnownUsernames = []; KnownUsernames = [];
@@ -1059,6 +1074,7 @@ public partial class Workspace : IAsyncDisposable
private bool HasInteractiveRenderStarted { get; set; } private bool HasInteractiveRenderStarted { get; set; }
private DotNetObjectReference<Workspace>? DotNetRef { get; set; } private DotNetObjectReference<Workspace>? DotNetRef { get; set; }
private CampaignStateSnapshot? CurrentCampaignState { get; set; } private CampaignStateSnapshot? CurrentCampaignState { get; set; }
private Guid? CampaignLogCursor { get; set; }
[Parameter] [Parameter]
public EventCallback<string?> LoggedOut { get; set; } public EventCallback<string?> LoggedOut { get; set; }
@@ -1194,6 +1210,7 @@ public partial class Workspace : IAsyncDisposable
private const string CampaignSessionKey = "campaign"; private const string CampaignSessionKey = "campaign";
private const string MobilePanelSessionKey = "play-panel"; private const string MobilePanelSessionKey = "play-panel";
private const string RollVisibilitySessionKey = "roll-visibility"; private const string RollVisibilitySessionKey = "roll-visibility";
private const int CampaignLogWindowSize = 100;
private const int ToastDurationMs = 3200; private const int ToastDurationMs = 3200;
private sealed record WorkspaceToast(Guid Id, string Message, bool IsError); 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))); 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() public Task<IReadOnlyList<AdminUserSummary>> GetAdminUsersAsync()
{ {
return Task.FromResult(GetValue(m_GameService.GetUsers(GetRequiredSessionToken()))); 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 CharacterStateVersion(Guid CharacterId, long Version);
public sealed record CampaignStateSnapshot(Guid CampaignId, long TotalVersion, long RosterVersion, long LogVersion, IReadOnlyList<CharacterStateVersion> CharacterVersions); 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); return ServiceResult<IReadOnlyList<CampaignLogEntry>>.Failure(context.Error!.Code, context.Error.Message);
var (user, campaign) = context.Value!; var (user, campaign) = context.Value!;
var entries = m_RollLog var entries = GetVisibleCampaignLogEntriesLocked(user, campaign)
.Where(r => r.CampaignId == campaign.Id) .TakeLast(CampaignLogPageSize)
.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) .Select(ToLogEntry)
.ToArray(); .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) public ServiceResult<CampaignStateSnapshot> GetCampaignStateSnapshot(string sessionToken, Guid campaignId)
{ {
lock (m_Gate) lock (m_Gate)
@@ -1070,6 +1104,15 @@ public sealed class GameService : IGameService
return new CampaignStateSnapshot(campaign.Id, state.TotalVersion, state.RosterVersion, state.LogVersion, characterVersions); 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) private static SkillGroupSummary ToSkillGroupSummary(SkillGroup skillGroup)
{ {
return new(skillGroup.Id, skillGroup.CharacterId, skillGroup.Name, skillGroup.DiceRollDefinition, skillGroup.WildDice, skillGroup.AllowFumble); 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(); 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) private static UserAccount CloneUser(UserAccount user)
{ {
return new() return new()

View File

@@ -38,6 +38,7 @@ public interface IGameService
ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility); ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility);
ServiceResult<IReadOnlyList<CampaignLogEntry>> GetCampaignLog(string sessionToken, Guid campaignId); 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); ServiceResult<CampaignStateSnapshot> GetCampaignStateSnapshot(string sessionToken, Guid campaignId);
} }