Improve campaign log roll cards
This commit is contained in:
@@ -18,7 +18,7 @@
|
||||
@foreach (var entry in CampaignLog)
|
||||
{
|
||||
var isExpanded = ExpandedRollId == entry.RollId;
|
||||
<li class="log-entry @LogEntryCssClass(entry) @(isExpanded ? "expanded" : string.Empty)">
|
||||
<li class="log-entry @LogEntryCssClass(entry, isExpanded, FreshRollId == entry.RollId)">
|
||||
<button type="button"
|
||||
class="log-entry-toggle"
|
||||
aria-expanded="@isExpanded"
|
||||
@@ -33,6 +33,19 @@
|
||||
</span>
|
||||
<span class="roll-total inline">@entry.Result</span>
|
||||
</span>
|
||||
@if (HasSummary(entry))
|
||||
{
|
||||
<span class="log-summary-row">
|
||||
@foreach (var badge in GetEventBadges(entry))
|
||||
{
|
||||
<span class="log-event-badge @badge.Tone">@badge.Label</span>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(entry.SummaryText))
|
||||
{
|
||||
<span class="log-summary-text">@entry.SummaryText</span>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
<span class="log-meta"><span class="badge @entry.VisibilityStyle">@entry.VisibilityLabel</span>
|
||||
<time
|
||||
title="@entry.TimestampUtc.ToString("O")">@entry.TimestampUtc.ToLocalTime().ToString("g")</time>
|
||||
|
||||
@@ -52,6 +52,9 @@ public partial class CampaignLogPanel
|
||||
[Parameter]
|
||||
public Guid? ExpandedRollId { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public Guid? FreshRollId { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<Guid> ToggleRollDetailRequested { get; set; }
|
||||
|
||||
@@ -64,8 +67,46 @@ public partial class CampaignLogPanel
|
||||
[Parameter]
|
||||
public Func<Guid, string?> GetRollDetailError { get; set; } = _ => null;
|
||||
|
||||
private static string LogEntryCssClass(CampaignLogListEntry entry)
|
||||
private static IReadOnlyList<EventBadgeView> GetEventBadges(CampaignLogListEntry entry)
|
||||
{
|
||||
return entry.VisibilityStyle;
|
||||
return (entry.EventBadges ?? [])
|
||||
.Select(ToEventBadgeView)
|
||||
.Where(badge => badge is not null)
|
||||
.Cast<EventBadgeView>()
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static bool HasSummary(CampaignLogListEntry entry)
|
||||
{
|
||||
return (entry.EventBadges?.Length ?? 0) > 0 || !string.IsNullOrWhiteSpace(entry.SummaryText);
|
||||
}
|
||||
|
||||
private static EventBadgeView? ToEventBadgeView(string code)
|
||||
{
|
||||
return code switch
|
||||
{
|
||||
"w6" => new EventBadgeView("Wild 6", "positive"),
|
||||
"w1" => new EventBadgeView("Wild 1", "danger"),
|
||||
"n20" => new EventBadgeView("Nat 20", "positive"),
|
||||
"n1" => new EventBadgeView("Nat 1", "danger"),
|
||||
"rf" => new EventBadgeView("Fumble", "danger"),
|
||||
"r100" => new EventBadgeView("100", "rare"),
|
||||
"r66" => new EventBadgeView("66", "rare"),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static string LogEntryCssClass(CampaignLogListEntry entry, bool isExpanded, bool isFresh)
|
||||
{
|
||||
var classes = new List<string> { entry.VisibilityStyle };
|
||||
if (isExpanded)
|
||||
classes.Add("expanded");
|
||||
|
||||
if (isFresh)
|
||||
classes.Add("fresh");
|
||||
|
||||
return string.Join(" ", classes);
|
||||
}
|
||||
|
||||
private sealed record EventBadgeView(string Label, string Tone);
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
IsCampaignDataLoading="IsCampaignDataLoading"
|
||||
CampaignLog="PlayVisibleCampaignLog"
|
||||
ExpandedRollId="ExpandedCampaignLogRollId"
|
||||
FreshRollId="FreshCampaignLogRollId"
|
||||
ToggleRollDetailRequested="ToggleRollDetailAsync"
|
||||
ResolveRollDetail="ResolveRollDetail"
|
||||
IsRollDetailLoading="IsRollDetailLoading"
|
||||
|
||||
@@ -181,7 +181,9 @@ public partial class Workspace : IAsyncDisposable
|
||||
return;
|
||||
}
|
||||
|
||||
var previousLogCount = CampaignLog.Count;
|
||||
var page = await WorkspaceQuery.GetCampaignLogPageAsync(SelectedCampaignId.Value, afterRollId, CampaignLogWindowSize);
|
||||
Guid? newestRollId = null;
|
||||
if (!afterRollId.HasValue || page.ResetRequired)
|
||||
{
|
||||
CampaignLog = page.Entries.ToList();
|
||||
@@ -193,8 +195,32 @@ public partial class Workspace : IAsyncDisposable
|
||||
CampaignLog = CampaignLog.TakeLast(CampaignLogWindowSize).ToList();
|
||||
}
|
||||
|
||||
var shouldAutoExpandNewest = afterRollId.HasValue && page.Entries.Length > 0;
|
||||
if (!shouldAutoExpandNewest &&
|
||||
!afterRollId.HasValue &&
|
||||
CurrentCampaignState is not null &&
|
||||
previousLogCount == 0 &&
|
||||
page.Entries.Length > 0)
|
||||
{
|
||||
shouldAutoExpandNewest = true;
|
||||
}
|
||||
|
||||
if (shouldAutoExpandNewest)
|
||||
{
|
||||
newestRollId = page.Entries[^1].RollId;
|
||||
ExpandedCampaignLogRollId = newestRollId;
|
||||
FreshCampaignLogRollId = newestRollId;
|
||||
}
|
||||
else if (!afterRollId.HasValue)
|
||||
{
|
||||
FreshCampaignLogRollId = null;
|
||||
}
|
||||
|
||||
CampaignLogCursor = page.Cursor ?? afterRollId;
|
||||
TrimCampaignLogDetails();
|
||||
|
||||
if (newestRollId.HasValue)
|
||||
await EnsureRollDetailLoadedAsync(newestRollId.Value);
|
||||
}
|
||||
|
||||
private async Task RefreshCampaignScopeAsync()
|
||||
@@ -622,28 +648,14 @@ public partial class Workspace : IAsyncDisposable
|
||||
if (ExpandedCampaignLogRollId == rollId)
|
||||
{
|
||||
ExpandedCampaignLogRollId = null;
|
||||
if (FreshCampaignLogRollId == rollId)
|
||||
FreshCampaignLogRollId = 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);
|
||||
}
|
||||
FreshCampaignLogRollId = null;
|
||||
await EnsureRollDetailLoadedAsync(rollId);
|
||||
}
|
||||
|
||||
private async Task OnSkillCreatedAsync(Guid _)
|
||||
@@ -706,8 +718,11 @@ public partial class Workspace : IAsyncDisposable
|
||||
try
|
||||
{
|
||||
LastRoll = await ApiClient.RequestAsync<RollResult>("POST", $"/api/skills/{skillId}/roll", new RollSkillRequest(RollVisibility));
|
||||
CampaignLogDetails[LastRoll.RollId] = ToCampaignRollDetail(LastRoll);
|
||||
CampaignLogDetailErrors.Remove(LastRoll.RollId);
|
||||
|
||||
await RefreshCampaignLogAsync(CampaignLogCursor);
|
||||
PromoteFreshRoll(LastRoll.RollId);
|
||||
ResetCampaignStateTracking();
|
||||
SetStatus("Roll recorded.", false);
|
||||
Announce("Roll result updated.");
|
||||
@@ -949,6 +964,7 @@ public partial class Workspace : IAsyncDisposable
|
||||
private void ResetCampaignLogDetailState()
|
||||
{
|
||||
ExpandedCampaignLogRollId = null;
|
||||
FreshCampaignLogRollId = null;
|
||||
CampaignLogDetails.Clear();
|
||||
CampaignLogDetailsLoading.Clear();
|
||||
CampaignLogDetailErrors.Clear();
|
||||
@@ -969,6 +985,45 @@ public partial class Workspace : IAsyncDisposable
|
||||
|
||||
if (ExpandedCampaignLogRollId.HasValue && !visibleRollIds.Contains(ExpandedCampaignLogRollId.Value))
|
||||
ExpandedCampaignLogRollId = null;
|
||||
|
||||
if (FreshCampaignLogRollId.HasValue && !visibleRollIds.Contains(FreshCampaignLogRollId.Value))
|
||||
FreshCampaignLogRollId = null;
|
||||
}
|
||||
|
||||
private async Task EnsureRollDetailLoadedAsync(Guid 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 static CampaignRollDetail ToCampaignRollDetail(RollResult roll)
|
||||
{
|
||||
return new CampaignRollDetail(roll.RollId, roll.Breakdown, roll.Dice.ToArray());
|
||||
}
|
||||
|
||||
private void PromoteFreshRoll(Guid rollId)
|
||||
{
|
||||
if (!CampaignLog.Any(entry => entry.RollId == rollId))
|
||||
return;
|
||||
|
||||
ExpandedCampaignLogRollId = rollId;
|
||||
FreshCampaignLogRollId = rollId;
|
||||
}
|
||||
|
||||
private void ClearAuthenticatedState()
|
||||
@@ -1109,6 +1164,7 @@ public partial class Workspace : IAsyncDisposable
|
||||
private CampaignStateSnapshot? CurrentCampaignState { get; set; }
|
||||
private Guid? CampaignLogCursor { get; set; }
|
||||
private Guid? ExpandedCampaignLogRollId { get; set; }
|
||||
private Guid? FreshCampaignLogRollId { get; set; }
|
||||
private Dictionary<Guid, CampaignRollDetail> CampaignLogDetails { get; } = [];
|
||||
private HashSet<Guid> CampaignLogDetailsLoading { get; } = [];
|
||||
private Dictionary<Guid, string> CampaignLogDetailErrors { get; } = [];
|
||||
|
||||
@@ -104,7 +104,17 @@ public sealed record CharacterSheet(Guid CharacterId, CharacterSheetSkillGroup[]
|
||||
|
||||
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 CampaignLogListEntry(
|
||||
Guid RollId,
|
||||
string CharacterName,
|
||||
string SkillName,
|
||||
string RollerLabel,
|
||||
string VisibilityLabel,
|
||||
string VisibilityStyle,
|
||||
int Result,
|
||||
string SummaryText,
|
||||
[property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string[]? EventBadges,
|
||||
DateTimeOffset TimestampUtc);
|
||||
|
||||
public sealed record CampaignRollDetail(Guid RollId, string Breakdown, RollDieResult[] Dice);
|
||||
|
||||
|
||||
@@ -1348,6 +1348,7 @@ public sealed class GameService : IGameService
|
||||
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";
|
||||
var eventBadges = BuildCompactLogEventBadges(campaign, skill, dice);
|
||||
|
||||
return new(
|
||||
entry.Id,
|
||||
@@ -1358,6 +1359,7 @@ public sealed class GameService : IGameService
|
||||
ResolveLogVisibilityStyle(user, campaign, entry),
|
||||
entry.Result,
|
||||
BuildCompactLogSummary(dice),
|
||||
eventBadges,
|
||||
entry.TimestampUtc);
|
||||
}
|
||||
|
||||
@@ -1378,17 +1380,7 @@ public sealed class GameService : IGameService
|
||||
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)}";
|
||||
return preview;
|
||||
}
|
||||
|
||||
private static string BuildRolemasterCompactLogSummary(IReadOnlyList<RollDieResult> dice)
|
||||
@@ -1433,6 +1425,55 @@ public sealed class GameService : IGameService
|
||||
RollDieKinds.RolemasterOpenEndedLowSubtract;
|
||||
}
|
||||
|
||||
private static string[]? BuildCompactLogEventBadges(Campaign campaign, Skill? skill, IReadOnlyList<RollDieResult> dice)
|
||||
{
|
||||
var badges = new List<string>();
|
||||
|
||||
switch (campaign.Ruleset)
|
||||
{
|
||||
case RulesetKind.D6:
|
||||
AddBadgeIfMissing(badges, dice.Any(die => die.Wild && die.Roll == 6), "w6");
|
||||
AddBadgeIfMissing(badges, dice.Any(die => die.Wild && die.Roll == 1), "w1");
|
||||
break;
|
||||
case RulesetKind.Dnd5e:
|
||||
if (skill is not null && IsSingleD20Expression(skill.DiceRollDefinition))
|
||||
{
|
||||
AddBadgeIfMissing(badges, dice.Any(die => die.Roll == 20), "n20");
|
||||
AddBadgeIfMissing(badges, dice.Any(die => die.Roll == 1), "n1");
|
||||
}
|
||||
|
||||
break;
|
||||
case RulesetKind.Rolemaster:
|
||||
AddBadgeIfMissing(
|
||||
badges,
|
||||
dice.Any(die =>
|
||||
string.Equals(die.Kind, RollDieKinds.RolemasterOpenEndedInitial, StringComparison.Ordinal) &&
|
||||
!die.SignedContribution.HasValue),
|
||||
"rf");
|
||||
AddBadgeIfMissing(badges, dice.Any(die => die.Roll == 100), "r100");
|
||||
AddBadgeIfMissing(badges, dice.Any(die => die.Roll == 66), "r66");
|
||||
break;
|
||||
}
|
||||
|
||||
return badges.Count == 0 ? null : badges.ToArray();
|
||||
}
|
||||
|
||||
private static void AddBadgeIfMissing(List<string> badges, bool condition, string code)
|
||||
{
|
||||
if (!condition || badges.Any(badge => string.Equals(badge, code, StringComparison.Ordinal)))
|
||||
return;
|
||||
|
||||
badges.Add(code);
|
||||
}
|
||||
|
||||
private static bool IsSingleD20Expression(string expression)
|
||||
{
|
||||
var parsedExpression = DiceRules.ParseExpression(RulesetKind.Dnd5e, expression);
|
||||
return parsedExpression.Succeeded &&
|
||||
parsedExpression.Value!.DiceCount == 1 &&
|
||||
parsedExpression.Value.Sides == 20;
|
||||
}
|
||||
|
||||
private bool CanViewRollLocked(UserAccount user, Campaign campaign, RollLogEntry entry)
|
||||
{
|
||||
return CanViewCampaignLocked(user.Id, campaign.Id) &&
|
||||
|
||||
@@ -662,6 +662,11 @@ select:focus-visible {
|
||||
border-color: color-mix(in srgb, var(--accent) 38%, var(--card-border) 62%);
|
||||
}
|
||||
|
||||
.log-entry.fresh {
|
||||
border-color: color-mix(in srgb, #c79913 52%, var(--card-border) 48%);
|
||||
box-shadow: 0 0.9rem 1.8rem rgba(199, 153, 19, 0.16);
|
||||
}
|
||||
|
||||
.log-entry-toggle:hover {
|
||||
background: color-mix(in srgb, var(--card) 84%, #ffffff 16%);
|
||||
}
|
||||
@@ -699,6 +704,51 @@ select:focus-visible {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.log-summary-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.log-summary-text {
|
||||
color: var(--muted);
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.log-event-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 1.5rem;
|
||||
padding: 0.12rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid transparent;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.log-event-badge.positive {
|
||||
border-color: #79a85d;
|
||||
background: #e7f6da;
|
||||
color: #235217;
|
||||
}
|
||||
|
||||
.log-event-badge.danger {
|
||||
border-color: #c56b5a;
|
||||
background: #ffe3dc;
|
||||
color: #7d1f17;
|
||||
}
|
||||
|
||||
.log-event-badge.rare {
|
||||
border-color: #b48b34;
|
||||
background: #fff1c7;
|
||||
color: #6d4c05;
|
||||
}
|
||||
|
||||
.log-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
Reference in New Issue
Block a user