Split campaign log summary from detail
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ public static class ApiEndpointRegistration
|
||||
authenticatedApi.MapCharacterEndpoints();
|
||||
authenticatedApi.MapAdminEndpoints();
|
||||
authenticatedApi.MapSkillEndpoints();
|
||||
authenticatedApi.MapRollEndpoints();
|
||||
authenticatedApi.MapStateEventEndpoints();
|
||||
}
|
||||
}
|
||||
|
||||
18
RpgRoller/Api/RollEndpoints.cs
Normal file
18
RpgRoller/Api/RollEndpoints.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
<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>
|
||||
<time
|
||||
title="@entry.TimestampUtc.ToString("O")">@entry.TimestampUtc.ToLocalTime().ToString("g")</time>
|
||||
</p>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -60,11 +60,12 @@
|
||||
<CampaignLogPanel
|
||||
IsCampaignDataLoading="IsCampaignDataLoading"
|
||||
CampaignLog="PlayVisibleCampaignLog"
|
||||
RollerLabel="RollerLabel"
|
||||
LogEntryCssClass="LogEntryCssClass"
|
||||
VisibilityLabel="VisibilityLabel"
|
||||
VisibilityBadgeCssClass="VisibilityBadgeCssClass"/>
|
||||
</main>
|
||||
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)"
|
||||
@onclick="SetMobilePanelCharacterAsync">Character
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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())));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user