Split campaign log summary from detail
This commit is contained in:
@@ -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