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