Split campaign log summary from detail

This commit is contained in:
2026-04-02 00:19:44 +02:00
parent e42c0fb9ba
commit ddb57cde8f
22 changed files with 406 additions and 110 deletions

View File

@@ -101,7 +101,8 @@ 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 an incremental log window backed by `/api/campaigns/{campaignId}/log/page`.
- Workspace campaign data is loaded in bounded slices: visible campaign summaries, a selected campaign roster, a selected character sheet, and a 50-row incremental log window backed by `/api/campaigns/{campaignId}/log/page`.
- Campaign log rows now ship compact summary data first and lazy-load dice + breakdown detail through `/api/rolls/{rollId}` only when a row is expanded.
- OpenAPI contract source remains at `openapi/RpgRoller.json`.
## Test and Coverage

View File

@@ -335,12 +335,17 @@ public sealed class CampaignApiTests : ApiTestBase
}
var initialPage = await GetAsync<CampaignLogPage>(gmClient, $"/api/campaigns/{campaign.Id}/log/page?limit=3");
Assert.Equal(3, initialPage.Entries.Count);
Assert.Equal(3, initialPage.Entries.Length);
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);
Assert.All(initialPage.Entries, entry =>
{
Assert.False(string.IsNullOrWhiteSpace(entry.SummaryText));
Assert.False(string.IsNullOrWhiteSpace(entry.VisibilityLabel));
});
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");
@@ -350,5 +355,10 @@ public sealed class CampaignApiTests : ApiTestBase
Assert.Equal(latestRoll.RollId, incrementalPage.Cursor);
Assert.False(incrementalPage.HasMore);
Assert.False(incrementalPage.ResetRequired);
var detail = await GetAsync<CampaignRollDetail>(gmClient, $"/api/rolls/{latestRoll.RollId}");
Assert.Equal(latestRoll.RollId, detail.RollId);
Assert.Equal(latestRoll.Breakdown, detail.Breakdown);
Assert.NotEmpty(detail.Dice);
}
}

View File

@@ -54,6 +54,14 @@ public sealed class RollVisibilityApiTests : ApiTestBase
Assert.Single(observerLogPage.Entries);
Assert.Equal(publicRoll.RollId, observerLogPage.Entries[0].RollId);
Assert.Equal(publicRoll.RollId, observerLogPage.Cursor);
Assert.Equal("Public", observerLogPage.Entries[0].VisibilityLabel);
var observerPublicDetail = await GetAsync<CampaignRollDetail>(observerClient, $"/api/rolls/{publicRoll.RollId}");
Assert.Equal(publicRoll.RollId, observerPublicDetail.RollId);
Assert.NotEmpty(observerPublicDetail.Dice);
var observerPrivateDetail = await observerClient.GetAsync($"/api/rolls/{privateRoll.RollId}");
Assert.Equal(HttpStatusCode.BadRequest, observerPrivateDetail.StatusCode);
await RegisterAsync(outsiderClient, "outsider", "Password123", "Outsider");
await LoginAsync(outsiderClient, "outsider", "Password123");
@@ -61,6 +69,9 @@ public sealed class RollVisibilityApiTests : ApiTestBase
var forbiddenCampaign = await outsiderClient.GetAsync($"/api/campaigns/{campaign.Id}");
Assert.Equal(HttpStatusCode.BadRequest, forbiddenCampaign.StatusCode);
var outsiderPublicDetail = await outsiderClient.GetAsync($"/api/rolls/{publicRoll.RollId}");
Assert.Equal(HttpStatusCode.BadRequest, outsiderPublicDetail.StatusCode);
var invalidVisibility = await playerClient.PostAsJsonAsync($"/api/skills/{skill.Id}/roll", new RollSkillRequest("hidden"));
Assert.Equal(HttpStatusCode.BadRequest, invalidVisibility.StatusCode);

View File

@@ -117,7 +117,7 @@ public sealed class ServiceCampaignTests
_ = ServiceTestSupport.GetValue(service.CreateSkill(otherSession, otherCharacter.Id, "Perception", "1D+2", 1, true));
var ownerView = ServiceTestSupport.GetValue(service.GetCampaign(ownerSession, campaign.Id));
Assert.Equal(2, ownerView.Characters.Count);
Assert.Equal(2, ownerView.Characters.Length);
Assert.Contains(ownerView.Characters, character => character.Id == ownerCharacter.Id);
Assert.Contains(ownerView.Characters, character => character.Id == otherCharacter.Id);

View File

@@ -96,12 +96,17 @@ public sealed class ServiceSkillRollTests
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(3, initialPage.Entries.Length);
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);
Assert.All(initialPage.Entries, entry =>
{
Assert.False(string.IsNullOrWhiteSpace(entry.SummaryText));
Assert.False(string.IsNullOrWhiteSpace(entry.RollerLabel));
});
var latestRoll = ServiceTestSupport.GetValue(service.RollSkill(ownerSession, skill.Id, "public"));
var incrementalPage = ServiceTestSupport.GetValue(service.GetCampaignLogPage(gmSession, campaign.Id, initialPage.Cursor, 3));
@@ -137,9 +142,49 @@ public sealed class ServiceSkillRollTests
Assert.True(gapPage.ResetRequired);
Assert.True(gapPage.HasMore);
Assert.Equal(3, gapPage.Entries.Count);
Assert.Equal(3, gapPage.Entries.Length);
Assert.Equal(rollIds[3], gapPage.Entries[0].RollId);
Assert.Equal(rollIds[^1], gapPage.Entries[^1].RollId);
Assert.Equal(rollIds[^1], gapPage.Cursor);
}
[Fact]
public void RollDetail_ReturnsVisibleDetailAndHidesPrivateRoll()
{
using var harness = ServiceTestSupport.CreateHarness(6, 5, 4, 3, 2, 6);
var service = harness.Service;
service.Register("gm-detail", "Password123", "GM");
service.Register("owner-detail", "Password123", "Owner");
service.Register("observer-detail", "Password123", "Observer");
service.Register("outsider-detail", "Password123", "Outsider");
var gmSession = ServiceTestSupport.GetValue(service.Login("gm-detail", "Password123")).SessionToken;
var ownerSession = ServiceTestSupport.GetValue(service.Login("owner-detail", "Password123")).SessionToken;
var observerSession = ServiceTestSupport.GetValue(service.Login("observer-detail", "Password123")).SessionToken;
var outsiderSession = ServiceTestSupport.GetValue(service.Login("outsider-detail", "Password123")).SessionToken;
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Detail", "d6"));
var ownerCharacter = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Hero", campaign.Id));
_ = ServiceTestSupport.GetValue(service.CreateCharacter(observerSession, "Watcher", campaign.Id));
var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, ownerCharacter.Id, "Stealth", "2D+1", 1, true));
var privateRoll = ServiceTestSupport.GetValue(service.RollSkill(ownerSession, skill.Id, "private"));
var publicRoll = ServiceTestSupport.GetValue(service.RollSkill(ownerSession, skill.Id, "public"));
var gmDetail = ServiceTestSupport.GetValue(service.GetRollDetail(gmSession, privateRoll.RollId));
var ownerDetail = ServiceTestSupport.GetValue(service.GetRollDetail(ownerSession, privateRoll.RollId));
var observerPublicDetail = ServiceTestSupport.GetValue(service.GetRollDetail(observerSession, publicRoll.RollId));
var observerPrivateDetail = service.GetRollDetail(observerSession, privateRoll.RollId);
var outsiderPublicDetail = service.GetRollDetail(outsiderSession, publicRoll.RollId);
Assert.NotEmpty(gmDetail.Dice);
Assert.Equal(privateRoll.RollId, gmDetail.RollId);
Assert.Equal(privateRoll.Breakdown, ownerDetail.Breakdown);
Assert.Equal(publicRoll.RollId, observerPublicDetail.RollId);
Assert.False(observerPrivateDetail.Succeeded);
Assert.Equal("roll_not_found", observerPrivateDetail.Error!.Code);
Assert.False(outsiderPublicDetail.Succeeded);
Assert.Equal("roll_not_found", outsiderPublicDetail.Error!.Code);
}
}

View File

@@ -27,7 +27,7 @@ public sealed class WorkspaceQueryServiceTests
GetCampaignsHandler = sessionToken =>
{
Assert.Equal("server-session", sessionToken);
return ServiceResult<IReadOnlyList<CampaignSummary>>.Success([new CampaignSummary(Guid.NewGuid(), "Alpha", "d6", new UserSummary(Guid.NewGuid(), "gm", "GM", []), 1)]);
return ServiceResult<IReadOnlyList<CampaignSummary>>.Success([new CampaignSummary(Guid.NewGuid(), "Alpha", "d6", new CampaignGmSummary(Guid.NewGuid(), "GM"), 1)]);
}
};
@@ -97,6 +97,7 @@ public sealed class WorkspaceQueryServiceTests
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<CampaignRollDetail> GetRollDetail(string sessionToken, Guid rollId) => throw new NotSupportedException();
public ServiceResult<CampaignStateSnapshot> GetCampaignStateSnapshot(string sessionToken, Guid campaignId) => throw new NotSupportedException();
}
}

View File

@@ -15,6 +15,7 @@ public static class ApiEndpointRegistration
authenticatedApi.MapCharacterEndpoints();
authenticatedApi.MapAdminEndpoints();
authenticatedApi.MapSkillEndpoints();
authenticatedApi.MapRollEndpoints();
authenticatedApi.MapStateEventEndpoints();
}
}

View File

@@ -0,0 +1,18 @@
using RpgRoller.Contracts;
using RpgRoller.Services;
namespace RpgRoller.Api;
internal static class RollEndpoints
{
public static RouteGroupBuilder MapRollEndpoints(this RouteGroupBuilder group)
{
group.MapGet("/rolls/{rollId:guid}", (Guid rollId, HttpContext context, IGameService game) =>
{
var result = game.GetRollDetail(context.GetRequiredSessionToken(), rollId);
return ApiResultMapper.ToApiResult(result);
});
return group;
}
}

View File

@@ -18,16 +18,37 @@
@foreach (var entry in CampaignLog)
{
<li class="log-entry @LogEntryCssClass(entry)">
<p><strong>@RollerLabel(entry)</strong> rolled <strong>@entry.SkillName</strong> with
<button type="button"
class="log-entry-toggle"
aria-expanded="@(ExpandedRollId == entry.RollId)"
@onclick="() => ToggleRollDetailRequested.InvokeAsync(entry.RollId)">
<p><strong>@entry.RollerLabel</strong> rolled <strong>@entry.SkillName</strong> with
<strong>@entry.CharacterName</strong></p>
<p class="roll-total inline">@entry.Result</p>
<RollDiceStrip Dice="entry.Dice" AriaLabel="Log roll dice"/>
<p>@entry.Breakdown</p>
<p class="log-meta"><span
class="badge @VisibilityBadgeCssClass(entry)">@VisibilityLabel(entry)</span>
<p class="log-summary">@entry.SummaryText</p>
<p class="log-meta"><span class="badge @entry.VisibilityStyle">@entry.VisibilityLabel</span>
<time
title="@entry.TimestampUtc.ToString("O")">@entry.TimestampUtc.ToLocalTime().ToString("g")</time>
</p>
</button>
@if (ExpandedRollId == entry.RollId)
{
<div class="log-detail">
@if (IsRollDetailLoading(entry.RollId))
{
<p class="muted">Loading roll detail...</p>
}
else if (!string.IsNullOrWhiteSpace(GetRollDetailError(entry.RollId)))
{
<p class="field-error">@GetRollDetailError(entry.RollId)</p>
}
else if (ResolveRollDetail(entry.RollId) is { } detail)
{
<RollDiceStrip Dice="detail.Dice" AriaLabel="Log roll dice"/>
<p>@detail.Breakdown</p>
}
</div>
}
</li>
}
</ul>

View File

@@ -47,17 +47,25 @@ public partial class CampaignLogPanel
public bool IsCampaignDataLoading { get; set; }
[Parameter]
public IReadOnlyList<CampaignLogEntry> CampaignLog { get; set; } = [];
public IReadOnlyList<CampaignLogListEntry> CampaignLog { get; set; } = [];
[Parameter]
public Func<CampaignLogEntry, string> RollerLabel { get; set; } = _ => string.Empty;
public Guid? ExpandedRollId { get; set; }
[Parameter]
public Func<CampaignLogEntry, string> LogEntryCssClass { get; set; } = _ => string.Empty;
public EventCallback<Guid> ToggleRollDetailRequested { get; set; }
[Parameter]
public Func<CampaignLogEntry, string> VisibilityLabel { get; set; } = _ => string.Empty;
public Func<Guid, CampaignRollDetail?> ResolveRollDetail { get; set; } = _ => null;
[Parameter]
public Func<CampaignLogEntry, string> VisibilityBadgeCssClass { get; set; } = _ => string.Empty;
public Func<Guid, bool> IsRollDetailLoading { get; set; } = _ => false;
[Parameter]
public Func<Guid, string?> GetRollDetailError { get; set; } = _ => null;
private static string LogEntryCssClass(CampaignLogListEntry entry)
{
return entry.VisibilityStyle;
}
}

View File

@@ -44,7 +44,7 @@
}
else
{
@if (SelectedCampaign.Characters.Count == 0)
@if (SelectedCampaign.Characters.Length == 0)
{
<p class="empty">No characters in this campaign yet.</p>
}

View File

@@ -11,7 +11,7 @@
{
<p class="empty">No campaign selected. Choose one in Campaign Management.</p>
}
else if (SelectedCampaign.Characters.Count == 0)
else if (SelectedCampaign.Characters.Length == 0)
{
<p class="empty">No characters in this campaign yet.</p>
}

View File

@@ -26,7 +26,7 @@ public partial class CharacterPanel
ShowCreateSkillModal = true;
}
private void OpenEditSkillModal(SkillSummary skill)
private void OpenEditSkillModal(CharacterSheetSkill skill)
{
EditingSkillId = skill.Id;
EditSkillInitialModel = new()
@@ -78,7 +78,7 @@ public partial class CharacterPanel
return Task.CompletedTask;
}
private Task OnEditSkillRequestedAsync(SkillSummary skill)
private Task OnEditSkillRequestedAsync(CharacterSheetSkill skill)
{
OpenEditSkillModal(skill);
return Task.CompletedTask;
@@ -103,7 +103,7 @@ public partial class CharacterPanel
ShowCreateSkillGroupModal = true;
}
private void OpenEditSkillGroupModal(SkillGroupSummary skillGroup)
private void OpenEditSkillGroupModal(CharacterSheetSkillGroup skillGroup)
{
EditingSkillGroupId = skillGroup.Id;
SkillGroupState.Model.Name = skillGroup.Name;
@@ -242,7 +242,7 @@ public partial class CharacterPanel
}
}
private bool SkillMatchesFilter(SkillSummary skill)
private bool SkillMatchesFilter(CharacterSheetSkill skill)
{
if (string.IsNullOrWhiteSpace(SkillFilterText))
return true;
@@ -297,10 +297,10 @@ public partial class CharacterPanel
public bool IsMutating { get; set; }
[Parameter]
public IReadOnlyList<SkillSummary> SelectedCharacterSkills { get; set; } = [];
public IReadOnlyList<CharacterSheetSkill> SelectedCharacterSkills { get; set; } = [];
[Parameter]
public IReadOnlyList<SkillGroupSummary> SelectedCharacterSkillGroups { get; set; } = [];
public IReadOnlyList<CharacterSheetSkillGroup> SelectedCharacterSkillGroups { get; set; } = [];
[Parameter]
public bool IsD6 { get; set; }
@@ -315,13 +315,13 @@ public partial class CharacterPanel
public Func<Guid, string> OwnerLabel { get; set; } = _ => string.Empty;
[Parameter]
public Func<SkillSummary, string> SkillDefinitionLabel { get; set; } = _ => string.Empty;
public Func<CharacterSheetSkill, string> SkillDefinitionLabel { get; set; } = _ => string.Empty;
[Parameter]
public Func<CharacterSummary, bool> CanEditCharacter { get; set; } = _ => false;
[Parameter]
public Func<SkillSummary, bool> CanEditSkill { get; set; } = _ => false;
public Func<CharacterSheetSkill, bool> CanEditSkill { get; set; } = _ => false;
[Parameter]
public EventCallback<Guid> CharacterSelected { get; set; }

View File

@@ -140,7 +140,7 @@ public partial class SkillFormModal
public Guid? EditingSkillId { get; set; }
[Parameter]
public IReadOnlyList<SkillGroupSummary> AvailableSkillGroups { get; set; } = [];
public IReadOnlyList<CharacterSheetSkillGroup> AvailableSkillGroups { get; set; } = [];
[Parameter]
public bool IsMutating { get; set; }

View File

@@ -14,7 +14,7 @@ public partial class SkillGroupBlock
public Guid? SkillGroupId { get; set; }
[Parameter]
public IReadOnlyList<SkillSummary> Skills { get; set; } = [];
public IReadOnlyList<CharacterSheetSkill> Skills { get; set; } = [];
[Parameter]
public bool IsMutating { get; set; }
@@ -35,16 +35,16 @@ public partial class SkillGroupBlock
public bool ShowGroupActions { get; set; }
[Parameter]
public Func<SkillSummary, bool> CanEditSkill { get; set; } = _ => false;
public Func<CharacterSheetSkill, bool> CanEditSkill { get; set; } = _ => false;
[Parameter]
public Func<SkillSummary, string> SkillDefinitionLabel { get; set; } = _ => string.Empty;
public Func<CharacterSheetSkill, string> SkillDefinitionLabel { get; set; } = _ => string.Empty;
[Parameter]
public EventCallback<Guid?> AddSkillRequested { get; set; }
[Parameter]
public EventCallback<SkillSummary> EditSkillRequested { get; set; }
public EventCallback<CharacterSheetSkill> EditSkillRequested { get; set; }
[Parameter]
public EventCallback<Guid> RollSkillRequested { get; set; }

View File

@@ -60,10 +60,11 @@
<CampaignLogPanel
IsCampaignDataLoading="IsCampaignDataLoading"
CampaignLog="PlayVisibleCampaignLog"
RollerLabel="RollerLabel"
LogEntryCssClass="LogEntryCssClass"
VisibilityLabel="VisibilityLabel"
VisibilityBadgeCssClass="VisibilityBadgeCssClass"/>
ExpandedRollId="ExpandedCampaignLogRollId"
ToggleRollDetailRequested="ToggleRollDetailAsync"
ResolveRollDetail="ResolveRollDetail"
IsRollDetailLoading="IsRollDetailLoading"
GetRollDetailError="GetRollDetailError"/>
</main>
<nav class="mobile-bottom-nav" aria-label="Play panel selector">
<button type="button" class="switch @(MobilePanel == "character" ? "active" : string.Empty)"

View File

@@ -177,6 +177,7 @@ public partial class Workspace : IAsyncDisposable
{
CampaignLog = [];
CampaignLogCursor = null;
ResetCampaignLogDetailState();
return;
}
@@ -185,7 +186,7 @@ public partial class Workspace : IAsyncDisposable
{
CampaignLog = page.Entries.ToList();
}
else if (page.Entries.Count > 0)
else if (page.Entries.Length > 0)
{
CampaignLog.AddRange(page.Entries);
if (CampaignLog.Count > CampaignLogWindowSize)
@@ -193,6 +194,7 @@ public partial class Workspace : IAsyncDisposable
}
CampaignLogCursor = page.Cursor ?? afterRollId;
TrimCampaignLogDetails();
}
private async Task RefreshCampaignScopeAsync()
@@ -207,6 +209,7 @@ public partial class Workspace : IAsyncDisposable
ConnectionState = "offline";
CurrentCampaignState = null;
CampaignLogCursor = null;
ResetCampaignLogDetailState();
return;
}
@@ -614,6 +617,35 @@ public partial class Workspace : IAsyncDisposable
.ToList();
}
private async Task ToggleRollDetailAsync(Guid rollId)
{
if (ExpandedCampaignLogRollId == rollId)
{
ExpandedCampaignLogRollId = null;
return;
}
ExpandedCampaignLogRollId = rollId;
CampaignLogDetailErrors.Remove(rollId);
if (CampaignLogDetails.ContainsKey(rollId) || CampaignLogDetailsLoading.Contains(rollId))
return;
CampaignLogDetailsLoading.Add(rollId);
try
{
CampaignLogDetails[rollId] = await WorkspaceQuery.GetRollDetailAsync(rollId);
}
catch (ApiRequestException ex)
{
CampaignLogDetailErrors[rollId] = ex.Message;
}
finally
{
CampaignLogDetailsLoading.Remove(rollId);
await InvokeAsync(StateHasChanged);
}
}
private async Task OnSkillCreatedAsync(Guid _)
{
await RefreshSelectedCharacterSheetAsync();
@@ -729,13 +761,12 @@ public partial class Workspace : IAsyncDisposable
}
}
private bool CanEditSkill(SkillSummary skill)
private bool CanEditSkill(CharacterSheetSkill skill)
{
if (SelectedCampaign is null)
if (SelectedCharacter is null)
return false;
var character = SelectedCampaign.Characters.FirstOrDefault(c => c.Id == skill.CharacterId);
return character is not null && CanEditCharacter(character);
return CanEditCharacter(SelectedCharacter);
}
[JSInvokable]
@@ -848,7 +879,7 @@ public partial class Workspace : IAsyncDisposable
private void SyncSelectedCharacter()
{
if (SelectedCampaign is null || SelectedCampaign.Characters.Count == 0)
if (SelectedCampaign is null || SelectedCampaign.Characters.Length == 0)
{
SelectedCharacterId = null;
return;
@@ -886,7 +917,7 @@ public partial class Workspace : IAsyncDisposable
return string.IsNullOrWhiteSpace(ownerDisplayName) ? "Unknown owner" : ownerDisplayName;
}
private string SkillDefinitionLabel(SkillSummary skill)
private string SkillDefinitionLabel(CharacterSheetSkill skill)
{
if (!string.Equals(SelectedCampaign?.RulesetId, "d6", StringComparison.OrdinalIgnoreCase))
return skill.DiceRollDefinition;
@@ -895,48 +926,44 @@ public partial class Workspace : IAsyncDisposable
return $"{skill.DiceRollDefinition}, wild {skill.WildDice}, {fumbleLabel}";
}
private string RollerLabel(CampaignLogEntry entry)
private CampaignRollDetail? ResolveRollDetail(Guid rollId)
{
if (User is not null && entry.RollerUserId == User.Id)
return "You";
if (SelectedCampaign is not null && entry.RollerUserId == SelectedCampaign.Gm.Id)
return "GM";
return entry.RollerDisplayName;
return CampaignLogDetails.GetValueOrDefault(rollId);
}
private string VisibilityLabel(CampaignLogEntry entry)
private bool IsRollDetailLoading(Guid rollId)
{
if (!string.Equals(entry.Visibility, "private", StringComparison.OrdinalIgnoreCase))
return "Public";
if (User is not null && entry.RollerUserId == User.Id)
return "Private (you)";
return IsCurrentUserGm ? "Private (GM view)" : "Private";
return CampaignLogDetailsLoading.Contains(rollId);
}
private string VisibilityBadgeCssClass(CampaignLogEntry entry)
private string? GetRollDetailError(Guid rollId)
{
if (!string.Equals(entry.Visibility, "private", StringComparison.OrdinalIgnoreCase))
return "public";
if (User is not null && entry.RollerUserId == User.Id)
return "private-self";
return IsCurrentUserGm ? "private-gm" : "private-generic";
return CampaignLogDetailErrors.GetValueOrDefault(rollId);
}
private string LogEntryCssClass(CampaignLogEntry entry)
private void ResetCampaignLogDetailState()
{
if (!string.Equals(entry.Visibility, "private", StringComparison.OrdinalIgnoreCase))
return "public";
ExpandedCampaignLogRollId = null;
CampaignLogDetails.Clear();
CampaignLogDetailsLoading.Clear();
CampaignLogDetailErrors.Clear();
}
if (User is not null && entry.RollerUserId == User.Id)
return "private-self";
private void TrimCampaignLogDetails()
{
var visibleRollIds = CampaignLog.Select(entry => entry.RollId).ToHashSet();
return IsCurrentUserGm ? "private-gm" : "private-generic";
foreach (var rollId in CampaignLogDetails.Keys.Where(rollId => !visibleRollIds.Contains(rollId)).ToArray())
CampaignLogDetails.Remove(rollId);
foreach (var rollId in CampaignLogDetailsLoading.Where(rollId => !visibleRollIds.Contains(rollId)).ToArray())
CampaignLogDetailsLoading.Remove(rollId);
foreach (var rollId in CampaignLogDetailErrors.Keys.Where(rollId => !visibleRollIds.Contains(rollId)).ToArray())
CampaignLogDetailErrors.Remove(rollId);
if (ExpandedCampaignLogRollId.HasValue && !visibleRollIds.Contains(ExpandedCampaignLogRollId.Value))
ExpandedCampaignLogRollId = null;
}
private void ClearAuthenticatedState()
@@ -951,6 +978,7 @@ public partial class Workspace : IAsyncDisposable
SelectedCharacterSkillGroups = [];
CampaignLog = [];
CampaignLogCursor = null;
ResetCampaignLogDetailState();
SelectedCharacterId = null;
LastRoll = null;
KnownUsernames = [];
@@ -1039,9 +1067,9 @@ public partial class Workspace : IAsyncDisposable
private CampaignRoster? SelectedCampaign { get; set; }
private List<CampaignSummary> Campaigns { get; set; } = [];
private List<CampaignOption> CharacterCampaignOptions { get; set; } = [];
private List<SkillSummary> SelectedCharacterSkills { get; set; } = [];
private List<SkillGroupSummary> SelectedCharacterSkillGroups { get; set; } = [];
private List<CampaignLogEntry> CampaignLog { get; set; } = [];
private List<CharacterSheetSkill> SelectedCharacterSkills { get; set; } = [];
private List<CharacterSheetSkillGroup> SelectedCharacterSkillGroups { get; set; } = [];
private List<CampaignLogListEntry> CampaignLog { get; set; } = [];
private List<RulesetDefinition> Rulesets { get; set; } = [];
private List<AdminUserSummary> AdminUsers { get; set; } = [];
private Guid? SelectedCharacterId { get; set; }
@@ -1075,6 +1103,10 @@ public partial class Workspace : IAsyncDisposable
private DotNetObjectReference<Workspace>? DotNetRef { get; set; }
private CampaignStateSnapshot? CurrentCampaignState { get; set; }
private Guid? CampaignLogCursor { get; set; }
private Guid? ExpandedCampaignLogRollId { get; set; }
private Dictionary<Guid, CampaignRollDetail> CampaignLogDetails { get; } = [];
private HashSet<Guid> CampaignLogDetailsLoading { get; } = [];
private Dictionary<Guid, string> CampaignLogDetailErrors { get; } = [];
[Parameter]
public EventCallback<string?> LoggedOut { get; set; }
@@ -1103,7 +1135,7 @@ public partial class Workspace : IAsyncDisposable
SelectedCampaign.Name,
SelectedCampaign.RulesetId,
SelectedCampaign.Gm,
ownedCharacters);
ownedCharacters.ToArray());
}
}
@@ -1111,43 +1143,37 @@ public partial class Workspace : IAsyncDisposable
{
get
{
if (PlaySelectedCampaign is null || PlaySelectedCampaign.Characters.Count == 0)
var playSelectedCampaign = PlaySelectedCampaign;
if (playSelectedCampaign is null || playSelectedCampaign.Characters.Length == 0)
return null;
if (SelectedCharacterId.HasValue)
{
var selectedCharacter = PlaySelectedCampaign.Characters.FirstOrDefault(character => character.Id == SelectedCharacterId.Value);
var selectedCharacter = playSelectedCampaign.Characters.FirstOrDefault(character => character.Id == SelectedCharacterId.Value);
if (selectedCharacter is not null)
return selectedCharacter;
}
if (ActiveCharacterId.HasValue)
{
var activeCharacter = PlaySelectedCampaign.Characters.FirstOrDefault(character => character.Id == ActiveCharacterId.Value);
var activeCharacter = playSelectedCampaign.Characters.FirstOrDefault(character => character.Id == ActiveCharacterId.Value);
if (activeCharacter is not null)
return activeCharacter;
}
return PlaySelectedCampaign.Characters[0];
return playSelectedCampaign.Characters[0];
}
}
private Guid? PlaySelectedCharacterId => PlaySelectedCharacter?.Id;
private List<SkillSummary> PlaySelectedCharacterSkills =>
private List<CharacterSheetSkill> PlaySelectedCharacterSkills =>
PlaySelectedCampaign is null || !PlaySelectedCharacterId.HasValue ? [] : SelectedCharacterSkills;
private List<SkillGroupSummary> PlaySelectedCharacterSkillGroups =>
private List<CharacterSheetSkillGroup> PlaySelectedCharacterSkillGroups =>
PlaySelectedCampaign is null || !PlaySelectedCharacterId.HasValue ? [] : SelectedCharacterSkillGroups;
private List<CampaignLogEntry> PlayVisibleCampaignLog =>
User is null
? CampaignLog.Where(entry => !string.Equals(entry.Visibility, "private", StringComparison.OrdinalIgnoreCase)).ToList()
: CampaignLog
.Where(entry =>
!string.Equals(entry.Visibility, "private", StringComparison.OrdinalIgnoreCase) ||
entry.RollerUserId == User.Id)
.ToList();
private List<CampaignLogListEntry> PlayVisibleCampaignLog => CampaignLog;
private bool IsCurrentUserGm =>
SelectedCampaign is not null && User is not null && SelectedCampaign.Gm.Id == User.Id;
@@ -1210,7 +1236,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 CampaignLogWindowSize = 50;
private const int ToastDurationMs = 3200;
private sealed record WorkspaceToast(Guid Id, string Message, bool IsError);

View File

@@ -56,6 +56,11 @@ public sealed class WorkspaceQueryService
return Task.FromResult(GetValue(m_GameService.GetCampaignLogPage(GetRequiredSessionToken(), campaignId, afterRollId, limit)));
}
public Task<CampaignRollDetail> GetRollDetailAsync(Guid rollId)
{
return Task.FromResult(GetValue(m_GameService.GetRollDetail(GetRequiredSessionToken(), rollId)));
}
public Task<IReadOnlyList<AdminUserSummary>> GetAdminUsersAsync()
{
return Task.FromResult(GetValue(m_GameService.GetUsers(GetRequiredSessionToken())));

View File

@@ -20,9 +20,11 @@ public sealed record RulesetDefinition(string Id, string Name, string DiceSyntax
public sealed record CreateCampaignRequest(string Name, string RulesetId);
public sealed record CampaignSummary(Guid Id, string Name, string RulesetId, UserSummary Gm, int CharacterCount);
public sealed record CampaignGmSummary(Guid Id, string DisplayName);
public sealed record CampaignRoster(Guid Id, string Name, string RulesetId, UserSummary Gm, IReadOnlyList<CharacterSummary> Characters);
public sealed record CampaignSummary(Guid Id, string Name, string RulesetId, CampaignGmSummary Gm, int CharacterCount);
public sealed record CampaignRoster(Guid Id, string Name, string RulesetId, CampaignGmSummary Gm, CharacterSummary[] Characters);
public sealed record CampaignOption(Guid Id, string Name);
@@ -50,12 +52,20 @@ public sealed record RollDieResult(int Roll, bool Crit, bool Fumble, bool Wild,
public sealed record RollResult(Guid RollId, Guid CampaignId, Guid CharacterId, Guid SkillId, Guid RollerUserId, string Visibility, int Result, string Breakdown, IReadOnlyList<RollDieResult> Dice, DateTimeOffset TimestampUtc);
public sealed record CharacterSheet(Guid CharacterId, IReadOnlyList<SkillGroupSummary> SkillGroups, IReadOnlyList<SkillSummary> Skills);
public sealed record CharacterSheetSkillGroup(Guid Id, string Name, string DiceRollDefinition, int WildDice, bool AllowFumble);
public sealed record CharacterSheetSkill(Guid Id, Guid? SkillGroupId, string Name, string DiceRollDefinition, int WildDice, bool AllowFumble);
public sealed record CharacterSheet(Guid CharacterId, CharacterSheetSkillGroup[] SkillGroups, CharacterSheetSkill[] Skills);
public sealed record CampaignLogEntry(Guid RollId, Guid CampaignId, Guid CharacterId, string CharacterName, Guid SkillId, string SkillName, Guid RollerUserId, string RollerDisplayName, string Visibility, int Result, string Breakdown, IReadOnlyList<RollDieResult> Dice, DateTimeOffset TimestampUtc);
public sealed record CampaignLogListEntry(Guid RollId, string CharacterName, string SkillName, string RollerLabel, string VisibilityLabel, string VisibilityStyle, int Result, string SummaryText, DateTimeOffset TimestampUtc);
public sealed record CampaignRollDetail(Guid RollId, string Breakdown, RollDieResult[] Dice);
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);
public sealed record CampaignLogPage(CampaignLogListEntry[] Entries, Guid? Cursor, bool HasMore, bool ResetRequired);

View File

@@ -819,14 +819,14 @@ public sealed class GameService : IGameService
if (!afterRollId.HasValue)
{
var initialEntries = visibleEntries.TakeLast(pageSize).Select(ToLogEntry).ToArray();
var initialEntries = visibleEntries.TakeLast(pageSize).Select(entry => ToLogListEntry(user, campaign, entry)).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();
var replacementEntries = visibleEntries.TakeLast(pageSize).Select(entry => ToLogListEntry(user, campaign, entry)).ToArray();
return ServiceResult<CampaignLogPage>.Success(new CampaignLogPage(replacementEntries, replacementEntries.LastOrDefault()?.RollId, visibleEntries.Length > pageSize, true));
}
@@ -836,15 +836,34 @@ public sealed class GameService : IGameService
if (newEntries.Length > pageSize)
{
var replacementEntries = newEntries.TakeLast(pageSize).Select(ToLogEntry).ToArray();
var replacementEntries = newEntries.TakeLast(pageSize).Select(entry => ToLogListEntry(user, campaign, entry)).ToArray();
return ServiceResult<CampaignLogPage>.Success(new CampaignLogPage(replacementEntries, replacementEntries[^1].RollId, true, true));
}
var appendedEntries = newEntries.Select(ToLogEntry).ToArray();
var appendedEntries = newEntries.Select(entry => ToLogListEntry(user, campaign, entry)).ToArray();
return ServiceResult<CampaignLogPage>.Success(new CampaignLogPage(appendedEntries, appendedEntries[^1].RollId, false, false));
}
}
public ServiceResult<CampaignRollDetail> GetRollDetail(string sessionToken, Guid rollId)
{
lock (m_Gate)
{
var user = ResolveUserLocked(sessionToken);
if (user is null)
return ServiceResult<CampaignRollDetail>.Failure("unauthorized", "You must be logged in.");
var entry = m_RollLog.FirstOrDefault(candidate => candidate.Id == rollId);
if (entry is null)
return ServiceResult<CampaignRollDetail>.Failure("roll_not_found", "Roll was not found.");
if (!m_CampaignsById.TryGetValue(entry.CampaignId, out var campaign) || !CanViewRollLocked(user, campaign, entry))
return ServiceResult<CampaignRollDetail>.Failure("roll_not_found", "Roll was not found.");
return ServiceResult<CampaignRollDetail>.Success(new CampaignRollDetail(entry.Id, entry.Breakdown, DeserializeDice(entry.Dice).ToArray()));
}
}
public ServiceResult<CampaignStateSnapshot> GetCampaignStateSnapshot(string sessionToken, Guid campaignId)
{
lock (m_Gate)
@@ -1056,7 +1075,7 @@ public sealed class GameService : IGameService
{
var gm = m_UsersById[campaign.GmUserId];
var characterCount = m_CharactersById.Values.Count(character => character.CampaignId == campaign.Id);
return new(campaign.Id, campaign.Name, DiceRules.ToRulesetId(campaign.Ruleset), ToUserSummary(gm), characterCount);
return new(campaign.Id, campaign.Name, DiceRules.ToRulesetId(campaign.Ruleset), ToCampaignGmSummary(gm), characterCount);
}
private CampaignRoster ToCampaignRoster(Campaign campaign)
@@ -1068,7 +1087,7 @@ public sealed class GameService : IGameService
.Select(ToCharacterSummary)
.ToArray();
return new(campaign.Id, campaign.Name, DiceRules.ToRulesetId(campaign.Ruleset), ToUserSummary(gm), characters);
return new(campaign.Id, campaign.Name, DiceRules.ToRulesetId(campaign.Ruleset), ToCampaignGmSummary(gm), characters);
}
private CharacterSheet ToCharacterSheet(Guid characterId)
@@ -1076,12 +1095,12 @@ public sealed class GameService : IGameService
var skillGroups = m_SkillGroupsById.Values
.Where(group => group.CharacterId == characterId)
.OrderBy(group => group.Name, StringComparer.OrdinalIgnoreCase)
.Select(ToSkillGroupSummary)
.Select(ToCharacterSheetSkillGroup)
.ToArray();
var skills = m_SkillsById.Values
.Where(skill => skill.CharacterId == characterId)
.OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase)
.Select(ToSkillSummary)
.Select(ToCharacterSheetSkill)
.ToArray();
return new(characterId, skillGroups, skills);
@@ -1113,11 +1132,26 @@ public sealed class GameService : IGameService
.ThenBy(r => r.Id);
}
private static CampaignGmSummary ToCampaignGmSummary(UserAccount user)
{
return new(user.Id, user.DisplayName);
}
private static CharacterSheetSkillGroup ToCharacterSheetSkillGroup(SkillGroup skillGroup)
{
return new(skillGroup.Id, skillGroup.Name, skillGroup.DiceRollDefinition, skillGroup.WildDice, skillGroup.AllowFumble);
}
private static SkillGroupSummary ToSkillGroupSummary(SkillGroup skillGroup)
{
return new(skillGroup.Id, skillGroup.CharacterId, skillGroup.Name, skillGroup.DiceRollDefinition, skillGroup.WildDice, skillGroup.AllowFumble);
}
private static CharacterSheetSkill ToCharacterSheetSkill(Skill skill)
{
return new(skill.Id, skill.SkillGroupId, skill.Name, skill.DiceRollDefinition, skill.WildDice, skill.AllowFumble);
}
private static SkillSummary ToSkillSummary(Skill skill)
{
return new(skill.Id, skill.CharacterId, skill.SkillGroupId, skill.Name, skill.DiceRollDefinition, skill.WildDice, skill.AllowFumble);
@@ -1151,11 +1185,90 @@ public sealed class GameService : IGameService
entry.TimestampUtc);
}
private CampaignLogListEntry ToLogListEntry(UserAccount user, Campaign campaign, RollLogEntry entry)
{
var dice = DeserializeDice(entry.Dice);
var characterName = m_CharactersById.TryGetValue(entry.CharacterId, out var character) ? character.Name : "Unknown character";
var skillName = m_SkillsById.TryGetValue(entry.SkillId, out var skill) ? skill.Name : "Unknown skill";
return new(
entry.Id,
characterName,
skillName,
ResolveLogRollerLabel(user, campaign, entry),
ResolveLogVisibilityLabel(user, campaign, entry),
ResolveLogVisibilityStyle(user, campaign, entry),
entry.Result,
BuildCompactLogSummary(dice),
entry.TimestampUtc);
}
private static string SerializeDice(IReadOnlyList<RollDieResult> dice)
{
return JsonSerializer.Serialize(dice, DiceJsonOptions);
}
private static string BuildCompactLogSummary(IReadOnlyList<RollDieResult> dice)
{
if (dice.Count == 0)
return "No detail available.";
var preview = string.Join(", ", dice.Take(3).Select(die => die.Roll.ToString()));
if (dice.Count > 3)
preview = $"{preview}, ...";
var tags = new List<string>();
if (dice.Any(die => die.Wild))
tags.Add("wild");
if (dice.Any(die => die.Crit))
tags.Add("crit");
if (dice.Any(die => die.Fumble))
tags.Add("fumble");
return tags.Count == 0 ? preview : $"{preview} | {string.Join(", ", tags)}";
}
private bool CanViewRollLocked(UserAccount user, Campaign campaign, RollLogEntry entry)
{
return CanViewCampaignLocked(user.Id, campaign.Id) &&
(entry.Visibility == RollVisibility.Public || entry.RollerUserId == user.Id || campaign.GmUserId == user.Id);
}
private string ResolveLogRollerLabel(UserAccount user, Campaign campaign, RollLogEntry entry)
{
if (entry.RollerUserId == user.Id)
return "You";
if (entry.RollerUserId == campaign.GmUserId)
return "GM";
return ResolveOwnerDisplayName(entry.RollerUserId);
}
private static string ResolveLogVisibilityLabel(UserAccount user, Campaign campaign, RollLogEntry entry)
{
if (entry.Visibility != RollVisibility.Private)
return "Public";
if (entry.RollerUserId == user.Id)
return "Private (you)";
return campaign.GmUserId == user.Id ? "Private (GM view)" : "Private";
}
private static string ResolveLogVisibilityStyle(UserAccount user, Campaign campaign, RollLogEntry entry)
{
if (entry.Visibility != RollVisibility.Private)
return "public";
if (entry.RollerUserId == user.Id)
return "private-self";
return campaign.GmUserId == user.Id ? "private-gm" : "private-generic";
}
private string ResolveOwnerDisplayName(Guid ownerUserId)
{
return m_UsersById.TryGetValue(ownerUserId, out var owner) && !string.IsNullOrWhiteSpace(owner.DisplayName)

View File

@@ -39,6 +39,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<CampaignRollDetail> GetRollDetail(string sessionToken, Guid rollId);
ServiceResult<CampaignStateSnapshot> GetCampaignStateSnapshot(string sessionToken, Guid campaignId);
}

View File

@@ -581,8 +581,22 @@ select:focus-visible {
.log-entry {
border: 1px solid #b8a37b;
border-radius: 0.55rem;
padding: 0.5rem;
background: #f8f0de;
overflow: hidden;
}
.log-entry-toggle {
width: 100%;
text-align: left;
border: 0;
background: transparent;
padding: 0.5rem;
color: inherit;
cursor: pointer;
}
.log-entry-toggle p {
margin: 0.2rem 0;
}
.log-entry.private-self {
@@ -602,6 +616,16 @@ select:focus-visible {
align-items: center;
}
.log-summary {
color: var(--muted);
font-size: 0.92rem;
}
.log-detail {
border-top: 1px solid rgba(0, 0, 0, 0.08);
padding: 0 0.5rem 0.5rem;
}
.badge {
display: inline-flex;
border-radius: 999px;