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`. - 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 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`. - OpenAPI contract source remains at `openapi/RpgRoller.json`.
## Test and Coverage ## 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"); 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[2], initialPage.Entries[0].RollId);
Assert.Equal(rollIds[^1], initialPage.Entries[^1].RollId); Assert.Equal(rollIds[^1], initialPage.Entries[^1].RollId);
Assert.Equal(rollIds[^1], initialPage.Cursor); Assert.Equal(rollIds[^1], initialPage.Cursor);
Assert.True(initialPage.HasMore); Assert.True(initialPage.HasMore);
Assert.False(initialPage.ResetRequired); 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 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"); 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.Equal(latestRoll.RollId, incrementalPage.Cursor);
Assert.False(incrementalPage.HasMore); Assert.False(incrementalPage.HasMore);
Assert.False(incrementalPage.ResetRequired); 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.Single(observerLogPage.Entries);
Assert.Equal(publicRoll.RollId, observerLogPage.Entries[0].RollId); Assert.Equal(publicRoll.RollId, observerLogPage.Entries[0].RollId);
Assert.Equal(publicRoll.RollId, observerLogPage.Cursor); 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 RegisterAsync(outsiderClient, "outsider", "Password123", "Outsider");
await LoginAsync(outsiderClient, "outsider", "Password123"); await LoginAsync(outsiderClient, "outsider", "Password123");
@@ -61,6 +69,9 @@ public sealed class RollVisibilityApiTests : ApiTestBase
var forbiddenCampaign = await outsiderClient.GetAsync($"/api/campaigns/{campaign.Id}"); var forbiddenCampaign = await outsiderClient.GetAsync($"/api/campaigns/{campaign.Id}");
Assert.Equal(HttpStatusCode.BadRequest, forbiddenCampaign.StatusCode); 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")); var invalidVisibility = await playerClient.PostAsJsonAsync($"/api/skills/{skill.Id}/roll", new RollSkillRequest("hidden"));
Assert.Equal(HttpStatusCode.BadRequest, invalidVisibility.StatusCode); 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)); _ = ServiceTestSupport.GetValue(service.CreateSkill(otherSession, otherCharacter.Id, "Perception", "1D+2", 1, true));
var ownerView = ServiceTestSupport.GetValue(service.GetCampaign(ownerSession, campaign.Id)); 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 == ownerCharacter.Id);
Assert.Contains(ownerView.Characters, character => character.Id == otherCharacter.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); rollIds.Add(ServiceTestSupport.GetValue(service.RollSkill(ownerSession, skill.Id, "public")).RollId);
var initialPage = ServiceTestSupport.GetValue(service.GetCampaignLogPage(gmSession, campaign.Id, limit: 3)); 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[2], initialPage.Entries[0].RollId);
Assert.Equal(rollIds[^1], initialPage.Entries[^1].RollId); Assert.Equal(rollIds[^1], initialPage.Entries[^1].RollId);
Assert.Equal(rollIds[^1], initialPage.Cursor); Assert.Equal(rollIds[^1], initialPage.Cursor);
Assert.True(initialPage.HasMore); Assert.True(initialPage.HasMore);
Assert.False(initialPage.ResetRequired); 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 latestRoll = ServiceTestSupport.GetValue(service.RollSkill(ownerSession, skill.Id, "public"));
var incrementalPage = ServiceTestSupport.GetValue(service.GetCampaignLogPage(gmSession, campaign.Id, initialPage.Cursor, 3)); 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.ResetRequired);
Assert.True(gapPage.HasMore); 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[3], gapPage.Entries[0].RollId);
Assert.Equal(rollIds[^1], gapPage.Entries[^1].RollId); Assert.Equal(rollIds[^1], gapPage.Entries[^1].RollId);
Assert.Equal(rollIds[^1], gapPage.Cursor); 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 => GetCampaignsHandler = sessionToken =>
{ {
Assert.Equal("server-session", 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<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<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(); public ServiceResult<CampaignStateSnapshot> GetCampaignStateSnapshot(string sessionToken, Guid campaignId) => throw new NotSupportedException();
} }
} }

View File

@@ -15,6 +15,7 @@ public static class ApiEndpointRegistration
authenticatedApi.MapCharacterEndpoints(); authenticatedApi.MapCharacterEndpoints();
authenticatedApi.MapAdminEndpoints(); authenticatedApi.MapAdminEndpoints();
authenticatedApi.MapSkillEndpoints(); authenticatedApi.MapSkillEndpoints();
authenticatedApi.MapRollEndpoints();
authenticatedApi.MapStateEventEndpoints(); 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) @foreach (var entry in CampaignLog)
{ {
<li class="log-entry @LogEntryCssClass(entry)"> <li class="log-entry @LogEntryCssClass(entry)">
<p><strong>@RollerLabel(entry)</strong> rolled <strong>@entry.SkillName</strong> with <button type="button"
<strong>@entry.CharacterName</strong></p> class="log-entry-toggle"
<p class="roll-total inline">@entry.Result</p> aria-expanded="@(ExpandedRollId == entry.RollId)"
<RollDiceStrip Dice="entry.Dice" AriaLabel="Log roll dice"/> @onclick="() => ToggleRollDetailRequested.InvokeAsync(entry.RollId)">
<p>@entry.Breakdown</p> <p><strong>@entry.RollerLabel</strong> rolled <strong>@entry.SkillName</strong> with
<p class="log-meta"><span <strong>@entry.CharacterName</strong></p>
class="badge @VisibilityBadgeCssClass(entry)">@VisibilityLabel(entry)</span> <p class="roll-total inline">@entry.Result</p>
<time <p class="log-summary">@entry.SummaryText</p>
title="@entry.TimestampUtc.ToString("O")">@entry.TimestampUtc.ToLocalTime().ToString("g")</time> <p class="log-meta"><span class="badge @entry.VisibilityStyle">@entry.VisibilityLabel</span>
</p> <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> </li>
} }
</ul> </ul>

View File

@@ -47,17 +47,25 @@ public partial class CampaignLogPanel
public bool IsCampaignDataLoading { get; set; } public bool IsCampaignDataLoading { get; set; }
[Parameter] [Parameter]
public IReadOnlyList<CampaignLogEntry> CampaignLog { get; set; } = []; public IReadOnlyList<CampaignLogListEntry> CampaignLog { get; set; } = [];
[Parameter] [Parameter]
public Func<CampaignLogEntry, string> RollerLabel { get; set; } = _ => string.Empty; public Guid? ExpandedRollId { get; set; }
[Parameter] [Parameter]
public Func<CampaignLogEntry, string> LogEntryCssClass { get; set; } = _ => string.Empty; public EventCallback<Guid> ToggleRollDetailRequested { get; set; }
[Parameter] [Parameter]
public Func<CampaignLogEntry, string> VisibilityLabel { get; set; } = _ => string.Empty; public Func<Guid, CampaignRollDetail?> ResolveRollDetail { get; set; } = _ => null;
[Parameter] [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 else
{ {
@if (SelectedCampaign.Characters.Count == 0) @if (SelectedCampaign.Characters.Length == 0)
{ {
<p class="empty">No characters in this campaign yet.</p> <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> <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> <p class="empty">No characters in this campaign yet.</p>
} }

View File

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

View File

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

View File

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

View File

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

View File

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

@@ -56,6 +56,11 @@ public sealed class WorkspaceQueryService
return Task.FromResult(GetValue(m_GameService.GetCampaignLogPage(GetRequiredSessionToken(), campaignId, afterRollId, limit))); 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() public Task<IReadOnlyList<AdminUserSummary>> GetAdminUsersAsync()
{ {
return Task.FromResult(GetValue(m_GameService.GetUsers(GetRequiredSessionToken()))); 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 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); 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 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 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 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); 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) 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)); return ServiceResult<CampaignLogPage>.Success(new CampaignLogPage(initialEntries, initialEntries.LastOrDefault()?.RollId, visibleEntries.Length > pageSize, false));
} }
var afterIndex = Array.FindIndex(visibleEntries, entry => entry.Id == afterRollId.Value); var afterIndex = Array.FindIndex(visibleEntries, entry => entry.Id == afterRollId.Value);
if (afterIndex < 0) 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)); 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) 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)); 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)); 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) public ServiceResult<CampaignStateSnapshot> GetCampaignStateSnapshot(string sessionToken, Guid campaignId)
{ {
lock (m_Gate) lock (m_Gate)
@@ -1056,7 +1075,7 @@ public sealed class GameService : IGameService
{ {
var gm = m_UsersById[campaign.GmUserId]; var gm = m_UsersById[campaign.GmUserId];
var characterCount = m_CharactersById.Values.Count(character => character.CampaignId == campaign.Id); 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) private CampaignRoster ToCampaignRoster(Campaign campaign)
@@ -1068,7 +1087,7 @@ public sealed class GameService : IGameService
.Select(ToCharacterSummary) .Select(ToCharacterSummary)
.ToArray(); .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) private CharacterSheet ToCharacterSheet(Guid characterId)
@@ -1076,12 +1095,12 @@ public sealed class GameService : IGameService
var skillGroups = m_SkillGroupsById.Values var skillGroups = m_SkillGroupsById.Values
.Where(group => group.CharacterId == characterId) .Where(group => group.CharacterId == characterId)
.OrderBy(group => group.Name, StringComparer.OrdinalIgnoreCase) .OrderBy(group => group.Name, StringComparer.OrdinalIgnoreCase)
.Select(ToSkillGroupSummary) .Select(ToCharacterSheetSkillGroup)
.ToArray(); .ToArray();
var skills = m_SkillsById.Values var skills = m_SkillsById.Values
.Where(skill => skill.CharacterId == characterId) .Where(skill => skill.CharacterId == characterId)
.OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase) .OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase)
.Select(ToSkillSummary) .Select(ToCharacterSheetSkill)
.ToArray(); .ToArray();
return new(characterId, skillGroups, skills); return new(characterId, skillGroups, skills);
@@ -1113,11 +1132,26 @@ public sealed class GameService : IGameService
.ThenBy(r => r.Id); .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) 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);
} }
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) private static SkillSummary ToSkillSummary(Skill skill)
{ {
return new(skill.Id, skill.CharacterId, skill.SkillGroupId, skill.Name, skill.DiceRollDefinition, skill.WildDice, skill.AllowFumble); 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); 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) private static string SerializeDice(IReadOnlyList<RollDieResult> dice)
{ {
return JsonSerializer.Serialize(dice, DiceJsonOptions); 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) private string ResolveOwnerDisplayName(Guid ownerUserId)
{ {
return m_UsersById.TryGetValue(ownerUserId, out var owner) && !string.IsNullOrWhiteSpace(owner.DisplayName) 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<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<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); ServiceResult<CampaignStateSnapshot> GetCampaignStateSnapshot(string sessionToken, Guid campaignId);
} }

View File

@@ -581,8 +581,22 @@ select:focus-visible {
.log-entry { .log-entry {
border: 1px solid #b8a37b; border: 1px solid #b8a37b;
border-radius: 0.55rem; border-radius: 0.55rem;
padding: 0.5rem;
background: #f8f0de; 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 { .log-entry.private-self {
@@ -602,6 +616,16 @@ select:focus-visible {
align-items: center; 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 { .badge {
display: inline-flex; display: inline-flex;
border-radius: 999px; border-radius: 999px;