Load campaign logs incrementally
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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())));
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user